augmentrum 0.0.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.
Files changed (42) hide show
  1. augmentrum/__init__.py +23 -0
  2. augmentrum/augmentation/__init__.py +32 -0
  3. augmentrum/augmentation/amplitude_scaling.py +170 -0
  4. augmentrum/augmentation/apodization.py +275 -0
  5. augmentrum/augmentation/artificial_peaks.py +334 -0
  6. augmentrum/augmentation/baseline_augmentation.py +544 -0
  7. augmentrum/augmentation/eddy_current.py +278 -0
  8. augmentrum/augmentation/gaussian_noise.py +211 -0
  9. augmentrum/augmentation/line_broadening.py +179 -0
  10. augmentrum/augmentation/phase_frequency.py +259 -0
  11. augmentrum/augmentation/residual_water.py +264 -0
  12. augmentrum/augmentation/spatial_augmentations.py +859 -0
  13. augmentrum/augmentation/spurious_echoes.py +378 -0
  14. augmentrum/augmentation/zero_fill.py +163 -0
  15. augmentrum/core/__init__.py +10 -0
  16. augmentrum/core/augmentrum.py +1172 -0
  17. augmentrum/core/base_module.py +423 -0
  18. augmentrum/core/dataset_utils.py +417 -0
  19. augmentrum/core/nifti_mrs_plus.py +1079 -0
  20. augmentrum/core/pipeline.py +358 -0
  21. augmentrum/dataset/brainbeats.py +537 -0
  22. augmentrum/dataset/cows.py +302 -0
  23. augmentrum/dataset/fmrsinpain.py +208 -0
  24. augmentrum/processing/bicubic_interpolator.py +296 -0
  25. augmentrum/processing/nifti_raw_processor.py +389 -0
  26. augmentrum/processing/tricubic_interpolator.py +293 -0
  27. augmentrum/processing/utils.py +414 -0
  28. augmentrum/resources/__init__.py +14 -0
  29. augmentrum/resources/scans/__init__.py +0 -0
  30. augmentrum/sampling/coil_average_sampler.py +274 -0
  31. augmentrum/sampling/kspace_reconstructor.py +275 -0
  32. augmentrum/sampling/kspace_sampling.py +2562 -0
  33. augmentrum/sampling/subject_splitter.py +131 -0
  34. augmentrum/utils/__init__.py +10 -0
  35. augmentrum/utils/philips.py +90 -0
  36. augmentrum/utils/plotting.py +651 -0
  37. augmentrum/utils/tensor_ops.py +203 -0
  38. augmentrum-0.0.1.dist-info/METADATA +189 -0
  39. augmentrum-0.0.1.dist-info/RECORD +42 -0
  40. augmentrum-0.0.1.dist-info/WHEEL +5 -0
  41. augmentrum-0.0.1.dist-info/licenses/LICENSE +202 -0
  42. augmentrum-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,859 @@
1
+ ####################################################################################################
2
+ # spatial_augmentations.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. T. LaMaster (john.t.lamaster@gmail.com) #
6
+ # J. P. Merkofer (j.p.merkofer@tue.nl) #
7
+ # #
8
+ # Created: 2025-11-28 #
9
+ # #
10
+ # Purpose: Defines SpatialAugmentations, a modular class that samples and defines multiple #
11
+ # augmentations with parameters and prbabilities. It returns a list of partially defined #
12
+ # augmentation functions and a list of dictionaries describing the provenance for each #
13
+ # sample in the mini-batch. #
14
+ # #
15
+ ####################################################################################################
16
+
17
+ import math
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ from functools import partial
22
+ from typing import Optional, Tuple, List, Dict, Any, Union
23
+
24
+ import numpy as np
25
+ from augmentrum.core.base_module import BaseModule
26
+ from augmentrum.core.nifti_mrs_plus import Backend, NIfTI_MRS_Plus
27
+
28
+
29
+ __all__ = ['SpatialAugmentations']
30
+
31
+
32
+ # ----------------------
33
+ # Quaternion utilities
34
+ # ----------------------
35
+ def quat_from_axis_angle(axis: torch.Tensor, angle_rad: float, device=None) -> torch.Tensor:
36
+ axis = axis.to(device) if device is not None else axis
37
+ axis = axis / (axis.norm() + 1e-12)
38
+ s = math.sin(angle_rad / 2.0)
39
+ w = math.cos(angle_rad / 2.0)
40
+ x, y, z = (axis * s).tolist()
41
+ return torch.tensor([w, x, y, z], dtype=torch.float32, device=device)
42
+
43
+
44
+ def quat_mul(q1: torch.Tensor, q2: torch.Tensor) -> torch.Tensor:
45
+ # Hamilton product q = q1 * q2
46
+ w1, x1, y1, z1 = q1
47
+ w2, x2, y2, z2 = q2
48
+ w = w1 * w2 - x1 * x2 - y1 * y2 - z1 * z2
49
+ x = w1 * x2 + x1 * w2 + y1 * z2 - z1 * y2
50
+ y = w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2
51
+ z = w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2
52
+ return torch.stack([w, x, y, z])
53
+
54
+
55
+ def quat_to_rotmat(q: torch.Tensor) -> torch.Tensor:
56
+ # q: [4] tensor [w, x, y, z]
57
+ w, x, y, z = q
58
+ ww = w * w
59
+ xx = x * x
60
+ yy = y * y
61
+ zz = z * z
62
+ wx = w * x
63
+ wy = w * y
64
+ wz = w * z
65
+ xy = x * y
66
+ xz = x * z
67
+ yz = y * z
68
+ R = torch.stack([
69
+ torch.stack([ww + xx - yy - zz, 2 * (xy - wz), 2 * (xz + wy)]),
70
+ torch.stack([2 * (xy + wz), ww - xx + yy - zz, 2 * (yz - wx)]),
71
+ torch.stack([2 * (xz - wy), 2 * (yz + wx), ww - xx - yy + zz])
72
+ ]) # 3x3
73
+ return R
74
+
75
+
76
+ # ----------------------
77
+ # Builder helpers
78
+ # ----------------------
79
+ def build_affine_2d_from_components(rotation_rad: float,
80
+ scale_xy: Tuple[float, float],
81
+ shear_xy: Tuple[float, float],
82
+ tx: float,
83
+ ty: float,
84
+ flip_x: bool,
85
+ flip_y: bool) -> torch.Tensor:
86
+ """
87
+ Build a 2x3 affine matrix that maps normalized output coords to normalized input coords.
88
+ flips are implemented as -scale on axis when True.
89
+ tx,ty are normalized translations (-1..1).
90
+ """
91
+ # rotation
92
+ c = math.cos(rotation_rad)
93
+ s = math.sin(rotation_rad)
94
+ R = torch.tensor([[c, -s], [s, c]], dtype=torch.float32)
95
+ # shear matrix
96
+ shx, shy = shear_xy
97
+ SH = torch.tensor([[1.0, shx], [shy, 1.0]], dtype=torch.float32)
98
+ sx = -scale_xy[0] if flip_x else scale_xy[0]
99
+ sy = -scale_xy[1] if flip_y else scale_xy[1]
100
+ Sc = torch.diag(torch.tensor([sx, sy], dtype=torch.float32))
101
+ A = R @ SH @ Sc
102
+ T = torch.tensor([tx, ty], dtype=torch.float32).unsqueeze(1)
103
+ mat = torch.cat([A, T], dim=1) # 2x3
104
+ return mat
105
+
106
+
107
+ def build_affine_3d_from_components(R: torch.Tensor,
108
+ scale_xyz: Tuple[float, float, float],
109
+ shear_mat: Optional[torch.Tensor],
110
+ t_xyz: Tuple[float, float, float],
111
+ flip_x: bool, flip_y: bool, flip_z: bool) -> torch.Tensor:
112
+ """
113
+ Build a 3x4 affine matrix that maps normalized output coords to normalized input coords.
114
+
115
+ Args:
116
+ R: 3x3 rotation matrix (torch)
117
+ scale_xyz: (sx, sy, sz)
118
+ shear_mat: 3x3 shear matrix (diagonal should be 1)
119
+ t_xyz: translations normalized
120
+ flips: convert to negative scale when True
121
+
122
+ returns 3x4 affine
123
+ """
124
+ sx, sy, sz = scale_xyz
125
+ sx = -sx if flip_x else sx
126
+ sy = -sy if flip_y else sy
127
+ sz = -sz if flip_z else sz
128
+ Sc = torch.diag(torch.tensor([sx, sy, sz], dtype=torch.float32))
129
+ SH = shear_mat if shear_mat is not None else torch.eye(3, dtype=torch.float32)
130
+ linear = R @ SH @ Sc
131
+ T = torch.tensor(t_xyz, dtype=torch.float32).unsqueeze(1)
132
+ mat = torch.cat([linear, T], dim=1) # 3x4
133
+ return mat
134
+
135
+
136
+ # ----------------------
137
+ # Module
138
+ # ----------------------
139
+ class SpatialAugmentations(BaseModule):
140
+
141
+ # NIfTI_LIST and NUMPY inputs are converted to PyTorch internally.
142
+ # TensorFlow, JAX, and Keras are NOT natively supported — the base-module
143
+ # fallback will route them through the NIfTI-list path (implicit conversion).
144
+ SUPPORTED_BACKENDS = [Backend.NIFTI_LIST, Backend.NUMPY, Backend.PYTORCH]
145
+
146
+ def __init__(self,
147
+ dim: int = 3,
148
+ prob: float = 0.5,
149
+ device: Optional[torch.device] = None,
150
+ # data-pipeline ranges (sampleable as flat kwargs from Augmentrum)
151
+ translation_frac: float = 0.10,
152
+ max_z_angle_deg: float = 30.0,
153
+ max_random_angle_deg: float = 359.0,
154
+ zoom_min: float = 0.9,
155
+ zoom_max: float = 1.1,
156
+ shear_max: float = 0.15,
157
+ scale_min: float = 0.9,
158
+ scale_max: float = 1.1,
159
+ translation_prob: float = 0.1,
160
+ # csm-pipeline overrides (if None, fall back to the data-pipeline value)
161
+ csm_max_z_angle_deg: Optional[float] = 359.0,
162
+ # coil subsampling
163
+ min_coils: int = 6,
164
+ max_coils: Optional[int] = None,
165
+ # additional bulk-override dicts (still supported for convenience)
166
+ data_ranges: Optional[Dict[str, Any]] = None,
167
+ csm_ranges: Optional[Dict[str, Any]] = None,
168
+ pipeline: str = 'data'):
169
+ """
170
+ dim: 2 or 3
171
+ prob: Bernoulli probability to activate each augmentation
172
+ device: torch device
173
+ translation_frac: max translation as fraction of axis length
174
+ max_z_angle_deg: max z-axis rotation for the data pipeline (degrees)
175
+ max_random_angle_deg: max random rotation angle (degrees)
176
+ zoom_min, zoom_max: bounds of the zoom factor range
177
+ shear_max: max shear magnitude
178
+ scale_min, scale_max: bounds of the anisotropic scale factor range
179
+ translation_prob: probability of applying translation (independent of prob)
180
+ csm_max_z_angle_deg: max z-axis rotation for the CSM pipeline (degrees).
181
+ Defaults to 359 (unrestricted). Set to None to mirror
182
+ max_z_angle_deg.
183
+ min_coils, max_coils: integer range of coils to keep when coil subsampling is active
184
+ data_ranges: optional dict to bulk-override any data-pipeline range key
185
+ csm_ranges: optional dict to bulk-override any csm-pipeline range key
186
+ pipeline: default pipeline to use ('data' or 'csm')
187
+
188
+ Pipeline-specific ranges allow different augmentation constraints:
189
+ - Data pipeline: typically has restricted z-axis rotation
190
+ - CSM pipeline: allows unrestricted rotation (full 360°)
191
+
192
+ Every scalar parameter can be passed as a (min, max) tuple to Augmentrum
193
+ for per-batch sampling in on-the-fly mode — e.g. zoom_min=(0.85, 0.95)
194
+ means the lower zoom bound is sampled uniformly from [0.85, 0.95] each batch.
195
+ """
196
+ super().__init__(dim=dim, prob=prob, min_coils=min_coils,
197
+ max_coils=max_coils, pipeline=pipeline)
198
+ assert dim in (2, 3)
199
+ self.dim = dim
200
+ self.prob = float(prob)
201
+ self.device = device or torch.device('cpu')
202
+ self.pipeline = pipeline
203
+
204
+ # Every attribute here is a plain scalar. The pipeline can sample each one
205
+ # independently — e.g. zoom_min=(0.85, 0.95) → sampled to 0.91 per-batch.
206
+ self.translation_frac = translation_frac
207
+ self.max_z_angle_deg = max_z_angle_deg
208
+ self.max_random_angle_deg = max_random_angle_deg
209
+ self.zoom_min = zoom_min
210
+ self.zoom_max = zoom_max
211
+ self.shear_max = shear_max
212
+ self.scale_min = scale_min
213
+ self.scale_max = scale_max
214
+ self.translation_prob = translation_prob
215
+ self.csm_max_z_angle_deg = csm_max_z_angle_deg if csm_max_z_angle_deg is not None else max_z_angle_deg
216
+
217
+ self.min_coils = int(min_coils) if min_coils is not None else None
218
+ self.max_coils = int(max_coils) if max_coils is not None else None
219
+
220
+ # Bulk overrides merged in last so explicit kwargs always win
221
+ self._extra_data_ranges = data_ranges or {}
222
+ self._extra_csm_ranges = csm_ranges or {}
223
+
224
+ # Populated after each forward pass — available for logging / reproducibility
225
+ self.aug_specs_: Optional[List[Dict[str, Any]]] = None
226
+
227
+ # ----------------------
228
+ # Range dict builders
229
+ # ----------------------
230
+ @property
231
+ def data_ranges(self) -> Dict[str, Any]:
232
+ """Live view of data-pipeline ranges, rebuilt from the flat scalar attributes."""
233
+ ranges = {
234
+ 'translation_frac': self.translation_frac,
235
+ 'max_z_angle_deg': self.max_z_angle_deg,
236
+ 'max_random_angle_deg': self.max_random_angle_deg,
237
+ 'zoom_range': (self.zoom_min, self.zoom_max),
238
+ 'shear_max': self.shear_max,
239
+ 'scale_range': (self.scale_min, self.scale_max),
240
+ 'translation_prob': self.translation_prob,
241
+ }
242
+ ranges.update(self._extra_data_ranges)
243
+ return ranges
244
+
245
+ @property
246
+ def csm_ranges(self) -> Dict[str, Any]:
247
+ """Live view of CSM-pipeline ranges, rebuilt from the flat scalar attributes."""
248
+ ranges = {
249
+ 'translation_frac': self.translation_frac,
250
+ 'max_z_angle_deg': self.csm_max_z_angle_deg,
251
+ 'max_random_angle_deg': self.max_random_angle_deg,
252
+ 'zoom_range': (self.zoom_min, self.zoom_max),
253
+ 'shear_max': self.shear_max,
254
+ 'scale_range': (self.scale_min, self.scale_max),
255
+ 'translation_prob': self.translation_prob,
256
+ }
257
+ ranges.update(self._extra_csm_ranges)
258
+ return ranges
259
+
260
+ # ----------------------
261
+ # Sampling augmentations per-sample
262
+ # ----------------------
263
+ def sample_augmentations(self, batch_size: int,
264
+ pipeline: str = 'data',
265
+ rng: Optional[torch.Generator] = None) -> List[Dict[str, Any]]:
266
+ """
267
+ Returns a list of augmentation specification dicts (one per sample).
268
+ Each dict contains booleans 'do_*' keys and corresponding parameter keys.
269
+
270
+ Args:
271
+ batch_size: Number of samples to generate augmentations for
272
+ pipeline: 'data' or 'csm' - determines which range configuration to use
273
+ rng: Optional random number generator
274
+ """
275
+ ranges = self.csm_ranges if pipeline == 'csm' else self.data_ranges
276
+ rng = rng or torch.default_generator
277
+ info_list = []
278
+
279
+ for _ in range(batch_size):
280
+ def bern():
281
+ return (torch.rand(1, generator=rng).item() < self.prob)
282
+
283
+ # decide per-augmentation activation
284
+ do_translate = bern()
285
+ do_z_rot = bern()
286
+ do_rot90 = bern()
287
+ do_random_csm_rot = bern()
288
+ do_zoom = bern()
289
+ do_shear = bern()
290
+ do_flip = bern()
291
+ do_anisotropic = bern()
292
+ do_coil_sub = bern()
293
+
294
+ # sample magnitudes
295
+ tx = 0.0
296
+ ty = 0.0
297
+ tz = 0.0
298
+ if do_translate:
299
+ frac = ranges['translation_frac']
300
+ tx = (torch.rand(1, generator=rng).item() * 2 - 1) * frac
301
+ ty = (torch.rand(1, generator=rng).item() * 2 - 1) * frac
302
+ if self.dim == 3:
303
+ tz = (torch.rand(1, generator=rng).item() * 2 - 1) * frac
304
+
305
+ # z-rotation angle
306
+ z_angle = 0.0
307
+ if do_z_rot:
308
+ max_deg = ranges['max_z_angle_deg']
309
+ z_angle = (torch.rand(1, generator=rng).item() * 2 - 1) * max_deg
310
+
311
+ # random full rotation angle
312
+ random_rot_deg = 0.0
313
+ if do_random_csm_rot:
314
+ random_rot_deg = torch.rand(1, generator=rng).item() * ranges['max_random_angle_deg']
315
+
316
+ # 90-degree rotations (k=1,2,3 for 90,180,270)
317
+ k_rot90 = 0
318
+ if do_rot90:
319
+ k_rot90 = torch.randint(1, 4, (1,), generator=rng).item()
320
+
321
+ # zoom factor
322
+ zoom_factor = 1.0
323
+ if do_zoom:
324
+ lo, hi = ranges['zoom_range']
325
+ zoom_factor = lo + (hi - lo) * torch.rand(1, generator=rng).item()
326
+
327
+ # shear
328
+ shx = 0.0
329
+ shy = 0.0
330
+ shz = 0.0
331
+ if do_shear:
332
+ mx = ranges['shear_max']
333
+ shx = (torch.rand(1, generator=rng).item() * 2 - 1) * mx
334
+ shy = (torch.rand(1, generator=rng).item() * 2 - 1) * mx
335
+ if self.dim == 3:
336
+ shz = (torch.rand(1, generator=rng).item() * 2 - 1) * mx
337
+
338
+ # flip
339
+ flip_x = False
340
+ flip_y = False
341
+ flip_z = False
342
+ if do_flip:
343
+ flip_x = bool(torch.rand(1, generator=rng).item() < 0.5)
344
+ flip_y = bool(torch.rand(1, generator=rng).item() < 0.5)
345
+ if self.dim == 3:
346
+ flip_z = bool(torch.rand(1, generator=rng).item() < 0.5)
347
+
348
+ # anisotropic scaling
349
+ scale_x = 1.0
350
+ scale_y = 1.0
351
+ scale_z = 1.0
352
+ if do_anisotropic:
353
+ lo, hi = ranges['scale_range']
354
+ scale_x = lo + (hi - lo) * torch.rand(1, generator=rng).item()
355
+ scale_y = lo + (hi - lo) * torch.rand(1, generator=rng).item()
356
+ if self.dim == 3:
357
+ scale_z = lo + (hi - lo) * torch.rand(1, generator=rng).item()
358
+
359
+ # coil subsampling
360
+ coil_keep = None
361
+ if do_coil_sub and self.min_coils is not None:
362
+ if self.max_coils is not None:
363
+ coil_keep = torch.randint(self.min_coils, self.max_coils + 1, (1,), generator=rng).item()
364
+ else:
365
+ coil_keep = self.min_coils
366
+
367
+ info = {
368
+ 'do_translate': do_translate,
369
+ 'tx': tx, 'ty': ty, 'tz': tz,
370
+ 'do_z_rot': do_z_rot,
371
+ 'z_angle_deg': z_angle,
372
+ 'do_rot90': do_rot90,
373
+ 'k_rot90': k_rot90,
374
+ 'do_random_csm_rot': do_random_csm_rot,
375
+ 'random_rot_deg': random_rot_deg,
376
+ 'do_zoom': do_zoom,
377
+ 'zoom_factor': zoom_factor,
378
+ 'do_shear': do_shear,
379
+ 'shear_xy': (shx, shy),
380
+ 'shear_z': shz,
381
+ 'do_flip': do_flip,
382
+ 'flip_x': flip_x, 'flip_y': flip_y, 'flip_z': flip_z,
383
+ 'do_anisotropic': do_anisotropic,
384
+ 'scale_xyz': (scale_x, scale_y, scale_z),
385
+ 'do_coil_sub': do_coil_sub,
386
+ 'coil_keep': coil_keep,
387
+ 'pipeline': pipeline, # Track which pipeline was used
388
+ }
389
+ info_list.append(info)
390
+ return info_list
391
+
392
+ # ----------------------
393
+ # Affine composition
394
+ # ----------------------
395
+ def _compose_affines_for_batch(self, aug_spec_list: List[Dict[str, Any]],
396
+ data_shape: torch.Size) -> torch.Tensor:
397
+ """
398
+ Build affine matrices for an entire batch given augmentation specs.
399
+ Returns (N, 2, 3) or (N, 3, 4) depending on dim.
400
+ """
401
+ batch_size = len(aug_spec_list)
402
+ thetas = []
403
+ for spec in aug_spec_list:
404
+ if self.dim == 2:
405
+ # 2D
406
+ rot_rad = math.radians(spec['z_angle_deg']) if spec['do_z_rot'] else 0.0
407
+ # incorporate 90-deg rotation
408
+ if spec['do_rot90']:
409
+ rot_rad += spec['k_rot90'] * math.pi / 2.0
410
+ # random rotation
411
+ if spec['do_random_csm_rot']:
412
+ rot_rad += math.radians(spec['random_rot_deg'])
413
+ sx, sy, _ = spec['scale_xyz']
414
+ sx *= spec['zoom_factor']
415
+ sy *= spec['zoom_factor']
416
+ shx, shy = spec['shear_xy']
417
+ tx = spec['tx']
418
+ ty = spec['ty']
419
+ fx = spec['flip_x']
420
+ fy = spec['flip_y']
421
+ theta = build_affine_2d_from_components(rot_rad, (sx, sy), (shx, shy), tx, ty, fx, fy)
422
+ thetas.append(theta)
423
+ else:
424
+ # 3D
425
+ # Build rotation matrix
426
+ R = torch.eye(3, dtype=torch.float32)
427
+ # z-axis rotation
428
+ if spec['do_z_rot']:
429
+ z_rad = math.radians(spec['z_angle_deg'])
430
+ q_z = quat_from_axis_angle(torch.tensor([0.0, 0.0, 1.0]), z_rad, device=None)
431
+ R = quat_to_rotmat(q_z)
432
+ # random rotation
433
+ if spec['do_random_csm_rot']:
434
+ rand_rad = math.radians(spec['random_rot_deg'])
435
+ axis_rand = torch.randn(3)
436
+ axis_rand = axis_rand / (axis_rand.norm() + 1e-12)
437
+ q_rand = quat_from_axis_angle(axis_rand, rand_rad, device=None)
438
+ R_rand = quat_to_rotmat(q_rand)
439
+ R = R @ R_rand
440
+ # 90-degree rotations around z
441
+ if spec['do_rot90']:
442
+ k = spec['k_rot90']
443
+ angle_90 = k * math.pi / 2.0
444
+ q90 = quat_from_axis_angle(torch.tensor([0.0, 0.0, 1.0]), angle_90, device=None)
445
+ R90 = quat_to_rotmat(q90)
446
+ R = R @ R90
447
+
448
+ # shear
449
+ shx, shy = spec['shear_xy']
450
+ shz = spec['shear_z']
451
+ SH = torch.tensor([
452
+ [1.0, shx, 0.0],
453
+ [shy, 1.0, 0.0],
454
+ [0.0, shz, 1.0]
455
+ ], dtype=torch.float32)
456
+ # scale
457
+ sx, sy, sz = spec['scale_xyz']
458
+ sx *= spec['zoom_factor']
459
+ sy *= spec['zoom_factor']
460
+ sz *= spec['zoom_factor']
461
+ tx = spec['tx']
462
+ ty = spec['ty']
463
+ tz = spec['tz']
464
+ fx = spec['flip_x']
465
+ fy = spec['flip_y']
466
+ fz = spec['flip_z']
467
+ theta = build_affine_3d_from_components(R, (sx, sy, sz), SH, (tx, ty, tz), fx, fy, fz)
468
+ thetas.append(theta)
469
+ # Stack
470
+ theta_batch = torch.stack(thetas, dim=0) # Nx2x3 or Nx3x4
471
+ return theta_batch
472
+
473
+ def _apply_affine_batch(self, x: torch.Tensor, theta: torch.Tensor) -> torch.Tensor:
474
+ """
475
+ x: (N, C, H, W) or (N, C, D, H, W)
476
+ theta: Nx2x3 or Nx3x4
477
+ returns transformed tensor
478
+ """
479
+ # detect complex dtype and split if necessary
480
+ is_complex = torch.is_complex(x)
481
+ if is_complex:
482
+ real = self._apply_affine_batch(x.real, theta)
483
+ imag = self._apply_affine_batch(x.imag, theta)
484
+ return torch.complex(real, imag)
485
+
486
+ # real path
487
+ # Ensure theta and x on same device
488
+ theta = theta.to(x.device)
489
+ out = F.grid_sample(x, F.affine_grid(theta, x.size(), align_corners=False),
490
+ mode='bilinear', padding_mode='border', align_corners=False)
491
+ return out
492
+
493
+ # ----------------------
494
+ # Coil sampler
495
+ # ----------------------
496
+ def default_coil_sampler(self, x: torch.Tensor, keep: int, coils_axis: int = 1) -> torch.Tensor:
497
+ """
498
+ Subsample coil maps along channel axis. Keep 'keep' coils selected uniformly at random.
499
+ """
500
+ if keep is None:
501
+ return x
502
+ C = x.shape[coils_axis]
503
+ keep = min(max(1, int(keep)), C)
504
+ idx = torch.randperm(C, device=x.device)[:keep]
505
+ return torch.index_select(x, dim=coils_axis, index=idx)
506
+
507
+ # ----------------------
508
+ # Public apply function
509
+ # ----------------------
510
+ def apply(self, x: torch.Tensor,
511
+ pipeline: str = 'data',
512
+ aug_spec_list: Optional[List[Dict[str, Any]]] = None,
513
+ coils_axis: int = 1) -> Tuple[torch.Tensor, List[Dict[str, Any]]]:
514
+ """
515
+ Apply augmentations to input batch x.
516
+
517
+ Args:
518
+ x: Input tensor [bS, channels, X, Y, Z] for 3D or [bS, channels, X, Y] for 2D
519
+ pipeline: 'data' or 'csm' - determines augmentation ranges if sampling new augs
520
+ aug_spec_list: Optional pre-specified augment list (length bS). If None, will sample.
521
+ coils_axis: Axis along which coils are stored (for coil subsampling)
522
+
523
+ Returns:
524
+ (x_aug, aug_info_list): Augmented tensor and list of augmentation specifications
525
+ """
526
+ assert x.dim() in (4, 5), "Input must be 4D (2D images) or 5D (3D images)."
527
+ batch_size = x.shape[0]
528
+ is_csm = (pipeline == 'csm')
529
+
530
+ if aug_spec_list is None:
531
+ aug_spec_list = self.sample_augmentations(batch_size, pipeline=pipeline)
532
+
533
+ # Prepare theta batch
534
+ theta = self._compose_affines_for_batch(aug_spec_list, x.shape)
535
+
536
+ # Apply affine to entire batch at once
537
+ x_aug = self._apply_affine_batch(x, theta)
538
+
539
+ # If coil subsampling requested and is CSM pipeline, do it per-sample
540
+ if is_csm:
541
+ out_list = []
542
+ for i, aug in enumerate(aug_spec_list):
543
+ sample = x_aug[i:i+1] # keep batch dim
544
+ if aug['do_coil_sub'] and aug['coil_keep'] is not None:
545
+ sample = self.default_coil_sampler(sample, keep=aug['coil_keep'], coils_axis=coils_axis)
546
+ out_list.append(sample)
547
+ # pad/stack back into a tensor: if coil counts differ we will return a list instead
548
+ chans = [s.shape[1] for s in out_list]
549
+ if all(c == chans[0] for c in chans):
550
+ x_aug = torch.cat(out_list, dim=0)
551
+ else:
552
+ # Return as list in this unusual case
553
+ x_aug = out_list
554
+
555
+ return x_aug, aug_spec_list
556
+
557
+ # ----------------------
558
+ # NIFTI conversion helpers
559
+ # ----------------------
560
+ def _nifti_to_tensor(self, nifti_list: List[NIfTI_MRS_Plus]) -> torch.Tensor:
561
+ """
562
+ Convert list of NIfTI_MRS_Plus objects to tensor format.
563
+
564
+ Returns:
565
+ torch.Tensor: Shape [batch_size, channels, ...spatial dims]
566
+ """
567
+ tensors = []
568
+ for nifti in nifti_list:
569
+ # Extract data from NIFTI object
570
+ data = nifti[:] # Assuming this returns the data array
571
+
572
+ # Convert to torch tensor
573
+ if isinstance(data, np.ndarray):
574
+ tensor = torch.from_numpy(data)
575
+ else:
576
+ tensor = data
577
+
578
+ # Ensure proper dtype
579
+ if torch.is_complex(tensor):
580
+ tensor = tensor.to(dtype=torch.complex64)
581
+ else:
582
+ tensor = tensor.to(dtype=torch.float32)
583
+
584
+ tensors.append(tensor)
585
+
586
+ # Stack into batch
587
+ batch_tensor = torch.stack(tensors, dim=0)
588
+ return batch_tensor.to(self.device)
589
+
590
+ def _tensor_to_nifti(self, tensor: torch.Tensor,
591
+ original_nifti_list: List[NIfTI_MRS_Plus]) -> List[NIfTI_MRS_Plus]:
592
+ """
593
+ Convert tensor back to list of NIfTI_MRS_Plus objects.
594
+
595
+ Args:
596
+ tensor: Augmented data tensor
597
+ original_nifti_list: Original NIFTI objects to preserve metadata
598
+
599
+ Returns:
600
+ List of new NIfTI_MRS_Plus objects with augmented data
601
+ """
602
+ result_list = []
603
+
604
+ # Handle case where tensor is actually a list (due to varying coil counts)
605
+ if isinstance(tensor, list):
606
+ tensor_list = tensor
607
+ else:
608
+ # Split batch dimension
609
+ tensor_list = [tensor[i] for i in range(tensor.shape[0])]
610
+
611
+ for i, (data_tensor, original_nifti) in enumerate(zip(tensor_list, original_nifti_list)):
612
+ # Remove batch dimension if present
613
+ if data_tensor.dim() > len(original_nifti.shape):
614
+ data_tensor = data_tensor.squeeze(0)
615
+
616
+ # Convert to numpy
617
+ if data_tensor.is_cuda:
618
+ data_tensor = data_tensor.cpu()
619
+ data_array = data_tensor.numpy()
620
+
621
+ # Create new NIFTI object with augmented data
622
+ nifti_new = original_nifti.copy()
623
+ nifti_new[:] = data_array
624
+
625
+ result_list.append(nifti_new)
626
+
627
+ return result_list
628
+
629
+ # ----------------------
630
+ # BaseModule interface
631
+ # ----------------------
632
+ def process_nifti_list(self,
633
+ data_list: List[NIfTI_MRS_Plus],
634
+ water_list: Optional[List[NIfTI_MRS_Plus]] = None,
635
+ **kwargs) -> Tuple[List[NIfTI_MRS_Plus], Optional[List[NIfTI_MRS_Plus]]]:
636
+ """
637
+ Process list of NIFTI_MRS objects with spatial augmentations.
638
+
639
+ Args:
640
+ data_list: List of NIFTI_MRS objects
641
+ water_list: Optional list of water reference NIFTI_MRS objects (passed through unchanged)
642
+ **kwargs: pipeline, aug_spec_list forwarded to apply()
643
+
644
+ Returns:
645
+ Tuple of (augmented_data_list, water_list_unchanged)
646
+ Augmentation specs are stored in self.aug_specs_ after each call.
647
+ """
648
+ pipeline = kwargs.get('pipeline', self.pipeline)
649
+ aug_spec_list = kwargs.get('aug_spec_list', None)
650
+
651
+ # Convert NIFTI to tensor
652
+ data_tensor = self._nifti_to_tensor(data_list)
653
+
654
+ # Apply augmentations
655
+ data_aug_tensor, aug_list = self.apply(
656
+ data_tensor,
657
+ pipeline=pipeline,
658
+ aug_spec_list=aug_spec_list,
659
+ coils_axis=1
660
+ )
661
+
662
+ self.aug_specs_ = aug_list # store for provenance / reproducibility
663
+
664
+ # Convert back to NIFTI
665
+ augmented_data_list = self._tensor_to_nifti(data_aug_tensor, data_list)
666
+
667
+ # Water references unchanged
668
+ return augmented_data_list, water_list
669
+
670
+ def process_tensor(self,
671
+ data_array,
672
+ water_array=None,
673
+ backend: Backend = Backend.NUMPY,
674
+ **kwargs) -> Tuple:
675
+ """
676
+ Process tensor data with spatial augmentations.
677
+
678
+ Args:
679
+ data_array: Input tensor or array (batch, channels, ...spatial dims)
680
+ water_array: Optional water reference (passed through unchanged)
681
+ backend: Backend enum (unused, kept for BaseModule signature compatibility)
682
+ **kwargs: pipeline, aug_spec_list forwarded to apply()
683
+
684
+ Returns:
685
+ Tuple of (augmented_data, water_unchanged)
686
+ Augmentation specs are stored in self.aug_specs_ after each call.
687
+ """
688
+ pipeline = kwargs.get('pipeline', self.pipeline)
689
+ aug_spec_list = kwargs.get('aug_spec_list', None)
690
+
691
+ # Convert to torch tensor if needed
692
+ return_numpy = False
693
+ if isinstance(data_array, np.ndarray):
694
+ return_numpy = True
695
+ data_array = torch.from_numpy(data_array)
696
+
697
+ # Ensure proper dtype
698
+ if torch.is_complex(data_array):
699
+ data_array = data_array.to(dtype=torch.complex64)
700
+ else:
701
+ data_array = data_array.to(dtype=torch.float32)
702
+
703
+ # Move to device
704
+ data_array = data_array.to(self.device)
705
+
706
+ # Apply augmentations
707
+ data_aug, aug_list = self.apply(
708
+ data_array,
709
+ pipeline=pipeline,
710
+ aug_spec_list=aug_spec_list,
711
+ coils_axis=1
712
+ )
713
+
714
+ self.aug_specs_ = aug_list # store for provenance / reproducibility
715
+
716
+ # Convert back to numpy if needed
717
+ if return_numpy:
718
+ if isinstance(data_aug, list):
719
+ # Handle list of tensors (variable coil counts)
720
+ data_aug = [d.cpu().numpy() for d in data_aug]
721
+ else:
722
+ data_aug = data_aug.cpu().numpy()
723
+
724
+ return data_aug, water_array
725
+
726
+ # ----------------------
727
+ # Pipeline routing
728
+ # ----------------------
729
+ def route_pipeline(self,
730
+ data_list: Optional[Union[List[NIfTI_MRS_Plus], torch.Tensor, np.ndarray]] = None,
731
+ water_list: Optional[Union[List[NIfTI_MRS_Plus], torch.Tensor, np.ndarray]] = None,
732
+ csm_list: Optional[Union[List[NIfTI_MRS_Plus], torch.Tensor, np.ndarray]] = None,
733
+ data_aug_list: Optional[List[Dict[str, Any]]] = None,
734
+ csm_aug_list: Optional[List[Dict[str, Any]]] = None,
735
+ **kwargs) -> Dict[str, Any]:
736
+ """
737
+ Route data through appropriate pipelines based on data type.
738
+ Automatically detects whether data is NIFTI_MRS or tensor/array format.
739
+
740
+ Args:
741
+ data_list: Spectroscopy data (NIFTI list or tensor/array)
742
+ water_list: Optional water reference (NIFTI list or tensor/array)
743
+ csm_list: Coil sensitivity maps (NIFTI list or tensor/array)
744
+ data_aug_list: Optional pre-computed augmentations for data
745
+ csm_aug_list: Optional pre-computed augmentations for CSMs
746
+ **kwargs: Additional arguments passed to processing methods
747
+
748
+ Returns:
749
+ Dictionary containing processed data and augmentation lists:
750
+ {
751
+ 'data': processed data,
752
+ 'water': processed water,
753
+ 'csm': processed CSM,
754
+ 'data_augmentations': augmentation specs used for data,
755
+ 'csm_augmentations': augmentation specs used for CSMs
756
+ }
757
+ """
758
+ results = {}
759
+
760
+ def _is_nifti_list(x):
761
+ return isinstance(x, list) and len(x) > 0 and isinstance(x[0], NIfTI_MRS_Plus)
762
+
763
+ # Process data pipeline
764
+ if data_list is not None:
765
+ if _is_nifti_list(data_list):
766
+ data_proc, water_proc = self.process_nifti_list(
767
+ data_list, water_list, pipeline='data', aug_spec_list=data_aug_list, **kwargs)
768
+ else:
769
+ data_proc, water_proc = self.process_tensor(
770
+ data_list, water_list, pipeline='data', aug_spec_list=data_aug_list, **kwargs)
771
+ results['data'] = data_proc
772
+ results['water'] = water_proc
773
+ results['data_augmentations'] = self.aug_specs_
774
+
775
+ # Process CSM pipeline
776
+ if csm_list is not None:
777
+ if _is_nifti_list(csm_list):
778
+ csm_proc, _ = self.process_nifti_list(
779
+ csm_list, None, pipeline='csm', aug_spec_list=csm_aug_list, **kwargs)
780
+ else:
781
+ csm_proc, _ = self.process_tensor(
782
+ csm_list, None, pipeline='csm', aug_spec_list=csm_aug_list, **kwargs)
783
+ results['csm'] = csm_proc
784
+ results['csm_augmentations'] = self.aug_specs_
785
+
786
+ return results
787
+
788
+
789
+ # ----------------------
790
+ # Usage / testing
791
+ # ----------------------
792
+ if __name__ == "__main__":
793
+ # Quick test -- 2D imaging (complex) and 3D csm example
794
+ device = torch.device('cpu')
795
+
796
+ print("=" * 80)
797
+ print("Testing SpatialAugmentations with dual pipelines")
798
+ print("=" * 80)
799
+
800
+ # Test 1: 2D data with data pipeline (restricted rotation)
801
+ print("\n1. Testing 2D data pipeline (restricted rotation):")
802
+ bS = 2
803
+ img2d = torch.randn(bS, 1, 64, 64, dtype=torch.float32, device=device) \
804
+ + 1j * torch.randn(bS, 1, 64, 64, dtype=torch.float32, device=device)
805
+
806
+ aug = SpatialAugmentations(dim=2, prob=0.6, device=device, min_coils=6, max_coils=12)
807
+ x2d_aug, info2d = aug.apply(img2d, pipeline='data')
808
+ print(f" Shape: {x2d_aug.shape}")
809
+ print(f" Z-rotation (restricted): {info2d[0]['z_angle_deg']:.2f}°")
810
+ print(f" Pipeline: {info2d[0]['pipeline']}")
811
+
812
+ # Test 2: 3D CSM with CSM pipeline (unrestricted rotation)
813
+ print("\n2. Testing 3D CSM pipeline (unrestricted rotation):")
814
+ bS = 1
815
+ csm3d = torch.randn(bS, 8, 16, 64, 64, dtype=torch.float32, device=device) \
816
+ + 1j * torch.randn(bS, 8, 16, 64, 64, dtype=torch.float32, device=device)
817
+
818
+ aug3d = SpatialAugmentations(dim=3, prob=0.7, device=device, min_coils=6, max_coils=8)
819
+ csm3d_aug, info3d = aug3d.apply(csm3d, pipeline='csm')
820
+ print(f" Result type: {type(csm3d_aug)}")
821
+ print(f" Z-rotation (unrestricted): {info3d[0]['z_angle_deg']:.2f}°")
822
+ print(f" Coil subsampling: {info3d[0]['do_coil_sub']}, keep={info3d[0]['coil_keep']}")
823
+ print(f" Pipeline: {info3d[0]['pipeline']}")
824
+
825
+ # Test 3: Pre-specified augmentations
826
+ print("\n3. Testing with pre-specified augmentations:")
827
+ aug_specs = aug.sample_augmentations(batch_size=2, pipeline='data')
828
+ print(f" Sampled {len(aug_specs)} augmentation specs")
829
+ img2d_2 = torch.randn(2, 1, 64, 64, dtype=torch.complex64, device=device)
830
+ x2d_aug_2, info2d_2 = aug.apply(img2d_2, pipeline='data', aug_spec_list=aug_specs)
831
+ print(f" Applied pre-specified augmentations")
832
+ print(f" Augmentations match: {aug_specs[0] == info2d_2[0]}")
833
+
834
+ # Test 4: process_tensor
835
+ print("\n4. Testing process_tensor:")
836
+ data_np = np.random.randn(2, 1, 32, 32, 32).astype(np.float32)
837
+ data_proc, _ = aug3d.process_tensor(data_np, pipeline='data')
838
+ print(f" Input type: {type(data_np)}, Output type: {type(data_proc)}")
839
+ print(f" Augmentation specs stored: {len(aug3d.aug_specs_)}")
840
+
841
+ # Test 5: route_pipeline
842
+ print("\n5. Testing route_pipeline with automatic type detection:")
843
+ data_tensor = torch.randn(2, 1, 32, 32, 32, dtype=torch.float32)
844
+ csm_tensor = torch.randn(2, 8, 32, 32, 32, dtype=torch.complex64)
845
+
846
+ results = aug3d.route_pipeline(
847
+ data_list=data_tensor,
848
+ csm_list=csm_tensor
849
+ )
850
+ print(f" Processed data shape: {results['data'].shape}")
851
+ print(f" Processed CSM type: {type(results['csm'])}")
852
+ print(f" Data augmentations: {len(results['data_augmentations'])} specs")
853
+ print(f" CSM augmentations: {len(results['csm_augmentations'])} specs")
854
+ print(f" Data pipeline: {results['data_augmentations'][0]['pipeline']}")
855
+ print(f" CSM pipeline: {results['csm_augmentations'][0]['pipeline']}")
856
+
857
+ print("\n" + "=" * 80)
858
+ print("All tests completed successfully!")
859
+ print("=" * 80)