Repository navigation
Fix(io): formats bound to RAS/LPS validate their endpoints; a transformation is never relabelled as a format (#343) - #360
Merged
Conversation
…o a format (#343) The generic family converters rebuild a transformation as whatever class they are asked for, so `t.to(Format)` relabelled a Scaling, a Linear, a chain... as a format whenever no converter of the format caught it, and io.save wrote the result. Each format now has explicit converters for every family that reaches it: exact ones for NIfTI (every affine family, SVF and CartesianField) and NiftyReg (any affine between declared world spaces, bridged to RAS), and `no_exact_conversion` refusals for FLIRT, LTA, the bare matrices, ITK, ITK NIfTI fields, FNIRT, M3Z, NiftyReg fields, X5, elastix and OME-Zarr until #312 adds exact ones. A format converted to its own class goes through the family rebuild (`_convert_withlog`, `_convert_withsplines`, `smart_replace`) instead of `replace`, which fixes `.inverse()`, `.to(matrix=...)` and `.to(log=...)` on a NiftyRegAffine and `.to(field=...)` on a NIfTI field of coordinates. Another variant of a format (Txt to Csv matrix, LTA vox-to-vox to RAS-to-RAS, TFM to H5) is refused rather than relabelled. Restores the `[scaling]` case of test_io_save and adds tests/test_io_into_formats.py. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…ead of refusing relabels (#343) Classes bound to coordinate systems now check, in their own `__post_init__`, that their endpoints are compatible with them (`check_endpoint`, which compares as `CoordinateSystem.compatible_with` does but leaves out axis names and compares a shorter system with the first axes): VoxelToRAS, RASToVoxel, VoxelToLPS, LPSToVoxel, RASToRAS (and so the NIfTI and NiftyReg affines), RAS/LPSCoordinatesField, RASToWarpField, WarpFieldToRAS and FlirtTransform. They raise the new IncompatibleSystemError, a ConversionError, so a relabel through the data model's converters fails as `t.to(Format)` refusing it. The refusals that only stopped relabels go: MatrixAffine (and its containers) and ITK chains now hold a relabelled transformation with its own endpoints, and the X5 and elastix converters were never reachable. FLIRT keeps a refusal that says why (its matrix depends on its images), as do LTA and the field formats that derive their systems from their content, where a relabel builds a wrong object. The exact NIfTI and NiftyReg converters stay, so that LPS endpoints are bridged; NiftyReg now takes unknown endpoints to be RAS, as NIfTI does, so a plain Affine is saved as NiftyReg (test updated). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
balbasty
commented
Oct 9, 2026
Rename base/conversions.py to base/_conversions.py and stop exporting it from the base package, so it leaves the API reference. Make the endpoint check and the standard systems of base/affines.py private, and move them below the classes. Docstrings that linked to them now describe the check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…md (#343) Rewrite the docstrings and comments that #360 adds or changes so they follow the documentation rules in CLAUDE.md: complete sentences that start with the purpose, no fragments or shorthand, fewer colons and dashes, NumPy sections with typed parameters, and a 72-column wrap. Define "relabelling" where the term is used. Move the "# io" section comment in nifti/_converters.py back above the io imports. The syntax trees are unchanged once docstrings are removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Since #315,
t.to(SomeFormat)relabelled any family member as the format. For example, aScalingfrom LPS to LPS became aNiftiVoxelToRASthat was still labelled LPS→LPS, andio.savewrote it.This PR follows the decision in #343 and its review: no
priority. Classes bound to semantic axes validate their endpoints, so a wrong relabel fails when the object is built. Explicit converters remain only where they bridge exactly, or where no validator can decide.Validators
Each class bound to a system has an explicit
__post_init__that checks its endpoints withcheck_endpoint(t, side, expected), a new module-level helper inio/transformations/base/affines.py.Classes:
VoxelToRAS,RASToVoxel,VoxelToLPS,LPSToVoxel,RASToRAS(inherited by the NIfTI and NiftyReg affines),RASCoordinatesField,LPSCoordinatesField(inherited byNiftiRASCoordinatesField),RASToWarpField/WarpFieldToRAS, andFlirtTransform.Compatible, not a subclass: an endpoint passes if it is compatible with the bound system.
None, unknown axes and open systems are accepted.CoordinateSystem.compatible_with, which otherwise rejects systems that are clearly the same:RASmmnames its axesx/y/zwhileRASCoordinateSystemnames them by orientation, andVoxelCoordinateSystemusesdim0…whileFVoxelCoordinateSystemusesi/j/k.This is done in io by rebuilding the axes without their names. Your call: a name-insensitive option in the datamodel's
compatible_withmight be the better home.The error is
brainhops.errors.IncompatibleSystemError(ConversionError). Building a class directly with the wrong endpoints raises it. Because it is aConversionError,t.to(...)also raises it (or returnsFalsewitherror=False), andio.savelists it as a reason.convertneeds no change.Converters
t.to(Format)NiftiVoxelToRAS/NiftiRASToVoxelaffine_between: LPS is bridged into RAS, and unknown systems are taken as the format's. An LPS→LPSScalingis still refused.NiftyRegAffineVoxelToRAS,RASToRAS, … (not formats)IncompatibleSystemError.MatrixAffine(all containers)_convert_withlog,_convert_withsplines,smart_replace), neverreplace.MatrixAffineand ITK chains, and NiftyReg's refusal of undeclared systems..to(matrix=...),inverse()and.to(log=False). This fixesNiftyRegAffine(...).inverse(),.to(matrix=...)and.to(log=False), which raised on main.Affinewith no declared systems is now saved as NiftyReg to.txt, consistent with "unknown means the format's systems". Main'stest_plain_affine_is_not_saved_as_niftyregbecametest_plain_affine_is_saved_as_niftyreg, which also checks the round trip, and a newtest_a_voxel_affine_is_not_saved_as_niftyregis added. Say if NiftyReg should instead keep refusing undeclared endpoints.MatrixAffineand ITK chains accept relabels, as described above.io.saveof a voxel→voxel transformation to.niior.txtis refused; main wrote it relabelled.Tests
[scaling]case oftest_a_transformation_no_nifti_format_holds_is_refused, an LPS→LPSScaling. It fails on main.tests/test_io_into_formats.py(251 tests):ConversionError, anderror=FalsereturnsFalse;MatrixAffineand ITK relabels are allowed, and NiftyReg accepts unknown endpoints;io.saverefusals.Checks
ruff check,ruff format --checkand codespell (with CI's ignore-words file) are clean.Closes #343
🤖 Generated with Claude Code
https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8