Repository navigation
Feat(io): convert general transformations exactly into the NIfTI and SPM formats (#120) - #314
Conversation
…ats (#120) Register converters into NiftiVoxelToRAS, NiftiRASToVoxel, NiftiRASDisplacementField, NiftiRASCoordinatesField and SpmCoordinatesField, from Affine, DisplacementField, CoordinatesField and Sequence. Each converter reads the endpoints of the transformation and puts the exact bridge to the format's systems around it (LPS to RAS, a pairing of voxel axes), as composition does, then either returns the very same map in the format or raises a ConversionError that says what the format cannot hold. Nothing is resampled or approximated: a field must be linearly interpolated with the nearest boundary (what NIfTI reads back), a displacement field must sit between a world-to-grid affine and its inverse, and displacements and coordinates are not converted into each other. The formats build themselves from another transformation through the same converters (ConvertedFormat), so `t.to(Format)`, `Format.from_instance(t)` and `Format.from_other(t)` are one conversion. `io.save` converts a transformation that no format holds as it is to the candidate it converts to, with the same converters. SPM `y_` fields are now written: the RAS coordinates and the inverse of `ras2voxel` as the grid affine. FNIRT warps become read-only, since their file does not hold the moving image their map depends on, so `save` no longer offers a format that could only raise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
…in (#120) Review follow-ups for the converters into the NIfTI and SPM formats. - A converter now takes only its format's own options (`header=`, and `log=`/`steps=` for a displacement field). `input=`, `output=` and the map keywords (`matrix=`, `field=`, `data=`, `transformations=`) are refused with a ConversionError, rather than relabelling or replacing the map after it was found exact. `log=True` on a field that holds a displacement, and `steps=` without `log=True`, are refused there too. - A field of coordinates without data is held without data, as a displacement field without data is, unless an affine follows it. - SPM refuses a nearly singular grid affine (condition number above 1e12) at conversion, rather than at write time. - The ConvertedFormat mixin is replaced by two module-level helpers (`converts_to`, `convert_instance`) and explicit `from_other` and `from_instance` on each of the five formats. - The read-only NIfTI base is private to FNIRT; it is kept as a class, since no order of its bases keeps FnirtWarpField's positional signature. - `check_coordinates` is public for SPM, and the `io.save` docstring explains its two passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
|
@copilot resolve the merge conflicts in this pull request |
…rt-into-formats # Conflicts: # docs/start/python.md # src/brainhops/io/base/_save.py # src/brainhops/io/transformations/fsl/fnirt/_base.py # src/brainhops/io/transformations/nifti/fields.py # src/brainhops/io/transformations/spm/y.py # tests/test_io_from_other.py # tests/test_io_save.py Co-authored-by: balbasty <7803834+balbasty@users.noreply.github.com>
Merged |
| @@ -0,0 +1,324 @@ | |||
| """ | |||
There was a problem hiding this comment.
This should be a private module (_converters).
There was a problem hiding this comment.
Renamed the module to _converters.py and updated registration imports. Included in d3db954.
| vox2ras = homogeneous_matrix(self.ras2voxel.inverse(), what, ndim=3) | ||
| # SPM stores sampled coordinates. | ||
| image = ras_coordinates_nifti( | ||
| self.rasfield.to(store="values").data, vox2ras |
There was a problem hiding this comment.
| self.rasfield.to(store="values").data, vox2ras | |
| self.rasfield.values, vox2ras |
I am pretty sure this is equivalent, but do check.
(also replace if this construct is used elsewhere)
There was a problem hiding this comment.
Verified and switched SPM serialization to self.rasfield.values; the SPM round-trip tests pass. Included in d3db954.
| @converter(_xforms.DisplacementField, SpmCoordinatesField) | ||
| @converter(_xforms.Sequence, SpmCoordinatesField) | ||
| @converter | ||
| def _( | ||
| t: _xforms.CoordinatesField, | ||
| cls: tx.Type[SpmCoordinatesField], | ||
| **kwargs, | ||
| ) -> SpmCoordinatesField: | ||
| # Exactly, or raise: an affine from RAS to a grid, then one field of | ||
| # coordinates on that grid, sampled as NIfTI stores it, followed by an | ||
| # affine into RAS. A field of displacements is refused there, with the | ||
| # reason. | ||
| cls = SpmCoordinatesField | ||
| format_options(t, cls, kwargs) | ||
| ras2voxel, field, voxel2ras = _spm_coordinates(t) | ||
| coordinates = ras_coordinate_values(t, field, voxel2ras, cls) | ||
| chain = ( | ||
| RASToVoxel(matrix=ras2voxel[:-1]), | ||
| RASCoordinatesField(field=coordinates), | ||
| ) | ||
| return cls(transformations=chain) | ||
|
|
||
|
|
||
| @converter | ||
| def _( | ||
| t: SpmCoordinatesField, | ||
| cls: tx.Type[SpmCoordinatesField], | ||
| **kwargs, | ||
| ) -> SpmCoordinatesField: | ||
| # Within its own format, a field is copied with the changes asked for. | ||
| return replace(t, **kwargs) if kwargs else t | ||
|
|
||
|
|
||
| def _spm_coordinates(t: _xforms.Transformation) -> tuple: | ||
| """ | ||
| `t` as an SPM deformation field, or the reason it is not. | ||
|
|
||
| Returns `(ras2voxel, field, voxel2ras)`: the homogeneous affine from | ||
| RAS to the grid of the field, the field of coordinates, and the affine | ||
| its coordinates are carried into RAS by. | ||
| """ | ||
| cls = SpmCoordinatesField | ||
| ras2voxel, field, voxel2ras = split_field_chain(t, RAS, RAS, cls) | ||
| check_coordinates(field, t, cls) | ||
| if np.linalg.cond(ras2voxel) > _MAX_CONDITION: | ||
| raise unrepresentable( | ||
| t, | ||
| cls, | ||
| "the affine before its field is singular, or nearly so, and SPM " | ||
| "stores the grid of the field by its inverse.", | ||
| ) | ||
| return ras2voxel, field, voxel2ras |
There was a problem hiding this comment.
I feel like this should go under spm._converters
There was a problem hiding this comment.
Moved SPM conversion registrations and helpers into spm/_converters.py; package initialization imports it to register converters. Included in d3db954.
| return image | ||
|
|
||
|
|
||
| def ras_coordinates_nifti( |
There was a problem hiding this comment.
This should be a private helper.
There was a problem hiding this comment.
Renamed the NIfTI coordinates image helper to _ras_coordinates_nifti and updated internal references. Included in d3db954.
| def to_nibabel(self, **kwargs) -> tx.NoReturn: | ||
| """FNIRT warps are read, not written (see the class notes).""" | ||
| raise WriterNotImplementedError( | ||
| "A FNIRT warp is read, not written: its file does not hold the " | ||
| "moving image its map depends on. Save it as a NIfTI " | ||
| "displacement field instead: " | ||
| "NiftiRASDisplacementField.from_any(warp).save(path)." | ||
| ) |
There was a problem hiding this comment.
It should be written nonetheless (users will have to know what were the moving/reference images)
There was a problem hiding this comment.
FNIRT objects can now write their stored field and header intent. The class documentation clarifies that moving/reference images and deformation interpretation remain external. Included in 2a318f9.
| if isinstance(xform, _xforms.Affine) | ||
| else xform.to(_xforms.Affine) |
There was a problem hiding this comment.
Do we need the if/else? I would assume that xform.to(_xforms.Affine) is pass through when xform is already an affine.
There was a problem hiding this comment.
I kept the branch: a format-specific affine subclass can convert to Affine without retaining its matrix, so reading homogeneous_matrix from the original affine is needed. The targeted writer and conversion tests pass.
|
@copilot Address review and fix failing tests |
Co-authored-by: balbasty <7803834+balbasty@users.noreply.github.com>
Co-authored-by: balbasty <7803834+balbasty@users.noreply.github.com>
Implements #120 with option (a): converters into the file-format classes look at the actual endpoint coordinate systems, not class names, and insert the bridge needed. The result is either exactly equivalent to the input as a map, or the conversion raises. No lossy fallback is included; that is the approximation policy, #50.
Converters
They are explicit, one per type pair, in
io/transformations/nifti/converters.pyandio/transformations/spm/y.py.Affine,SequenceNiftiVoxelToRAS,NiftiRASToVoxelDisplacementField,SequenceNiftiRASDisplacementFieldCoordinatesField,SequenceNiftiRASCoordinatesField,SpmCoordinatesFieldDisplacementField → SpmCoordinatesField. They are registered only so they refuse with the format's reason rather than "no converter found". See [FIX] DisplacementField → CoordinatesField changes the map outside the grid (and near the border for degree 3) #313: main'sDisplacementField → CoordinatesFieldconverter changes the map outside the grid.adaptors.bridgepairing, the same oneSequence.computeuses. LPS is flipped into RAS, voxel axes are paired by position (with the existing warning), and an open or unknown endpoint is taken to be the format's.affine_between,split_field_chain, …, inio/transformations/base/conversions.py). Every refusal goes throughunrepresentable(t, cls, reason), which raisesConversionError. That is where anapproximate=option ([FEAT] Writers: optionally reslice an unrepresentable transform onto the closest representable grid #50) would plug in.header; for displacement fields alsologandsteps). Endpoint and map keywords (input,output,matrix,field,data,transformations) are refused, so they can't relabel a result after the exactness check.nearest;Symmetry and
io.saveobj.to(Format),Format.from_instance(obj)andFormat.from_other(obj)give the same result and errors. Each of the five formats has a short explicitfrom_other/from_instancethat calls two module-level helpers (converts_to,convert_instance).io.savenow writes a plainAffine,DisplacementField,CoordinatesFieldorSequence. When no format already holds the object, it triesobj.to(fmt)for each candidate format, most specific first.AmbiguousFormatError.WriterErrorlisting each format's reason.The older pass (formats that "hold" the data model, copied with
from_instance) stays, documented as transitional, until every writable transformation format has a converter; that is [FEAT] Exact converters into the remaining transformation formats (ITK, NiftyReg, LTA, M3Z, X5, Elastix) #312.Writers
y_fields are now writable: RAS coordinates as values, with the VECTOR intent and the name "Mapping". They round-trip.moving=anddeformation_type=were passed again. Before, it was registered as writable but raisedWriterNotImplementedError.NiftiRASDisplacementField.from_other(fnirt)works, and is tested.Behaviour changes
io.savewrites general transformations to NIfTI. Before, they were refused with a pointer tofrom_other.NiftiVoxelToRAS.from_other(affine to LPS)now flips it into RAS. Before, it kept the matrix and labelled the output LPS. A world→world affine is now refused.NiftiRASDisplacementField.from_instancenow checks the chain instead of copying it unchecked.Review
An independent review (Fable) checked exactness numerically, in memory and after save/load, at nodes, between nodes and outside the grid:
coeff=Trueat degree 1;It confirmed that refusing cubic fields is correct, and that the conversion paths are symmetric. Its fixes are included in 8674d6e:
TypeError;ConvertedFormatmixin was replaced with explicit methods;Open decision
The new
conversions.undoestreats two affines as inverses within about 64·eps·‖A‖·‖B‖·n. The library's ownsequence._undoescompares exactly, so with realistic oblique headers it rejects a format's own[RASToVoxel, field, VoxelToRAS]chain, whereA @ inv(A)misses the identity by about 1e-15. There are two options:This needs the maintainer's call before merge.
Tests and checks
tests/test_io_convert_formats.pyhas 46 tests: maps compared exactly (matrices) or at points on, between and outside the nodes; LPS bridges; save/load round trips; refusals and their messages; options passed through or refused; symmetry; SPM writing; FNIRT read-only.ruff check,ruff format --checkand codespell are clean.Follow-ups: #312 (the other formats, and choosing between formats that share an extension), #313 (the displacement → coordinates map), #50 (the approximation policy).
Closes #120
🤖 Generated with Claude Code
https://claude.ai/code/session_01M3KmPTJ2CihFMCFaJ4twx8
Generated by Claude Code