Skip to content

ENH: Add transform analysis utils - #286

Open
jhlegarreta wants to merge 22 commits into
nipy:masterfrom
jhlegarreta:enh/add-transform-analysis-utils
Open

ENH: Add transform analysis utils#286
jhlegarreta wants to merge 22 commits into
nipy:masterfrom
jhlegarreta:enh/add-transform-analysis-utils

Conversation

@jhlegarreta

@jhlegarreta jhlegarreta commented Nov 1, 2025

Copy link
Copy Markdown

Add transform analysis utils.

Transfer contents from the NiFreeze projects so that hey can be reused across projects requiring transform analysis:
https://github.com/nipreps/nifreeze/blob/d27ba7552bbd9095c3c13b46443d87a4b5504c4c/src/nifreeze/analysis/motion.py https://github.com/nipreps/nifreeze/blob/d27ba7552bbd9095c3c13b46443d87a4b5504c4c/src/nifreeze/data/utils.py

Add a fixture to be able to reuse a random number generator across tests.

Fixes #239.

@jhlegarreta

Copy link
Copy Markdown
Author

I did not dare to create a data module the utils.py module at the root folder, as I do not know well the philosophy in this project, but let me know if it does belong to a data module.

I think the plotting should go to nireports.

@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch 3 times, most recently from 7fae796 to 73b27ba Compare November 1, 2025 18:19
@codecov

codecov Bot commented Nov 1, 2025

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.87%. Comparing base (9486757) to head (0732e1d).

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #286      +/-   ##
==========================================
+ Coverage   96.74%   96.87%   +0.13%     
==========================================
  Files          16       17       +1     
  Lines        1994     2083      +89     
  Branches      267      240      -27     
==========================================
+ Hits         1929     2018      +89     
  Misses         41       41              
  Partials       24       24              
Flag Coverage Δ
unittests 96.87% <100.00%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 73b27ba to 86fa6af Compare November 19, 2025 12:32
@jhlegarreta

Copy link
Copy Markdown
Author

Pinging @oesteban.

Comment thread nitransforms/utils.py Outdated
Comment thread nitransforms/tests/test_utils.py Outdated
@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 86fa6af to 711146a Compare November 20, 2025 02:31
@oesteban

Copy link
Copy Markdown
Collaborator

Then should we remove these files from NiFreeze?
https://github.com/nipreps/nifreeze/blob/6723229312850a73cbe9e4f31144ade3502535da/src/nifreeze/data/utils.py
https://github.com/nipreps/nifreeze/blob/6723229312850a73cbe9e4f31144ade3502535da/test/test_data_utils.py

Yes

and use nitransforms.resampling.apply in
https://github.com/nipreps/nifreeze/blob/6723229312850a73cbe9e4f31144ade3502535da/src/nifreeze/registration/utils.py#L74
and
https://github.com/nipreps/nifreeze/blob/6723229312850a73cbe9e4f31144ade3502535da/src/nifreeze/registration/utils.py#L113

No, if you look at it closer, you'll see this is using apply_affine from nibabel.affines, which is not the same (that applies affines to points, our apply in nitransforms and the apply_affines (notice the 's' at the end) in nifreeze apply affines to images (internally to points, but that's not relevant here).

@oesteban oesteban left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the update. I just realized of a little inconsistency. Other than that, I think this is almost ready.

Comment thread nitransforms/analysis/utils.py Outdated
@jhlegarreta

Copy link
Copy Markdown
Author

Had a closer look at the apply_affines function: it calls nt.resampling.apply so ultimately its a wrapper around it to write transformed images to a NIfTI file. So, maybe it is useful if we rename/relocate it? Else, are there other nitransforms utils that do that job?

@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 04b0f77 to 8039887 Compare November 20, 2025 12:55
@oesteban

Copy link
Copy Markdown
Collaborator

Else, are there other nitransforms utils that do that job?

Yes, nitransforms.resampling.apply does the job. We created the apply_transforms wrapper in nifreeze because the multiple-volume resampling was not very well supported in nitransforms, but that changed.

@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch 3 times, most recently from b71eac7 to f3236b7 Compare November 20, 2025 13:24
@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch 2 times, most recently from 66875cb to 8164b14 Compare December 8, 2025 23:55
@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 8164b14 to 98ec243 Compare December 27, 2025 22:25
@jhlegarreta

Copy link
Copy Markdown
Author

This is ready to go. Merging this would allow NiFreeze to drop code that is general, and probably have better support as these functions may get better testing. We will need to set/bump a new min version for NiTransforms when a new version with these changes is released.

@oesteban oesteban left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had a pretty hefty bug in the original implementation of nifreeze!

Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py
Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/tests/test_analysis.py Outdated
Comment thread nitransforms/tests/test_analysis.py Outdated
Comment thread nitransforms/analysis/utils.py Outdated
@oesteban
oesteban force-pushed the enh/add-transform-analysis-utils branch 4 times, most recently from 079e6bc to 358dcec Compare January 4, 2026 16:25
Add transform analysis utils.

Transfer contents from the `NiFreeze` projects so that hey can be reused
across projects requiring transform analysis:
https://github.com/nipreps/nifreeze/blob/d27ba7552bbd9095c3c13b46443d87a4b5504c4c/src/nifreeze/analysis/motion.py
https://github.com/nipreps/nifreeze/blob/d27ba7552bbd9095c3c13b46443d87a4b5504c4c/src/nifreeze/data/utils.py

Add a fixture to be able to reuse a random number generator across
tests.

Co-authored-by: Oscar Esteban <code@oscaresteban.es>
@effigies

effigies commented Jan 7, 2026

Copy link
Copy Markdown
Member

Out of curiosity, why are we standardizing on degrees? Apart from AFNI, everybody reports angles in radians. It seems to add unnecessary complication. Nipype also normalizes to SPM style (https://github.com/nipy/nipype/blob/8234ec318489930fa17487cf15d210420214b00a/nipype/utils/misc.py#L246-L271) so consistency within the nipy world seems desirable.

@effigies

effigies commented Jan 7, 2026

Copy link
Copy Markdown
Member

Another conceptual question: Do we want to continue the tradition of accepting an unlabeled Nx6 matrix, or would it be a good opportunity to require the caller to consider the it useful to have a function that accepts an Nx6 matrix, or would it make more sense to accept 2 Nx3 matrices and a radius? The reason I ask is that it would be very tempting to load an Nx6 matrix out of some transform file and pass it directly without carefully considering which are the translations and which are the rotations. I might think of the API as follows:

def framewise_displacement(
    translations: np.ndarray,
    rotations: np.ndarray,
    radius: float = DEFAULT_RADIUS
) -> np.ndarray:
    """Calculate framewise displacement of motion parameters. ..."""

def extract_parameters(motion_parameters: np.ndarray, fmt: str | None=None) -> tuple[np.ndarray, np.ndarray]:
    """Extract translation and rotation parameters from a parameter array.

    This function normalizes rotation parameters to radians, if the input format is known to use degrees.

    The parameters are returned in the order expected by the `framewise_displacement` function.
    """

# I know what my matrix is:
fd = framewise_displacement(motion_parameters[:, :3], motion_parameters[:, 3:])
# I know I got it from AFNI:
afni_fd = framewise_displacement(*extract_parameters(motion_parameters, fmt='afni'))

We could even force people to use labels instead and say:

@dataclass(kw_only=True)
class MotionParameters:
    rotations: np.ndarray
    translations: np.ndarray

def extract_parameters(motion_parameters: np.ndarray, fmt: str | None=None) -> MotionParameters: ...
def framewise_displacement(motion_parameters: MotionParameters, radius: float = DEFAULT_RADIUS) -> np.ndarray: ...

This would even suggest a straightforward translation between affines and motion parameters:

def affineToMotionParams(aff: Affine) -> MotionParameters: ...
def motionParamstoAffine(motion_parameters: MotionParameters) -> LinearTransformsMapping: ...

Just a couple thoughts. Will review the PR on the assumption that you'll stick with degrees and a big matrix.

Comment thread nitransforms/analysis/utils.py Outdated
Comment thread nitransforms/analysis/utils.py
Comment thread nitransforms/analysis/utils.py Outdated
Comment on lines +173 to +193
def extract_motion_parameters(affine: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
"""Extract translation (mm) and rotation (degrees) parameters from an affine matrix.

Parameters
----------
affine : :obj:`~numpy.ndarray`
The affine transformation matrix.

Returns
-------
:obj:`tuple`
Extracted translation and rotation parameters.
"""

translation = affine[:3, 3]
rotation_rad = np.arctan2(
[affine[2, 1], affine[0, 2], affine[1, 0]],
[affine[2, 2], affine[0, 0], affine[1, 1]],
)
rotation_deg = np.rad2deg(rotation_rad)
return *translation, *rotation_deg

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't obviously correct to me. There are many ways of extracting angles from a rotation matrix, but I'm surprised that you seem able to get them using only 6 entries of a 3x3 matrix.

At the least, this function needs a citation, but I would be inclined to rely on scipy.spatial.transforms or transforms3d for this functionality.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, will investigate.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See 18faeb8.

numpy.ndarray
An array of shape ``(n_points, 3)`` whose rows have unit norm.

Examples

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be too much to ask for these docs to have plots to show the points on the unit sphere?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, I'll see if it doesn't involve much or defer to a future PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See b29f839.

Comment thread nitransforms/analysis/utils.py Outdated
Comment on lines +353 to +354
Identifies high-motion frames as timepoint exceeding a given threshold value
based on z-score normalized framewise displacement (FD) values.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a citation for this one? This seems dubious to me, as z-scoring will amplify low-motion subjects' FD and suppress high-motion subjects' FD. For the purposes of censoring, absolute thresholds are often used based on voxel size.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inclined to drop this function altogether. The purpose is not censoring, but rather identifying the higher trees in the forest.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in b459594.

Comment thread nitransforms/analysis/utils.py Outdated
Co-authored-by: Chris Markiewicz <effigies@gmail.com>
@jhlegarreta

Copy link
Copy Markdown
Author

Can we revisit this so that we can reduce the fronts across NiFreeze, please?

Remove spike identification function.
@jhlegarreta

Copy link
Copy Markdown
Author

I think #286 (comment) can be addressed in a subsequent PR.

@jhlegarreta

Copy link
Copy Markdown
Author

I'd squash commits once we are satisfied with the changes.

@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch 4 times, most recently from a42548b to 3a6fb98 Compare July 24, 2026 08:16
Use `scipy` to extract rotations from affine matrices.
Make the computation of FD for a single vertex consistent.
Ensure motion array has right dimensionality before computing FD.

Take advantage of the commit to:
- Make the array use floating point precision.
- Prefer using explicit array rather than relying on broadcasting for
  prepending.
Improve return value docstring in FD computation function from motion
array.
Plot unit sphere sampling docstring examples.

Take advantage of the commit to:
- Define the values used for the docstring test as a named variable for
  the sake of clearliness.
- Use Sphinx markup for math symbols instead of using non-unicode
  characters.
Use L1 norm to check resulting FD value from transform matrix.
@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 3a6fb98 to 730b14f Compare July 24, 2026 08:27
@jhlegarreta

Copy link
Copy Markdown
Author

Note: we are assuming that the matrices are all valid: scipy 1.17 introduced a check parameter that we are not able to use because we only require scipy >= 1.10.

I think I've done all I can on this PR.

@jhlegarreta
jhlegarreta requested review from effigies and oesteban July 25, 2026 10:37
Improve documentation: use appropriate markup for types, document
exceptions.
@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 608b796 to e562414 Compare July 28, 2026 19:20
Test exceptions.
@jhlegarreta
jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from e562414 to 0732e1d Compare July 28, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Framewise displacement calculations, CLI, and perhaps, plotting module

3 participants