antspymm 1.4.9__py3-none-any.whl → 1.5.1__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
@@ -5,7 +5,10 @@ except:
5
5
  pass
6
6
 
7
7
  from .mm import get_data
8
+ from .mm import ants_to_nibabel_affine
8
9
  from .mm import get_dti
10
+ from .mm import efficient_tensor_fit
11
+ from .mm import efficient_dwi_fit
9
12
  from .mm import triangular_to_tensor
10
13
  from .mm import dti_numpy_to_image
11
14
  from .mm import transform_and_reorient_dti
antspymm/mm.py CHANGED
@@ -93,6 +93,7 @@ __all__ = ['version',
93
93
  'loop_timeseries_censoring',
94
94
  'clean_tmp_directory',
95
95
  'validate_nrg_file_format',
96
+ 'ants_to_nibabel_affine',
96
97
  'dict_to_dataframe']
97
98
 
98
99
  from pathlib import Path
@@ -288,12 +289,44 @@ def get_antsimage_keys(dictionary):
288
289
  """
289
290
  return [key for key, value in dictionary.items() if isinstance(value, ants.core.ants_image.ANTsImage)]
290
291
 
291
- def to_nibabel(img: "ants.core.ants_image.ANTsImage"):
292
- with tempfile.TemporaryDirectory() as tmp:
293
- temp_file_name = os.path.join(tmp, str(uuid.uuid1()) + '.nii.gz')
294
- ants.image_write(img, temp_file_name)
295
- nibabel_image = nib.load(temp_file_name)
296
- return(nibabel_image)
292
+ def to_nibabel(img: "ants.core.ants_image.ANTsImage") -> nib.Nifti1Image:
293
+ """
294
+ Convert an ANTsPy image to a Nibabel Nifti1Image in-memory, using correct spatial affine.
295
+
296
+ Parameters:
297
+ img (ants.ANTsImage): An image from ANTsPy.
298
+
299
+ Returns:
300
+ nib.Nifti1Image: The corresponding Nibabel image with spatial orientation in RAS.
301
+ """
302
+ array_data = img.numpy() # get voxel data as NumPy array
303
+ affine = ants_to_nibabel_affine(img)
304
+ return nib.Nifti1Image(array_data, affine)
305
+
306
+ def ants_to_nibabel_affine(ants_img):
307
+ """
308
+ Convert an ANTsPy image (in LPS space) to a Nibabel-compatible affine (in RAS space).
309
+ Handles 2D, 3D, 4D input (only spatial dimensions are encoded in the affine).
310
+
311
+ Returns:
312
+ 4x4 np.ndarray affine matrix in RAS space.
313
+ """
314
+ spatial_dim = ants_img.dimension
315
+ spacing = np.array(ants_img.spacing)
316
+ origin = np.array(ants_img.origin)
317
+ direction = np.array(ants_img.direction).reshape((spatial_dim, spatial_dim))
318
+ # Compute rotation-scale matrix
319
+ affine_linear = direction @ np.diag(spacing)
320
+ # Build full 4x4 affine with identity in homogeneous bottom row
321
+ affine = np.eye(4)
322
+ affine[:spatial_dim, :spatial_dim] = affine_linear
323
+ affine[:spatial_dim, 3] = origin
324
+ affine[3, 3]=1
325
+ # Convert LPS -> RAS by flipping x and y
326
+ lps_to_ras = np.diag([-1, -1, 1, 1])
327
+ affine = lps_to_ras @ affine
328
+ return affine
329
+
297
330
 
298
331
  def dict_to_dataframe(data_dict, convert_lists=True, convert_arrays=True, convert_images=True, verbose=False):
299
332
  """
@@ -2195,7 +2228,7 @@ def transform_and_reorient_dti( fixed, moving_dti, composite_transform, py_based
2195
2228
  if verbose:
2196
2229
  print("reorient tensors locally: compose and get reo image")
2197
2230
  locrot = ants.deformation_gradient( ants.image_read(composite_transform),
2198
- to_rotation = True, py_based=py_based )
2231
+ to_rotation = True, py_based=py_based ).numpy()
2199
2232
  rebaser = np.dot( np.transpose( fixed.direction ), moving_dti.direction )
2200
2233
  if verbose:
2201
2234
  print("convert UT to full tensor")
@@ -3522,6 +3555,239 @@ def trim_dti_mask( fa, mask, param=4.0 ):
3522
3555
  trim_mask = ants.iMath(trim_mask,"MD",paramVox-1)
3523
3556
  return trim_mask
3524
3557
 
3558
+
3559
+
3560
+ def efficient_tensor_fit( gtab, fit_method, imagein, maskin, diffusion_model='DTI',
3561
+ chunk_size=10, num_threads=1, verbose=True):
3562
+ """
3563
+ Efficient and optionally parallelized tensor reconstruction using DiPy.
3564
+
3565
+ Parameters
3566
+ ----------
3567
+ gtab : GradientTable
3568
+ Dipy gradient table.
3569
+ fit_method : str
3570
+ Tensor fitting method (e.g. 'WLS', 'OLS', 'RESTORE').
3571
+ imagein : ants.ANTsImage
3572
+ 4D diffusion-weighted image.
3573
+ maskin : ants.ANTsImage
3574
+ Binary brain mask image.
3575
+ diffusion_model : string, optional
3576
+ DTI, FreeWater, DKI.
3577
+ chunk_size : int, optional
3578
+ Number of slices (along z-axis) to process at once.
3579
+ num_threads : int, optional
3580
+ Number of threads to use (1 = single-threaded).
3581
+ verbose : bool, optional
3582
+ Print status updates.
3583
+
3584
+ Returns
3585
+ -------
3586
+ tenfit : TensorFit or FreeWaterTensorFit
3587
+ Fitted tensor model.
3588
+ FA : ants.ANTsImage
3589
+ Fractional anisotropy image.
3590
+ MD : ants.ANTsImage
3591
+ Mean diffusivity image.
3592
+ RGB : ants.ANTsImage
3593
+ RGB FA map.
3594
+ """
3595
+ assert imagein.dimension == 4, "Input image must be 4D"
3596
+
3597
+ import ants
3598
+ import numpy as np
3599
+ import dipy.reconst.dti as dti
3600
+ import dipy.reconst.fwdti as fwdti
3601
+ from dipy.reconst.dti import fractional_anisotropy
3602
+ from dipy.reconst.dti import color_fa
3603
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3604
+
3605
+ img_data = imagein.numpy()
3606
+ mask = maskin.numpy().astype(bool)
3607
+ X, Y, Z, N = img_data.shape
3608
+ if verbose:
3609
+ print(f"Input shape: {img_data.shape}, Processing in chunks of {chunk_size} slices.")
3610
+
3611
+ model = fwdti.FreeWaterTensorModel(gtab) if diffusion_model == 'FreeWater' else dti.TensorModel(gtab, fit_method=fit_method)
3612
+
3613
+ def process_chunk(z_start):
3614
+ z_end = min(Z, z_start + chunk_size)
3615
+ local_data = img_data[:, :, z_start:z_end, :]
3616
+ local_mask = mask[:, :, z_start:z_end]
3617
+ masked_data = local_data * local_mask[..., None]
3618
+ masked_data = np.nan_to_num(masked_data, nan=0)
3619
+ fit = model.fit(masked_data)
3620
+ FA_chunk = fractional_anisotropy(fit.evals)
3621
+ FA_chunk[np.isnan(FA_chunk)] = 1
3622
+ FA_chunk = np.clip(FA_chunk, 0, 1)
3623
+ MD_chunk = dti.mean_diffusivity(fit.evals)
3624
+ RGB_chunk = color_fa(FA_chunk, fit.evecs)
3625
+ return z_start, z_end, FA_chunk, MD_chunk, RGB_chunk
3626
+
3627
+ FA_vol = np.zeros((X, Y, Z), dtype=np.float32)
3628
+ MD_vol = np.zeros((X, Y, Z), dtype=np.float32)
3629
+ RGB_vol = np.zeros((X, Y, Z, 3), dtype=np.float32)
3630
+
3631
+ chunks = range(0, Z, chunk_size)
3632
+ if num_threads > 1:
3633
+ with ThreadPoolExecutor(max_workers=num_threads) as executor:
3634
+ futures = {executor.submit(process_chunk, z): z for z in chunks}
3635
+ for f in as_completed(futures):
3636
+ z_start, z_end, FA_chunk, MD_chunk, RGB_chunk = f.result()
3637
+ FA_vol[:, :, z_start:z_end] = FA_chunk
3638
+ MD_vol[:, :, z_start:z_end] = MD_chunk
3639
+ RGB_vol[:, :, z_start:z_end, :] = RGB_chunk
3640
+ else:
3641
+ for z in chunks:
3642
+ z_start, z_end, FA_chunk, MD_chunk, RGB_chunk = process_chunk(z)
3643
+ FA_vol[:, :, z_start:z_end] = FA_chunk
3644
+ MD_vol[:, :, z_start:z_end] = MD_chunk
3645
+ RGB_vol[:, :, z_start:z_end, :] = RGB_chunk
3646
+
3647
+ b0 = ants.slice_image(imagein, axis=3, idx=0)
3648
+ FA = ants.copy_image_info(b0, ants.from_numpy(FA_vol))
3649
+ MD = ants.copy_image_info(b0, ants.from_numpy(MD_vol))
3650
+ RGB_channels = [ants.copy_image_info(b0, ants.from_numpy(RGB_vol[..., i])) for i in range(3)]
3651
+ RGB = ants.merge_channels(RGB_channels)
3652
+
3653
+ return model.fit(img_data * mask[..., None]), FA, MD, RGB
3654
+
3655
+
3656
+
3657
+ def efficient_dwi_fit(gtab, diffusion_model, imagein, maskin,
3658
+ model_params=None, bvals_to_use=None,
3659
+ chunk_size=10, num_threads=1, verbose=True):
3660
+ """
3661
+ Efficient and optionally parallelized diffusion model reconstruction using DiPy.
3662
+
3663
+ Parameters
3664
+ ----------
3665
+ gtab : GradientTable
3666
+ DiPy gradient table.
3667
+ diffusion_model : str
3668
+ One of ['DTI', 'FreeWater', 'DKI'].
3669
+ imagein : ants.ANTsImage
3670
+ 4D diffusion-weighted image.
3671
+ maskin : ants.ANTsImage
3672
+ Binary brain mask image.
3673
+ model_params : dict, optional
3674
+ Additional parameters passed to model constructors.
3675
+ bvals_to_use : list of int, optional
3676
+ Subset of b-values to use for the fit (e.g., [0, 1000, 2000]).
3677
+ chunk_size : int, optional
3678
+ Z-axis slice chunk size.
3679
+ num_threads : int, optional
3680
+ Number of parallel threads.
3681
+ verbose : bool, optional
3682
+ Whether to print status messages.
3683
+
3684
+ Returns
3685
+ -------
3686
+ fit : dipy ModelFit
3687
+ The fitted model object.
3688
+ FA : ants.ANTsImage or None
3689
+ Fractional anisotropy image (if applicable).
3690
+ MD : ants.ANTsImage or None
3691
+ Mean diffusivity image (if applicable).
3692
+ RGB : ants.ANTsImage or None
3693
+ Color FA image (if applicable).
3694
+ """
3695
+ import ants
3696
+ import numpy as np
3697
+ import dipy.reconst.dti as dti
3698
+ import dipy.reconst.fwdti as fwdti
3699
+ import dipy.reconst.dki as dki
3700
+ from dipy.core.gradients import gradient_table
3701
+ from dipy.reconst.dti import fractional_anisotropy, color_fa, mean_diffusivity
3702
+ from concurrent.futures import ThreadPoolExecutor, as_completed
3703
+
3704
+ assert imagein.dimension == 4, "Input image must be 4D"
3705
+ model_params = model_params or {}
3706
+
3707
+ img_data = imagein.numpy()
3708
+ mask = maskin.numpy().astype(bool)
3709
+ X, Y, Z, N = img_data.shape
3710
+
3711
+ if verbose:
3712
+ print(f"[INFO] Image shape: {img_data.shape}")
3713
+ print(f"[INFO] Using model: {diffusion_model}")
3714
+ print(f"[INFO] Chunk size: {chunk_size} | Threads: {num_threads}")
3715
+
3716
+ # Filter shells if specified
3717
+ if bvals_to_use is not None:
3718
+ bvals_to_use = set(bvals_to_use)
3719
+ sel = np.isin(gtab.bvals, list(bvals_to_use))
3720
+ img_data = img_data[..., sel]
3721
+ gtab = gradient_table(gtab.bvals[sel], gtab.bvecs[sel])
3722
+ if verbose:
3723
+ print(f"[INFO] Selected b-values: {sorted(bvals_to_use)}")
3724
+ print(f"[INFO] Selected volumes: {sel.sum()} / {N}")
3725
+
3726
+ # Choose model
3727
+ def get_model(name, gtab, **params):
3728
+ if name == 'DTI':
3729
+ return dti.TensorModel(gtab, **params)
3730
+ elif name == 'FreeWater':
3731
+ return fwdti.FreeWaterTensorModel(gtab)
3732
+ elif name == 'DKI':
3733
+ return dki.DiffusionKurtosisModel(gtab, **params)
3734
+ else:
3735
+ raise ValueError(f"Unsupported model: {name}")
3736
+
3737
+ model = get_model(diffusion_model, gtab, **model_params)
3738
+
3739
+ # Output volumes initialized to zero
3740
+ FA_vol = np.zeros((X, Y, Z), dtype=np.float32)
3741
+ MD_vol = np.zeros((X, Y, Z), dtype=np.float32)
3742
+ RGB_vol = np.zeros((X, Y, Z, 3), dtype=np.float32)
3743
+ has_tensor_metrics = diffusion_model in ['DTI', 'FreeWater']
3744
+
3745
+ def process_chunk(z_start):
3746
+ z_end = min(Z, z_start + chunk_size)
3747
+ local_data = img_data[:, :, z_start:z_end, :]
3748
+ local_mask = mask[:, :, z_start:z_end]
3749
+ masked_data = local_data * local_mask[..., None]
3750
+ masked_data = np.nan_to_num(masked_data, nan=0)
3751
+ fit = model.fit(masked_data)
3752
+ if has_tensor_metrics and hasattr(fit, 'evals') and hasattr(fit, 'evecs'):
3753
+ FA = fractional_anisotropy(fit.evals)
3754
+ FA[np.isnan(FA)] = 1
3755
+ FA = np.clip(FA, 0, 1)
3756
+ MD = mean_diffusivity(fit.evals)
3757
+ RGB = color_fa(FA, fit.evecs)
3758
+ return z_start, z_end, FA, MD, RGB
3759
+ return z_start, z_end, None, None, None
3760
+
3761
+ # Run processing
3762
+ chunks = range(0, Z, chunk_size)
3763
+ if num_threads > 1:
3764
+ with ThreadPoolExecutor(max_workers=num_threads) as executor:
3765
+ futures = {executor.submit(process_chunk, z): z for z in chunks}
3766
+ for f in as_completed(futures):
3767
+ z_start, z_end, FA, MD, RGB = f.result()
3768
+ if FA is not None:
3769
+ FA_vol[:, :, z_start:z_end] = FA
3770
+ MD_vol[:, :, z_start:z_end] = MD
3771
+ RGB_vol[:, :, z_start:z_end, :] = RGB
3772
+ else:
3773
+ for z in chunks:
3774
+ z_start, z_end, FA, MD, RGB = process_chunk(z)
3775
+ if FA is not None:
3776
+ FA_vol[:, :, z_start:z_end] = FA
3777
+ MD_vol[:, :, z_start:z_end] = MD
3778
+ RGB_vol[:, :, z_start:z_end, :] = RGB
3779
+
3780
+ b0 = ants.slice_image(imagein, axis=3, idx=0)
3781
+ FA_img = ants.copy_image_info(b0, ants.from_numpy(FA_vol)) if has_tensor_metrics else None
3782
+ MD_img = ants.copy_image_info(b0, ants.from_numpy(MD_vol)) if has_tensor_metrics else None
3783
+ RGB_img = (ants.merge_channels([
3784
+ ants.copy_image_info(b0, ants.from_numpy(RGB_vol[..., i])) for i in range(3)
3785
+ ]) if has_tensor_metrics else None)
3786
+
3787
+ full_fit = model.fit(img_data * mask[..., None])
3788
+ return full_fit, FA_img, MD_img, RGB_img
3789
+
3790
+
3525
3791
  def dipy_dti_recon(
3526
3792
  image,
3527
3793
  bvalsfn,
@@ -3532,7 +3798,7 @@ def dipy_dti_recon(
3532
3798
  mask_closing = 5,
3533
3799
  fit_method='WLS',
3534
3800
  trim_the_mask=2.0,
3535
- free_water=False,
3801
+ diffusion_model='DTI',
3536
3802
  verbose=False ):
3537
3803
  """
3538
3804
  DiPy DTI reconstruction - building on the DiPy basic DTI example
@@ -3559,7 +3825,8 @@ def dipy_dti_recon(
3559
3825
 
3560
3826
  trim_the_mask : float >=0 post-hoc method for trimming the mask
3561
3827
 
3562
- free_water : boolean
3828
+ diffusion_model : string
3829
+ DTI, FreeWater, DKI
3563
3830
 
3564
3831
  verbose : boolean
3565
3832
 
@@ -3612,47 +3879,22 @@ def dipy_dti_recon(
3612
3879
  if verbose:
3613
3880
  print("recon dti.TensorModel",flush=True)
3614
3881
 
3615
- def justthefit( gtab, fit_method, imagein, maskin, free_water=False ):
3616
- if fit_method is None:
3617
- return None, None, None, None
3618
- maskedimage=[]
3619
- for myidx in range(imagein.shape[3]):
3620
- b0 = ants.slice_image( imagein, axis=3, idx=myidx)
3621
- maskedimage.append( b0 * maskin )
3622
- maskedimage = ants.list_to_ndimage( imagein, maskedimage )
3623
- maskdata = maskedimage.numpy()
3624
- if free_water:
3625
- tenmodel = fwdti.FreeWaterTensorModel(gtab)
3626
- else:
3627
- tenmodel = dti.TensorModel(gtab,fit_method=fit_method)
3628
- tenfit = tenmodel.fit(maskdata)
3629
- FA = fractional_anisotropy(tenfit.evals)
3630
- FA[np.isnan(FA)] = 1
3631
- FA = np.clip(FA, 0, 1)
3632
- MD1 = dti.mean_diffusivity(tenfit.evals)
3633
- MD1 = ants.copy_image_info( b0, ants.from_numpy( MD1.astype(np.float32) ) )
3634
- FA = ants.copy_image_info( b0, ants.from_numpy( FA.astype(np.float32) ) )
3635
- FA, MD1 = impute_fa( FA, MD1 )
3636
- RGB = color_fa(FA.numpy(), tenfit.evecs)
3637
- RGB = ants.from_numpy( RGB.astype(np.float32) )
3638
- RGB0 = ants.copy_image_info( b0, ants.slice_image( RGB, axis=3, idx=0 ) )
3639
- RGB1 = ants.copy_image_info( b0, ants.slice_image( RGB, axis=3, idx=1 ) )
3640
- RGB2 = ants.copy_image_info( b0, ants.slice_image( RGB, axis=3, idx=2 ) )
3641
- RGB = ants.merge_channels( [RGB0,RGB1,RGB2] )
3642
- return tenfit, FA, MD1, RGB
3643
-
3644
3882
  bvecs = repair_bvecs( bvecs )
3645
- gtab = gradient_table(bvals, bvecs, atol=2.0 )
3646
- if free_water:
3647
- free_water=len( np.unique( bvals ) ) >= 3
3648
- tenfit, FA, MD1, RGB = justthefit( gtab, fit_method, image, maskdil, free_water=free_water )
3883
+ gtab = gradient_table(bvals, bvecs=bvecs, atol=2.0 )
3884
+ mynt=1
3885
+ threads_env = os.environ.get("ITK_GLOBAL_DEFAULT_NUMBER_OF_THREADS")
3886
+ if threads_env is not None:
3887
+ mynt = int(threads_env)
3888
+ tenfit, FA, MD1, RGB = efficient_dwi_fit( gtab, diffusion_model, image, maskdil,
3889
+ num_threads=mynt )
3649
3890
  if verbose:
3650
3891
  print("recon dti.TensorModel done",flush=True)
3651
3892
 
3652
3893
  # change the brain mask based on high FA values
3653
3894
  if trim_the_mask > 0 and fit_method is not None:
3654
3895
  mask = trim_dti_mask( FA, mask, trim_the_mask )
3655
- tenfit, FA, MD1, RGB = justthefit( gtab, fit_method, image, mask, free_water=free_water )
3896
+ tenfit, FA, MD1, RGB = efficient_dwi_fit( gtab, diffusion_model, image, maskdil,
3897
+ num_threads=mynt )
3656
3898
 
3657
3899
  return {
3658
3900
  'tensormodel' : tenfit,
@@ -3736,7 +3978,7 @@ def joint_dti_recon(
3736
3978
  fit_method='WLS',
3737
3979
  impute = False,
3738
3980
  censor = True,
3739
- free_water = False,
3981
+ diffusion_model = 'DTI',
3740
3982
  verbose = False ):
3741
3983
  """
3742
3984
  1. pass in subject data and 1mm JHU atlas/labels
@@ -3791,7 +4033,8 @@ def joint_dti_recon(
3791
4033
 
3792
4034
  censor : boolean
3793
4035
 
3794
- free_water : boolean
4036
+ diffusion_model : string
4037
+ DTI, FreeWater, DKI
3795
4038
 
3796
4039
  verbose : boolean
3797
4040
 
@@ -3911,7 +4154,7 @@ def joint_dti_recon(
3911
4154
  img_LRdwp, bval_LR, bvec_LR,
3912
4155
  mask = brain_mask,
3913
4156
  fit_method=fit_method,
3914
- mask_dilation=0, free_water=free_water, verbose=True )
4157
+ mask_dilation=0, diffusion_model=diffusion_model, verbose=True )
3915
4158
  if verbose:
3916
4159
  print("recon done", flush=True)
3917
4160
 
@@ -4204,16 +4447,15 @@ def dwi_deterministic_tracking(
4204
4447
  if verbose:
4205
4448
  print("begin tracking",flush=True)
4206
4449
 
4207
- dwi_img = to_nibabel(dwi)
4208
- affine = dwi_img.affine
4450
+ affine = ants_to_nibabel_affine(dwi)
4209
4451
 
4210
4452
  if isinstance( bvals, str ) or isinstance( bvecs, str ):
4211
4453
  bvals, bvecs = read_bvals_bvecs(bvals, bvecs)
4212
4454
  bvecs = repair_bvecs( bvecs )
4213
- gtab = gradient_table(bvals, bvecs, atol=2.0 )
4455
+ gtab = gradient_table(bvals, bvecs=bvecs, atol=2.0 )
4214
4456
  if mask is None:
4215
4457
  mask = ants.threshold_image( fa, fa_thresh, 2.0 ).iMath("GetLargestComponent")
4216
- dwi_data = dwi_img.get_fdata()
4458
+ dwi_data = dwi.numpy() # dwi_img.get_fdata()
4217
4459
  dwi_mask = mask.numpy() == 1
4218
4460
  dti_model = dti.TensorModel(gtab,fit_method=fit_method)
4219
4461
  if verbose:
@@ -4223,7 +4465,7 @@ def dwi_deterministic_tracking(
4223
4465
  from dipy.tracking.stopping_criterion import ThresholdStoppingCriterion
4224
4466
  stopping_criterion = ThresholdStoppingCriterion(fa.numpy(), fa_thresh)
4225
4467
  from dipy.data import get_sphere
4226
- sphere = get_sphere('symmetric362')
4468
+ sphere = get_sphere(name='symmetric362')
4227
4469
  from dipy.direction import peaks_from_model
4228
4470
  if peak_indices is None:
4229
4471
  # problems with multi-threading ...
@@ -4282,7 +4524,7 @@ def dwi_deterministic_tracking(
4282
4524
  streamlines = Streamlines(streamlines_generator)
4283
4525
  from dipy.io.stateful_tractogram import Space, StatefulTractogram
4284
4526
  from dipy.io.streamline import save_tractogram
4285
- sft = StatefulTractogram(streamlines, dwi_img, Space.RASMM)
4527
+ sft = None # StatefulTractogram(streamlines, dwi_img, Space.RASMM)
4286
4528
  if verbose:
4287
4529
  print("streamlines done", flush=True)
4288
4530
  return {
@@ -4384,15 +4626,14 @@ def dwi_closest_peak_tracking(
4384
4626
  if verbose:
4385
4627
  print("begin tracking",flush=True)
4386
4628
 
4387
- dwi_img = to_nibabel(dwi)
4388
- affine = dwi_img.affine
4629
+ affine = ants_to_nibabel_affine(dwi)
4389
4630
  if isinstance( bvals, str ) or isinstance( bvecs, str ):
4390
4631
  bvals, bvecs = read_bvals_bvecs(bvals, bvecs)
4391
4632
  bvecs = repair_bvecs( bvecs )
4392
- gtab = gradient_table(bvals, bvecs, atol=2.0 )
4633
+ gtab = gradient_table(bvals, bvecs=bvecs, atol=2.0 )
4393
4634
  if mask is None:
4394
4635
  mask = ants.threshold_image( fa, fa_thresh, 2.0 ).iMath("GetLargestComponent")
4395
- dwi_data = dwi_img.get_fdata()
4636
+ dwi_data = dwi.numpy()
4396
4637
  dwi_mask = mask.numpy() == 1
4397
4638
 
4398
4639
 
@@ -4429,7 +4670,7 @@ def dwi_closest_peak_tracking(
4429
4670
  streamlines = Streamlines(streamlines_generator)
4430
4671
  from dipy.io.stateful_tractogram import Space, StatefulTractogram
4431
4672
  from dipy.io.streamline import save_tractogram
4432
- sft = StatefulTractogram(streamlines, dwi_img, Space.RASMM)
4673
+ sft = None # StatefulTractogram(streamlines, dwi_img, Space.RASMM)
4433
4674
  if verbose:
4434
4675
  print("streamlines done", flush=True)
4435
4676
  return {
@@ -4465,8 +4706,7 @@ def dwi_streamline_pairwise_connectivity( streamlines, label_image, labels_to_co
4465
4706
  from dipy.tracking.streamline import Streamlines
4466
4707
  keep_streamlines = Streamlines()
4467
4708
 
4468
-
4469
- affine = to_nibabel(label_image).affine
4709
+ affine = ants_to_nibabel_affine(label_image) # to_nibabel(label_image).affine
4470
4710
 
4471
4711
  lin_T, offset = utils._mapping_to_voxel(affine)
4472
4712
  label_image_np = label_image.numpy()
@@ -4526,7 +4766,7 @@ def dwi_streamline_pairwise_connectivity_old(
4526
4766
  volUnit = np.prod( ants.get_spacing( label_image ) )
4527
4767
  labels = label_image.numpy()
4528
4768
 
4529
- affine = to_nibabel(label_image).affine
4769
+ affine = ants_to_nibabel_affine(label_image) # to_nibabel(label_image).affine
4530
4770
 
4531
4771
  import numpy as np
4532
4772
  from dipy.io.image import load_nifti_data, load_nifti, save_nifti
@@ -4618,7 +4858,7 @@ def dwi_streamline_connectivity(
4618
4858
  volUnit = np.prod( ants.get_spacing( label_image ) )
4619
4859
  labels = label_image.numpy()
4620
4860
 
4621
- affine = to_nibabel(label_image).affine
4861
+ affine = ants_to_nibabel_affine(label_image) # to_nibabel(label_image).affine
4622
4862
 
4623
4863
  import numpy as np
4624
4864
  from dipy.io.image import load_nifti_data, load_nifti, save_nifti
@@ -4708,7 +4948,7 @@ def dwi_streamline_connectivity_old(
4708
4948
  volUnit = np.prod( ants.get_spacing( label_image ) )
4709
4949
  labels = label_image.numpy()
4710
4950
 
4711
- affine = to_nibabel(label_image).affine
4951
+ affine = ants_to_nibabel_affine(label_image) # to_nibabel(label_image).affine
4712
4952
 
4713
4953
  if verbose:
4714
4954
  print("path length begin ... volUnit = " + str( volUnit ) )
@@ -7480,7 +7720,8 @@ def write_mm( output_prefix, mm, mm_norm=None, t1wide=None, separator='_', verbo
7480
7720
  if 'tractography' in mm:
7481
7721
  if mm['tractography'] is not None:
7482
7722
  ofn = output_prefix + separator + 'tractogram.trk'
7483
- save_tractogram( mm['tractography']['tractogram'], ofn )
7723
+ if mm['tractography']['tractogram'] is not None:
7724
+ save_tractogram( mm['tractography']['tractogram'], ofn )
7484
7725
  cnxderk = None
7485
7726
  if 'tractography_connectivity' in mm:
7486
7727
  if mm['tractography_connectivity'] is not None:
@@ -10666,94 +10907,104 @@ def calculate_loop_scores_full(flattened_series, n_neighbors=20, verbose=True ):
10666
10907
  return m.local_outlier_probabilities[:]
10667
10908
 
10668
10909
 
10669
- def calculate_loop_scores(flattened_series, n_neighbors=20,
10670
- n_features_sample=10000, seed=42, verbose=True):
10910
+ def calculate_loop_scores(
10911
+ flattened_series,
10912
+ n_neighbors=20,
10913
+ n_features_sample=0.02,
10914
+ n_feature_repeats=5,
10915
+ seed=42,
10916
+ use_approx_knn=True,
10917
+ verbose=True,
10918
+ ):
10671
10919
  """
10672
- Approximate LoOP scores using a random subset of features to reduce memory usage.
10920
+ Memory-efficient and robust LoOP score estimation with optional approximate KNN
10921
+ and averaging over multiple random feature subsets.
10673
10922
 
10674
10923
  Parameters:
10675
- flattened_series (np.ndarray): 2D array of shape (n_samples, n_features)
10676
- n_neighbors (int): Number of neighbors for LOF/LoOP computation
10677
- n_features_sample (int): Number of features to sample for approximation
10678
- seed (int): Random seed for reproducible feature sampling
10679
- verbose (bool): If True, print detailed progress and dimensions
10924
+ flattened_series (np.ndarray): 2D array (n_samples x n_features)
10925
+ n_neighbors (int): Number of neighbors for LoOP
10926
+ n_features_sample (int or float): Number or fraction of features to sample
10927
+ n_feature_repeats (int): How many independent feature subsets to sample and average over
10928
+ seed (int): Random seed
10929
+ use_approx_knn (bool): Whether to use fast approximate KNN (via pynndescent)
10930
+ verbose (bool): Verbose output
10680
10931
 
10681
10932
  Returns:
10682
- np.ndarray: 1D array of local outlier probabilities (length n_samples)
10933
+ np.ndarray: Averaged local outlier probabilities (length n_samples)
10683
10934
  """
10684
10935
  import numpy as np
10685
10936
  from sklearn.preprocessing import StandardScaler
10686
- from sklearn.neighbors import NearestNeighbors
10687
10937
  from PyNomaly import loop
10688
10938
 
10689
- # -------------------------------
10690
- # Step 1: Input stats and cleanup
10691
- # -------------------------------
10939
+ # Optional approximate nearest neighbors
10940
+ try:
10941
+ from pynndescent import NNDescent
10942
+ has_nn_descent = True
10943
+ except ImportError:
10944
+ has_nn_descent = False
10945
+
10946
+ rng = np.random.default_rng(seed)
10692
10947
  X = np.nan_to_num(flattened_series, nan=0).astype(np.float32)
10693
10948
  n_samples, n_features = X.shape
10694
10949
 
10695
- if verbose:
10696
- print("\n[LoOP Approximation - Verbose Mode]")
10697
- print(f"- Original input shape: {X.shape} (samples x features)")
10698
- print(f"- Requested sampled features: {n_features_sample}")
10699
-
10700
- if n_features_sample > n_features:
10701
- n_features_sample = n_features
10702
- if verbose:
10703
- print(f"- Requested n_features_sample exceeds available features. Using all {n_features} features.")
10950
+ # Handle feature sampling
10951
+ if isinstance(n_features_sample, float):
10952
+ if 0 < n_features_sample <= 1.0:
10953
+ n_features_sample = max(1, int(n_features_sample * n_features))
10954
+ else:
10955
+ raise ValueError("If float, n_features_sample must be in (0, 1].")
10704
10956
 
10705
- # -------------------------------
10706
- # Step 2: Feature sampling
10707
- # -------------------------------
10708
- rng = np.random.default_rng(seed)
10709
- sampled_indices = rng.choice(n_features, n_features_sample, replace=False)
10710
- X_sampled = X[:, sampled_indices]
10957
+ n_features_sample = min(n_features, n_features_sample)
10711
10958
 
10712
- if verbose:
10713
- print(f"- Sampled feature shape: {X_sampled.shape} (samples x sampled_features)")
10714
- print(f"- Random seed for reproducibility: {seed}")
10715
-
10716
- # -------------------------------
10717
- # Step 3: Standardization
10718
- # -------------------------------
10719
- scaler = StandardScaler(copy=False)
10720
- X_sampled = scaler.fit_transform(X_sampled)
10721
- X_sampled = np.nan_to_num(X_sampled, nan=0)
10722
-
10723
- # -------------------------------
10724
- # Step 4: KNN setup for LoOP
10725
- # -------------------------------
10726
10959
  if n_neighbors >= n_samples:
10727
10960
  n_neighbors = max(1, n_samples // 2)
10728
- if verbose:
10729
- print(f"- Adjusted n_neighbors to {n_neighbors} (was too large for available samples).")
10730
10961
 
10731
10962
  if verbose:
10732
- print(f"- Performing KNN using Minkowski distance (default p=2, Euclidean)")
10733
- print(f"- Each point will use its {n_neighbors} nearest neighbors for local density estimation")
10963
+ print(f"[LoOP] Input shape: {X.shape}")
10964
+ print(f"[LoOP] Sampling {n_features_sample} features per repeat, {n_feature_repeats} repeats")
10965
+ print(f"[LoOP] Using {n_neighbors} neighbors")
10734
10966
 
10735
- neigh = NearestNeighbors(n_neighbors=n_neighbors)
10736
- neigh.fit(X_sampled)
10737
- dists, indices = neigh.kneighbors(X_sampled, return_distance=True)
10967
+ loop_scores = []
10738
10968
 
10739
- # -------------------------------
10740
- # Step 5: LoOP probability calculation
10741
- # -------------------------------
10742
- if verbose:
10743
- print(f"- Distance matrix shape: {dists.shape} (samples x n_neighbors)")
10744
- print(f"- Neighbor index matrix shape: {indices.shape}")
10745
- print("- Estimating Local Outlier Probabilities (LoOP)...")
10969
+ for rep in range(n_feature_repeats):
10970
+ feature_idx = rng.choice(n_features, n_features_sample, replace=False)
10971
+ X_sub = X[:, feature_idx]
10746
10972
 
10747
- model = loop.LocalOutlierProbability(
10748
- distance_matrix=dists,
10749
- neighbor_matrix=indices,
10750
- n_neighbors=n_neighbors
10751
- ).fit()
10973
+ scaler = StandardScaler(copy=False)
10974
+ X_sub = scaler.fit_transform(X_sub)
10975
+ X_sub = np.nan_to_num(X_sub, nan=0)
10976
+
10977
+ # Approximate or exact KNN
10978
+ if use_approx_knn and has_nn_descent and n_samples > 1000:
10979
+ if verbose:
10980
+ print(f" [Rep {rep+1}] Using NNDescent (approximate KNN)")
10981
+ ann = NNDescent(X_sub, n_neighbors=n_neighbors, random_state=seed + rep)
10982
+ indices, dists = ann.neighbor_graph
10983
+ else:
10984
+ from sklearn.neighbors import NearestNeighbors
10985
+ if verbose:
10986
+ print(f" [Rep {rep+1}] Using NearestNeighbors (exact KNN)")
10987
+ nn = NearestNeighbors(n_neighbors=n_neighbors)
10988
+ nn.fit(X_sub)
10989
+ dists, indices = nn.kneighbors(X_sub)
10990
+
10991
+ # LoOP score for this repeat
10992
+ model = loop.LocalOutlierProbability(
10993
+ distance_matrix=dists,
10994
+ neighbor_matrix=indices,
10995
+ n_neighbors=n_neighbors
10996
+ ).fit()
10997
+ loop_scores.append(model.local_outlier_probabilities[:])
10998
+
10999
+ # Average over repeats
11000
+ loop_scores = np.stack(loop_scores)
11001
+ loop_scores_mean = loop_scores.mean(axis=0)
10752
11002
 
10753
11003
  if verbose:
10754
- print("- LoOP scoring complete.\n")
11004
+ print(f"[LoOP] Averaged over {n_feature_repeats} feature subsets. Final shape: {loop_scores_mean.shape}")
11005
+
11006
+ return loop_scores_mean
10755
11007
 
10756
- return model.local_outlier_probabilities[:]
10757
11008
 
10758
11009
 
10759
11010
  def score_fmri_censoring(cbfts, csf_seg, gm_seg, wm_seg ):
@@ -10823,7 +11074,7 @@ def score_fmri_censoring(cbfts, csf_seg, gm_seg, wm_seg ):
10823
11074
  cbfts_recon_ants = ants.copy_image_info(cbfts, cbfts_recon_ants)
10824
11075
  return cbfts_recon_ants, indx
10825
11076
 
10826
- def loop_timeseries_censoring(x, threshold=0.5, mask=None, n_features_sample=10000, verbose=True):
11077
+ def loop_timeseries_censoring(x, threshold=0.5, mask=None, n_features_sample=0.02, seed=42, verbose=True):
10827
11078
  """
10828
11079
  Censor high leverage volumes from a time series using Local Outlier Probabilities (LoOP).
10829
11080
 
@@ -10831,7 +11082,8 @@ def loop_timeseries_censoring(x, threshold=0.5, mask=None, n_features_sample=100
10831
11082
  x (ANTsImage): A 4D time series image.
10832
11083
  threshold (float): Threshold for determining high leverage volumes based on LoOP scores.
10833
11084
  mask (antsImage): restricts to a ROI
10834
- n_features_sample (int): feature sample size default 5000
11085
+ n_features_sample (int/float): feature sample size default 0.01; if less than one then this is interpreted as a percentage of the total features otherwise it sets the number of features to be used
11086
+ seed (int): random seed
10835
11087
  verbose (bool)
10836
11088
 
10837
11089
  Returns:
@@ -10847,7 +11099,7 @@ def loop_timeseries_censoring(x, threshold=0.5, mask=None, n_features_sample=100
10847
11099
  flattened_series = ants.timeseries_to_matrix( x, mask )
10848
11100
  if verbose:
10849
11101
  print("loop_timeseries_censoring: flattened")
10850
- loop_scores = calculate_loop_scores(flattened_series, n_features_sample=n_features_sample, verbose=verbose )
11102
+ loop_scores = calculate_loop_scores(flattened_series, n_features_sample=n_features_sample, seed=seed, verbose=verbose )
10851
11103
  high_leverage_volumes = np.where(loop_scores > threshold)[0]
10852
11104
  if verbose:
10853
11105
  print("loop_timeseries_censoring: High Leverage Volumes:", high_leverage_volumes)
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: antspymm
3
- Version: 1.4.9
3
+ Version: 1.5.1
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
7
7
  Classifier: License :: OSI Approved :: Apache Software License
8
8
  Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Operating System :: OS Independent
10
- Requires-Python: >=3.10
10
+ Requires-Python: >=3.9
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
13
  Requires-Dist: h5py>=2.10.0
@@ -0,0 +1,7 @@
1
+ antspymm/__init__.py,sha256=DnkidUfEu3Dl0tuWNTA-9VOUkBtH_cROKiPGNNXNagU,4637
2
+ antspymm/mm.py,sha256=oPHhV70IhFXCKI26rP5HgKpkb2OjqCWkqoy4cSxJXqA,526866
3
+ antspymm-1.5.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
4
+ antspymm-1.5.1.dist-info/METADATA,sha256=_I4UmZGWLM6KkmTJc21wKW1DrjT2TY723C1jr7LA3Fw,25939
5
+ antspymm-1.5.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
+ antspymm-1.5.1.dist-info/top_level.txt,sha256=iyD1sRhCKzfwKRJLq5ZUeV9xsv1cGQl8Ejp6QwXM1Zg,9
7
+ antspymm-1.5.1.dist-info/RECORD,,
@@ -1,7 +0,0 @@
1
- antspymm/__init__.py,sha256=ZdNJyHwS6rzq59v0OK3tE3qSTD0za2iULzSLGkM_0uc,4527
2
- antspymm/mm.py,sha256=tJXaT-81XEjNjCOhmGKCjSRB7HEM2z_mlAWxKwJlc3M,517529
3
- antspymm-1.4.9.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
4
- antspymm-1.4.9.dist-info/METADATA,sha256=6uEIRuATG8mqBBCQxdXHyd15xDeVBcyFOH3T-OnFYHw,25940
5
- antspymm-1.4.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- antspymm-1.4.9.dist-info/top_level.txt,sha256=iyD1sRhCKzfwKRJLq5ZUeV9xsv1cGQl8Ejp6QwXM1Zg,9
7
- antspymm-1.4.9.dist-info/RECORD,,