Skip to content

Conversation

@jhlegarreta
Copy link

@jhlegarreta jhlegarreta commented Nov 1, 2025

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
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
Copy link

codecov bot commented Nov 1, 2025

Codecov Report

❌ Patch coverage is 0% with 86 lines in your changes missing coverage. Please review.
✅ Project coverage is 0.00%. Comparing base (9486757) to head (dab55cf).

Files with missing lines Patch % Lines
nitransforms/analysis/utils.py 0.00% 86 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (9486757) and HEAD (dab55cf). Click for more details.

HEAD has 49 uploads less than BASE
Flag BASE (9486757) HEAD (dab55cf)
49 1
unittests 1 0
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #286       +/-   ##
==========================================
- Coverage   96.74%   0.00%   -96.75%     
==========================================
  Files          16      17        +1     
  Lines        1994    2079       +85     
  Branches      267       0      -267     
==========================================
- Hits         1929       0     -1929     
- Misses         41    2079     +2038     
+ Partials       24       0       -24     
Flag Coverage Δ
unittests ?

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

☔ View full report in Codecov by Sentry.
📢 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
Author

Pinging @oesteban.

@jhlegarreta jhlegarreta force-pushed the enh/add-transform-analysis-utils branch from 86fa6af to 711146a Compare November 20, 2025 02:31
@oesteban
Copy link
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).

Copy link
Collaborator

@oesteban oesteban left a comment

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.

@jhlegarreta
Copy link
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
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
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.

Copy link
Collaborator

@oesteban oesteban left a comment

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!

@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>
@oesteban oesteban force-pushed the enh/add-transform-analysis-utils branch from 358dcec to bacdc83 Compare January 4, 2026 16:25
@oesteban oesteban requested a review from effigies January 4, 2026 18:48
@oesteban
Copy link
Collaborator

oesteban commented Jan 4, 2026

@jhlegarreta please have one more look - I realized that the FD from transform matrices was also badly computed as it did not accept the previous transform. IMHO, we should also provide a "vectorized" version of FD such that you pass the list of transforms and gives you timeseries. Could be left for a future PR though.

@effigies added you to have an extra pair of eyes on this.

xfm: TransformBase,
xfm_prev: TransformBase | None = None,
radius: float = DEFAULT_FD_RADIUS,
n_vertices: int = 8,
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be interesting to make the output match compute_fd_from_motion for n_vertices = 1.

Copy link
Author

@jhlegarreta jhlegarreta left a comment

Choose a reason for hiding this comment

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

IMHO, we should also provide a "vectorized" version of FD such that you pass the list of transforms and gives you timeseries. Could be left for a future PR though.

Better for a future PR. This is already better than what we have in NiFreeze (thanks for reviewing and the subsequent work), allows us to have a common, single authoritative location, and frees NiFreeze from hosting this.

Some inline comments about documentation and syntax highlighting.

@effigies
Copy link
Member

effigies commented Jan 7, 2026

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
Copy link
Member

effigies commented Jan 7, 2026

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 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
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
Collaborator

Choose a reason for hiding this comment

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

Thanks, will investigate.

numpy.ndarray
An array of shape ``(n_points, 3)`` whose rows have unit norm.
Examples
Copy link
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
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.

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
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
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.

Co-authored-by: Chris Markiewicz <effigies@gmail.com>
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