-
Notifications
You must be signed in to change notification settings - Fork 272
BF - Fix io_orientation to process input axes by strength #1450
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
…onsistent labeling. Add regression test for handling competing axes in orientation determination.
|
After testing the new implementation on a few hundred images that have non-standard orientations (e.g. oblique planes). The previous bug seems to be resolved for most cases, however I still found one image for which loading the image assigns what should be an Axial plane to the Sagittal one. Even more, the old implementation (using sto_xyz:1 1.942228 -0.187633 -0.254019 109.614174
sto_xyz:2 1.563327 0.334649 -0.079706 -108.926643
sto_xyz:3 2.161888 -0.073427 0.285848 -85.799545
sto_xyz:4 0.000000 0.000000 0.000000 1.000000
sform_xorient Left-to-Right
sform_yorient Posterior-to-Anterior
sform_zorient Inferior-to-SuperiorLoading this image with another tool, like MITK, does seem to load the image in the correct orientation, assigning the axial plane to what the new implementation here assigns the sagittal one. |
|
I think I may be thinking about this differently. The goal of an affine is to accurately describe the orientation of an image as a vector space, and the axis codes are just human conveniences. When your data is highly oblique, there just isn't an axial plane in the data, there's a phase-encoding, frequency-encoding and slice axis. We can assign a "nearest" world axis to each, but the only way to be wrong here is to use a procedure that (a) gives unintuitive results when each axis is dominantly aligned with a different world axis or (b) assigns different labels when the axes are reordered. Looking at the affine you show, it is currently SAL by the naive implementation, but attempting to rotate to RAS will produce IAR. Here's the rotation matrix: What happens if you give MITK the |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1450 +/- ##
=======================================
Coverage 95.42% 95.42%
=======================================
Files 209 209
Lines 29814 29844 +30
Branches 4483 4485 +2
=======================================
+ Hits 28451 28480 +29
Misses 930 930
- Partials 433 434 +1 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| for in_ax in range(p): | ||
| # Process input axes from strongest to weakest (stable on ties) so a given | ||
| # dimension is labeled consistently regardless of the original order. | ||
| in_axes = np.argsort(np.min(-(RS**2), axis=0), kind='stable') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think RS is the right choice. The whole point of R is to produce a de-sheared rotation matrix.
In the case of the failing test, the RS matrix is:
array([[1.00000000e+00, 1.00000000e+00, 0.00000000e+00, 0.00000000e+00],
[0.00000000e+00, 2.22044605e-15, 0.00000000e+00, 0.00000000e+00],
[0.00000000e+00, 0.00000000e+00, 1.00000000e+00, 0.00000000e+00]])
Column 1 has a slightly higher magnitude than col 0, so ends up with a slightly higher maximum cosine in the desheared rotation (R):
array([[7.07106781e-01, 7.07106781e-01, 0.00000000e+00, 0.00000000e+00],
[7.85046229e-16, 7.85046229e-16, 0.00000000e+00, 0.00000000e+00],
[0.00000000e+00, 0.00000000e+00, 1.00000000e+00, 0.00000000e+00]])
If we passed in [[1, 1, 0, 0, 0], [0, 0, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 0, 0, 1]], then we would have a tie in both the max and argmax of the two columns, so choosing the first column makes sense and the stable argsort will achieve this. In the test case, we have a tie in argmax, but we can break the tie with max, which also solves the problem we were seeing with non-degenerate matrices.
I think the thing to do here is actually to update the test to check three conditions:
- Put the positive error in the first column, yielding the current expected result.
- Put a negative error in the second column, yielding the current expected result.
- Put a positive error in the second column (current test), yielding a swap of the axis labeled
nan.
I would be curious to hear @matthew-brett's thoughts here.
| affine = np.array( | ||
| [ | ||
| [1.12271041e-01, 7.70245194e-02, -2.08759499e00, 5.00499039e01], | ||
| [-5.34135476e-02, 1.58019245e-01, 1.04219818e00, -2.11098356e01], | ||
| [1.24289364e-01, -1.66752085e-03, 2.33361936e00, -8.56721640e01], | ||
| [0.00000000e00, 0.00000000e00, 0.00000000e00, 1.00000000e00], | ||
| ] | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the kind of thing I was thinking of:
tricky_affine = <iar_affine>
orig_ornt = io_orientation(tricky_affine)
assert_array_equal(orig_ornt, axcodes2ornt('IAR'))
orig_img = Nifti1Image(np.zeros((2, 3, 4)), tricky_affine)
ras_img = orig_img.as_reoriented(orig_ornt)
ras_ornt = io_orientation(ras_img.affine)
# Verify reorientation swaps first and third axes and the labels follow the axes
assert ras_img.shape == (4, 3, 2)
assert_array_equal(ras_ornt, [[0, 1], [1, 1], [2, 1]])
# Test idempotence
reras_img = ras_img.as_reoriented(ras_ornt)
reras_ornt = io_orientation(reras_img.affine)
assert reras_img.shape == (4, 3, 2)
assert_array_equal(reras_ornt, [[0, 1], [1, 1], [2, 1]])| # Duplicate the first column to make two axes compete for the same output | ||
| # axis. The fixed code (ordering by RS strengths) keeps the strongest axis | ||
| # and drops the duplicate; the buggy SVD-ordered variant would pick the | ||
| # wrong column. | ||
| dup_affine = affine.copy() | ||
| dup_affine[:3, 1] = dup_affine[:3, 0] | ||
| expected = np.array([[0, 1], [1, -1], [2, 1]], dtype=np.int8) | ||
| assert_array_equal(io_orientation(dup_affine), expected) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This doesn't really fit in this regression test, as it artificially changes the problem. The columns that were getting flipped were 1 and 3, not 1 and 2. What you're doing here is setting two columns to point mostly superior, then right, then posterior, and one to point mostly superior, then left, then anterior.
This is an interesting edge case, but not one that I think should push us into decent edge-case test, but then we need to agree that a rank-deficient matrix should be considered to be should be considered to have a particular, rather than just explainable, orientation.
| def buggy_io_orientation(aff): | ||
| # Replicates the pre-fix iteration order (range(p)) that could flip | ||
| # assignments when columns compete for the same output axis. | ||
| q, p = aff.shape[0] - 1, aff.shape[1] - 1 | ||
| rzs = aff[:q, :p] | ||
| zooms = np.sqrt(np.sum(rzs * rzs, axis=0)) | ||
| zooms[zooms == 0] = 1 | ||
| rs = rzs / zooms | ||
| P, S, Qs = np.linalg.svd(rs, full_matrices=False) | ||
| tol = S.max() * max(rs.shape) * np.finfo(S.dtype).eps | ||
| keep = S > tol | ||
| R = np.dot(P[:, keep], Qs[keep]) | ||
| ornt = np.ones((p, 2), dtype=np.int8) * np.nan | ||
| for in_ax in range(p): | ||
| col = R[:, in_ax] | ||
| if not np.allclose(col, 0): | ||
| out_ax = np.argmax(np.abs(col)) | ||
| ornt[in_ax, 0] = out_ax | ||
| ornt[in_ax, 1] = -1 if col[out_ax] < 0 else 1 | ||
| R[out_ax, :] = 0 | ||
| return ornt | ||
|
|
||
| # check that the buggy orientation is not the expected orientation | ||
| assert not np.array_equal(buggy_io_orientation(dup_affine), expected) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For a regression test, we just need to have plausible affines and their correct orientations. We do not need to replicate the old function, though. The test should fail if we revert the fix or otherwise change the code to no longer do what's expected, and pass if the fix remains in place.
This PR introduces changes aiming to make the axis assignment based on image affines more consistent. Instead of iterating over the axis in order
range(p), the axis are now iterated over based on thestrongestaxis according to the rotation-shear matrix (RS).Alternative implementation:
Add a parameter to
io_orientationcalledsort_by_strengthwhich would activate this new sorting method, while preserving the old behavior. This parameter should then be propagated toas_closest_canonicaland other places that might need it. In my opinion this might introduce more complexity and make it harder to maintain.Notes:
In principle the sorting could be done based on the rotation matrix
Rinstead ofRS. However this causestest_io_orientationto fail due to the check when additional columns are added to the affine.Closes #1449