antspymm 1.5.2__py3-none-any.whl → 1.5.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
antspymm/__init__.py CHANGED
@@ -135,5 +135,6 @@ from .mm import fix_LR_RL_stuff
135
135
  from .mm import segment_timeseries_by_bvalue
136
136
  from .mm import shorten_pymm_names
137
137
  from .mm import pet3d_summary
138
+ from .mm import deformation_gradient_optimized
138
139
 
139
140
 
antspymm/mm.py CHANGED
@@ -2195,62 +2195,206 @@ def dti_numpy_to_image( reference_image, tensorarray, upper_triangular=True):
2195
2195
  ants.copy_image_info( reference_image, dtiAnts )
2196
2196
  return dtiAnts
2197
2197
 
2198
- def transform_and_reorient_dti( fixed, moving_dti, composite_transform, py_based=True, verbose=False, **kwargs):
2198
+
2199
+ def deformation_gradient_optimized(warp_image, to_rotation=False, to_inverse_rotation=False):
2199
2200
  """
2200
- apply a transform to DTI in the style of ants.apply_transforms. this function
2201
- expects a pre-computed composite transform which it will use to reorient
2202
- the DTI using preservation of principle directions.
2203
-
2204
- fixed : antsImage reference space
2201
+ Compute the deformation gradient tensor from a displacement (warp) field image.
2205
2202
 
2206
- moving_dti : antsImage DTI in upper triangular format
2203
+ This function computes the **deformation gradient** `F = ∂φ/∂x` where `φ(x) = x + u(x)` is the mapping
2204
+ induced by the displacement field `u(x)` stored in `warp_image`.
2207
2205
 
2208
- composite_transform : should be a composition of all transforms to be applied stored on disk ( a filename ) ... might change this in the future.
2206
+ The returned tensor field has shape `(x, y, z, dim, dim)` (for 3D), where each matrix represents
2207
+ the **Jacobian** of the transformation at that voxel. The gradient is computed in the physical space
2208
+ of the image using spacing and direction metadata.
2209
2209
 
2210
- py_based : boolean
2210
+ Optionally, the deformation gradient can be projected onto the space of pure rotations using the polar
2211
+ decomposition (via SVD). This is useful for applications like reorientation of tensors (e.g., DTI).
2211
2212
 
2212
- verbose : boolean
2213
+ Parameters
2214
+ ----------
2215
+ warp_image : ants.ANTsImage
2216
+ A vector-valued ANTsImage encoding the warp/displacement field. It must have `dim` components
2217
+ (e.g., shape `(x, y, z, 3)` for 3D) representing the displacements in each spatial direction.
2218
+
2219
+ to_rotation : bool, optional
2220
+ If True, the deformation gradient will be replaced with its **nearest rotation matrix**
2221
+ using the polar decomposition (`F → R`, where `F = R U`).
2222
+
2223
+ to_inverse_rotation : bool, optional
2224
+ If True, the deformation gradient will be replaced with the **inverse of the rotation**
2225
+ (`F → R.T`), which is often needed for transforming tensors **back** to their original frame.
2226
+
2227
+ Returns
2228
+ -------
2229
+ F : np.ndarray
2230
+ A NumPy array of shape `(x, y, z, dim, dim)` (or `(dim1, dim2, ..., dim, dim)` in general),
2231
+ representing the deformation gradient tensor field at each voxel.
2232
+
2233
+ Raises
2234
+ ------
2235
+ RuntimeError
2236
+ If `warp_image` is not an `ants.ANTsImage`.
2237
+
2238
+ Notes
2239
+ -----
2240
+ - The function computes gradients in physical space using the spacing of the image and applies
2241
+ the image direction matrix (`tdir`) to properly orient gradients.
2242
+ - The gradient is added to the identity matrix to yield the deformation gradient `F = I + ∂u/∂x`.
2243
+ - The polar decomposition ensures `F` is replaced with the closest rotation matrix (orthogonal, det=1).
2244
+ - This is a **vectorized pure NumPy implementation**, intended for performance and simplicity.
2245
+
2246
+ Examples
2247
+ --------
2248
+ >>> warp = ants.create_warp_image(reference_image, displacement_field)
2249
+ >>> F = deformation_gradient_optimized(warp)
2250
+ >>> R = deformation_gradient_optimized(warp, to_rotation=True)
2251
+ >>> Rinv = deformation_gradient_optimized(warp, to_inverse_rotation=True)
2252
+ """
2253
+ if not ants.is_image(warp_image):
2254
+ raise RuntimeError("antsimage is required")
2255
+ dim = warp_image.dimension
2256
+ tshp = warp_image.shape
2257
+ tdir = warp_image.direction
2258
+ spc = warp_image.spacing
2259
+ warpnp = warp_image.numpy()
2260
+ gradient_list = [np.gradient(warpnp[..., k], *spc, axis=range(dim)) for k in range(dim)]
2261
+ # This correctly calculates J.T, where dg[..., i, j] = d(u_j)/d(x_i)
2262
+ dg = np.stack([np.stack(grad_k, axis=-1) for grad_k in gradient_list], axis=-1)
2263
+ dg = (tdir @ dg).swapaxes(-1, -2)
2264
+ dg += np.eye(dim)
2265
+ if to_rotation or to_inverse_rotation:
2266
+ U, s, Vh = np.linalg.svd(dg)
2267
+ Z = U @ Vh
2268
+ dets = np.linalg.det(Z)
2269
+ reflection_mask = dets < 0
2270
+ Vh[reflection_mask, -1, :] *= -1
2271
+ Z[reflection_mask] = U[reflection_mask] @ Vh[reflection_mask]
2272
+ dg = Z
2273
+ if to_inverse_rotation:
2274
+ dg = np.transpose(dg, axes=(*range(dg.ndim - 2), dg.ndim - 1, dg.ndim - 2))
2275
+ new_shape = tshp + (dim,dim)
2276
+ return np.reshape(dg, new_shape)
2277
+
2278
+
2279
+ def transform_and_reorient_dti( fixed, moving_dti, composite_transform, verbose=False, **kwargs):
2280
+ """
2281
+ Applies a transformation to a DTI image using an ANTs composite transform,
2282
+ including local tensor reorientation via the Finite Strain method.
2283
+
2284
+ This function expects:
2285
+ - Input `moving_dti` to be a 6-component ANTsImage (upper triangular format).
2286
+ - `composite_transform` to point to an ANTs-readable transform file,
2287
+ which maps points from `fixed` space to `moving` space.
2213
2288
 
2214
- **kwargs : passed to ants.apply_transforms
2289
+ Args:
2290
+ fixed (ants.ANTsImage): The reference space image (defines the output grid).
2291
+ moving_dti (ants.ANTsImage): The input DTI (6-component), to be transformed.
2292
+ composite_transform (str): File path to an ANTs transform
2293
+ (e.g., from `ants.read_transform` or a written composite transform).
2294
+ verbose (bool): Whether to print verbose output during execution.
2295
+ **kwargs: Additional keyword arguments passed to `ants.apply_transforms`.
2215
2296
 
2297
+ Returns:
2298
+ ants.ANTsImage: The transformed and reoriented DTI image in the `fixed` space,
2299
+ in 6-component upper triangular format.
2216
2300
  """
2217
2301
  if moving_dti.dimension != 3:
2218
- raise ValueError('moving image should have 3 dimensions')
2302
+ raise ValueError('moving_dti must be 3-dimensional.')
2219
2303
  if moving_dti.components != 6:
2220
- raise ValueError('moving image should have 6 components')
2221
- # now apply the transform to the template
2222
- # 1. transform the tensor components
2304
+ raise ValueError('moving_dti must have 6 components (upper triangular format).')
2305
+
2306
+ if verbose:
2307
+ print("1. Spatially transforming DTI scalar components from moving to fixed space...")
2308
+
2309
+ # ants.apply_transforms resamples the *values* of each DTI component from 'moving_dti'
2310
+ # onto the grid of 'fixed'.
2311
+ # The output 'dtiw' will have the same spatial metadata (spacing, origin, direction) as 'fixed'.
2312
+ # However, the tensor values contained within it are still oriented as they were in
2313
+ # 'moving_dti's original image space, not 'fixed' image space, and certainly not yet reoriented
2314
+ # by the local deformation.
2223
2315
  dtsplit = moving_dti.split_channels()
2224
- dtiw = []
2316
+ dtiw_channels = []
2225
2317
  for k in range(len(dtsplit)):
2226
- dtiw.append( ants.apply_transforms( fixed, dtsplit[k], composite_transform ) )
2227
- dtiw=ants.merge_channels(dtiw)
2318
+ dtiw_channels.append( ants.apply_transforms( fixed, dtsplit[k], composite_transform, **kwargs ) )
2319
+ dtiw = ants.merge_channels(dtiw_channels)
2320
+
2228
2321
  if verbose:
2229
- print("reorient tensors locally: compose and get reo image")
2230
- locrot = ants.deformation_gradient( ants.image_read(composite_transform),
2231
- to_rotation = True, py_based=py_based ).numpy()
2232
- rebaser = np.dot( np.transpose( fixed.direction ), moving_dti.direction )
2322
+ print(f" DTI scalar components resampled to fixed grid. Result shape: {dtiw.shape}")
2323
+ print("2. Computing local rotation field from composite transform...")
2324
+
2325
+ # Read the composite transform as an image (assumed to be a displacement field).
2326
+ # The 'deformation_gradient_optimized' function is assumed to be 100% correct,
2327
+ # meaning it returns the appropriate local rotation matrix field (R_moving_to_fixed)
2328
+ # in (spatial_dims..., 3, 3 ) format when called with these flags.
2329
+ wtx = ants.image_read(composite_transform)
2330
+ R_moving_to_fixed_forward = deformation_gradient_optimized(
2331
+ wtx,
2332
+ to_rotation=False, # This means the *deformation gradient* F=I+J is computed first.
2333
+ to_inverse_rotation=True # This requests the inverse of the rotation part of F.
2334
+ )
2335
+
2233
2336
  if verbose:
2234
- print("convert UT to full tensor")
2235
- dtiw2tensor = triangular_to_tensor( dtiw )
2337
+ print(f" Local reorientation matrices (R_moving_to_fixed_forward) computed. Shape: {R_moving_to_fixed_forward.shape}")
2338
+ print("3. Converting 6-component DTI to full 3x3 tensors for vectorized reorientation...")
2339
+
2340
+ # Convert `dtiw` (resampled, but still in moving-image-space orientation)
2341
+ # from 6-components to full 3x3 tensor representation.
2342
+ # dtiw2tensor_np will have shape (spatial_dims..., 3, 3).
2343
+ dtiw2tensor_np = triangular_to_tensor(dtiw)
2344
+
2236
2345
  if verbose:
2237
- print("rebase tensors to new space via iterator")
2238
- it = np.ndindex( fixed.shape )
2239
- for i in it:
2240
- # direction * dt * direction.transpose();
2241
- mmm = dtiw2tensor[i]
2242
- # transform rebase
2243
- locrotx = np.reshape( locrot[i], [3,3] )
2244
- mmm = np.dot( mmm, np.transpose( locrotx ) )
2245
- mmm = np.dot( locrotx, mmm )
2246
- # physical space rebase
2247
- mmm = np.dot( mmm, np.transpose( rebaser ) )
2248
- mmm = np.dot( rebaser, mmm )
2249
- dtiw2tensor[i] = mmm
2346
+ print("4. Applying vectorized tensor reorientation (Finite Strain Method)...")
2347
+
2348
+ # --- Vectorized Tensor Reorientation ---
2349
+ # This replaces the entire `for i in it:` loop and its contents with efficient NumPy operations.
2350
+
2351
+ # Step 4.1: Rebase tensors from `moving_dti.direction` coordinate system to World Coordinates.
2352
+ # D_world_moving_orient = moving_dti.direction @ D_moving_image_frame @ moving_dti.direction.T
2353
+ # This transforms the tensor's components from being relative to `moving_dti`'s image axes
2354
+ # (where they are currently defined) into absolute World (physical) coordinates.
2355
+ D_world_moving_orient = np.einsum(
2356
+ 'ab, ...bc, cd -> ...ad',
2357
+ moving_dti.direction, # 3x3 matrix (moving_image_axes -> world_axes)
2358
+ dtiw2tensor_np, # (spatial_dims..., 3, 3)
2359
+ moving_dti.direction.T # 3x3 matrix (world_axes -> moving_image_axes) - inverse of moving_dti.direction
2360
+ )
2361
+
2362
+ # Step 4.2: Apply local rotation in World Coordinates (Finite Strain Reorientation).
2363
+ # D_reoriented_world = R_moving_to_fixed_forward @ D_world_moving_orient @ (R_moving_to_fixed_forward).T
2364
+ # This is the core reorientation step, transforming the tensor's orientation from
2365
+ # the original `moving` space to the new `fixed` space, all within world coordinates.
2366
+ D_world_fixed_orient = np.einsum(
2367
+ '...ab, ...bc, ...cd -> ...ad',
2368
+ R_moving_to_fixed_forward, # (spatial_dims..., 3, 3) - local rotation
2369
+ D_world_moving_orient, # (spatial_dims..., 3, 3) - tensor in world space, moving_orient
2370
+ np.swapaxes(R_moving_to_fixed_forward, -1, -2) # (spatial_dims..., 3, 3) - transpose of local rotation
2371
+ )
2372
+
2373
+ # Step 4.3: Rebase reoriented tensors from World Coordinates to `fixed.direction` coordinate system.
2374
+ # D_final_fixed_image_frame = (fixed.direction).T @ D_world_fixed_orient @ fixed.direction
2375
+ # This transforms the tensor's components from absolute World (physical) coordinates
2376
+ # back into `fixed.direction`'s image coordinate system.
2377
+ final_dti_tensors_numpy = np.einsum(
2378
+ 'ba, ...bc, cd -> ...ad',
2379
+ fixed.direction, # Using `fixed.direction` here, but 'ba' indices specify to use its transpose.
2380
+ D_world_fixed_orient, # (spatial_dims..., 3, 3)
2381
+ fixed.direction # 3x3 matrix (world_axes -> fixed_image_axes)
2382
+ )
2383
+
2250
2384
  if verbose:
2251
- print("done with rebasing")
2252
- return dti_numpy_to_image( fixed, dtiw2tensor )
2385
+ print(" Vectorized tensor reorientation complete.")
2253
2386
 
2387
+ if verbose:
2388
+ print("5. Converting reoriented full tensors back to 6-component ANTsImage...")
2389
+
2390
+ # Convert the final (spatial_dims..., 3, 3) NumPy array of tensors back into a
2391
+ # 6-component ANTsImage with the correct spatial metadata from `fixed`.
2392
+ final_dti_image = dti_numpy_to_image(fixed, final_dti_tensors_numpy)
2393
+
2394
+ if verbose:
2395
+ print(f"Done. Final reoriented DTI image in fixed space generated. Shape: {final_dti_image.shape}")
2396
+
2397
+ return final_dti_image
2254
2398
 
2255
2399
  def dti_reg(
2256
2400
  image,
@@ -2769,7 +2913,7 @@ def template_figure_with_overlay(scalar_label_df, prefix, outputfilename=None, t
2769
2913
  toviz = temp['overlay']
2770
2914
  return { "underlay": seggm, 'overlay': toviz, 'seg': tcrop }
2771
2915
 
2772
- def get_data( name=None, force_download=False, version=25, target_extension='.csv' ):
2916
+ def get_data( name=None, force_download=False, version=26, target_extension='.csv' ):
2773
2917
  """
2774
2918
  Get ANTsPyMM data filename
2775
2919
 
@@ -6510,7 +6654,37 @@ def bold_perfusion_minimal(
6510
6654
  return convert_np_in_dict( outdict )
6511
6655
 
6512
6656
 
6513
- def bold_perfusion( fmri, t1head, t1, t1segmentation, t1dktcit,
6657
+
6658
+ def warn_if_small_mask( mask: ants.ANTsImage, threshold_fraction: float = 0.05, label: str = ' ' ):
6659
+ """
6660
+ Warn the user if the number of non-zero voxels in the mask
6661
+ is less than a given fraction of the total number of voxels in the mask.
6662
+
6663
+ Parameters
6664
+ ----------
6665
+ mask : ants.ANTsImage
6666
+ The binary mask to evaluate.
6667
+ threshold_fraction : float, optional
6668
+ Fraction threshold below which a warning is triggered (default is 0.05).
6669
+
6670
+ Returns
6671
+ -------
6672
+ None
6673
+ """
6674
+ import warnings
6675
+ image_size = np.prod(mask.shape)
6676
+ mask_size = np.count_nonzero(mask.numpy())
6677
+ if mask_size / image_size < threshold_fraction:
6678
+ percentage = 100.0 * mask_size / image_size
6679
+ warnings.warn(
6680
+ f"[ants] Warning: {label} contains only {mask_size} voxels "
6681
+ f"({percentage:.2f}% of image volume). "
6682
+ f"This is below the threshold of {threshold_fraction * 100:.2f}% and may lead to unreliable results.",
6683
+ UserWarning
6684
+ )
6685
+
6686
+ def bold_perfusion(
6687
+ fmri, t1head, t1, t1segmentation, t1dktcit,
6514
6688
  FD_threshold=0.5,
6515
6689
  spa = (0., 0., 0., 0.),
6516
6690
  nc = 3,
@@ -6711,11 +6885,18 @@ def bold_perfusion( fmri, t1head, t1, t1segmentation, t1dktcit,
6711
6885
  newspc = [minspc,minspc,minspc]
6712
6886
  fmri_template = ants.resample_image( fmri_template, newspc, interp_type=0 )
6713
6887
 
6888
+ if verbose:
6889
+ print( 'fmri_template')
6890
+ print( fmri_template )
6891
+
6714
6892
  rig = ants.registration( fmri_template, t1head, 'BOLDRigid' )
6715
6893
  bmask = ants.apply_transforms( fmri_template,
6716
6894
  ants.threshold_image(t1segmentation,1,6),
6717
6895
  rig['fwdtransforms'][0],
6718
6896
  interpolator='genericLabel' )
6897
+
6898
+ warn_if_small_mask( bmask, label='bold_perfusion:bmask')
6899
+
6719
6900
  corrmo = timeseries_reg(
6720
6901
  fmri, fmri_template,
6721
6902
  type_of_transform=type_of_transform,
@@ -6739,6 +6920,7 @@ def bold_perfusion( fmri, t1head, t1, t1segmentation, t1dktcit,
6739
6920
  mytsnrThresh = np.quantile( mytsnr.numpy(), 0.995 )
6740
6921
  tsnrmask = ants.threshold_image( mytsnr, 0, mytsnrThresh ).morphology("close",3)
6741
6922
  bmask = bmask * ants.iMath( tsnrmask, "FillHoles" )
6923
+ warn_if_small_mask( bmask, label='bold_perfusion:bmask*tsnrmask')
6742
6924
  fmrimotcorr=corrmo['motion_corrected']
6743
6925
  und = fmri_template * bmask
6744
6926
  t1reg = ants.registration( und, t1, "SyNBold" )
@@ -6751,13 +6933,16 @@ def bold_perfusion( fmri, t1head, t1, t1segmentation, t1dktcit,
6751
6933
  csfseg = ants.threshold_image( t1segmentation, 1, 1 )
6752
6934
  wmseg = ants.threshold_image( t1segmentation, 3, 3 )
6753
6935
  csfAndWM = ( csfseg + wmseg ).morphology("erode",1)
6936
+ compcorquantile=0.50
6754
6937
  csfAndWM = ants.apply_transforms( und, csfAndWM,
6755
6938
  t1reg['fwdtransforms'], interpolator = 'nearestNeighbor' ) * bmask
6756
6939
  csfseg = ants.apply_transforms( und, csfseg,
6757
6940
  t1reg['fwdtransforms'], interpolator = 'nearestNeighbor' ) * bmask
6758
6941
  wmseg = ants.apply_transforms( und, wmseg,
6759
6942
  t1reg['fwdtransforms'], interpolator = 'nearestNeighbor' ) * bmask
6760
- compcorquantile=0.50
6943
+ warn_if_small_mask( wmseg, label='bold_perfusion:wmseg')
6944
+ # warn_if_small_mask( csfseg, threshold_fraction=0.01, label='bold_perfusion:csfseg')
6945
+ warn_if_small_mask( csfAndWM, label='bold_perfusion:csfAndWM')
6761
6946
  mycompcor = ants.compcor( fmrimotcorr,
6762
6947
  ncompcor=nc, quantile=compcorquantile, mask = csfAndWM,
6763
6948
  filter_type='polynomial', degree=2 )
@@ -7606,7 +7791,7 @@ def mm(
7606
7791
  if srmodel is not None:
7607
7792
  tspc=[1.,1.,1.]
7608
7793
  group_template2mm = ants.resample_image( group_template, tspc )
7609
- normalization_dict['DTI_norm'] = transform_and_reorient_dti( group_template2mm, mydti['dti'], comptx, py_based=True, verbose=True )
7794
+ normalization_dict['DTI_norm'] = transform_and_reorient_dti( group_template2mm, mydti['dti'], comptx, verbose=False )
7610
7795
  import shutil
7611
7796
  shutil.rmtree(output_directory, ignore_errors=True )
7612
7797
  if output_dict['rsf'] is not None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: antspymm
3
- Version: 1.5.2
3
+ Version: 1.5.4
4
4
  Summary: multi-channel/time-series medical image processing with antspyx
5
5
  Author-email: "Avants, Gosselin, Tustison, Reardon" <stnava@gmail.com>
6
6
  License: Apache-2.0
@@ -197,6 +197,8 @@ NOTE: an example process for BIDS data on a cluster is [here](https://github.com
197
197
 
198
198
  # example processing
199
199
 
200
+ see discussion [here](https://github.com/ANTsX/ANTsPyMM/issues/26) for the organization of tests and examples.
201
+
200
202
  see the latest help but this snippet gives an idea of how one might use the package:
201
203
 
202
204
  ```python
@@ -0,0 +1,7 @@
1
+ antspymm/__init__.py,sha256=hynrdvZDlPQ0Wam8tU6mBtbEk0Worwz_bLZk9N7N1CM,4684
2
+ antspymm/mm.py,sha256=e4BTBarPnlk3RlqMAPOp6wcGZxv5ufSA1GxuKi3oiJw,536471
3
+ antspymm-1.5.4.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
4
+ antspymm-1.5.4.dist-info/METADATA,sha256=2NNkAHHTSMIhla6KURkKU39w9CqxWrDshuHzOI2kMdc,26051
5
+ antspymm-1.5.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ antspymm-1.5.4.dist-info/top_level.txt,sha256=iyD1sRhCKzfwKRJLq5ZUeV9xsv1cGQl8Ejp6QwXM1Zg,9
7
+ antspymm-1.5.4.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- antspymm/__init__.py,sha256=DnkidUfEu3Dl0tuWNTA-9VOUkBtH_cROKiPGNNXNagU,4637
2
- antspymm/mm.py,sha256=NbT1IBiuEMtMoanr_8yO3kLNpSSfV0j1_155gykGOM0,526972
3
- antspymm-1.5.2.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
4
- antspymm-1.5.2.dist-info/METADATA,sha256=3Ttc-cPytZsiNct2MRz4PZwe5JmdZzaEsEgu7hxKxvA,25939
5
- antspymm-1.5.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- antspymm-1.5.2.dist-info/top_level.txt,sha256=iyD1sRhCKzfwKRJLq5ZUeV9xsv1cGQl8Ejp6QwXM1Zg,9
7
- antspymm-1.5.2.dist-info/RECORD,,