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.
- augmentrum/__init__.py +23 -0
- augmentrum/augmentation/__init__.py +32 -0
- augmentrum/augmentation/amplitude_scaling.py +170 -0
- augmentrum/augmentation/apodization.py +275 -0
- augmentrum/augmentation/artificial_peaks.py +334 -0
- augmentrum/augmentation/baseline_augmentation.py +544 -0
- augmentrum/augmentation/eddy_current.py +278 -0
- augmentrum/augmentation/gaussian_noise.py +211 -0
- augmentrum/augmentation/line_broadening.py +179 -0
- augmentrum/augmentation/phase_frequency.py +259 -0
- augmentrum/augmentation/residual_water.py +264 -0
- augmentrum/augmentation/spatial_augmentations.py +859 -0
- augmentrum/augmentation/spurious_echoes.py +378 -0
- augmentrum/augmentation/zero_fill.py +163 -0
- augmentrum/core/__init__.py +10 -0
- augmentrum/core/augmentrum.py +1172 -0
- augmentrum/core/base_module.py +423 -0
- augmentrum/core/dataset_utils.py +417 -0
- augmentrum/core/nifti_mrs_plus.py +1079 -0
- augmentrum/core/pipeline.py +358 -0
- augmentrum/dataset/brainbeats.py +537 -0
- augmentrum/dataset/cows.py +302 -0
- augmentrum/dataset/fmrsinpain.py +208 -0
- augmentrum/processing/bicubic_interpolator.py +296 -0
- augmentrum/processing/nifti_raw_processor.py +389 -0
- augmentrum/processing/tricubic_interpolator.py +293 -0
- augmentrum/processing/utils.py +414 -0
- augmentrum/resources/__init__.py +14 -0
- augmentrum/resources/scans/__init__.py +0 -0
- augmentrum/sampling/coil_average_sampler.py +274 -0
- augmentrum/sampling/kspace_reconstructor.py +275 -0
- augmentrum/sampling/kspace_sampling.py +2562 -0
- augmentrum/sampling/subject_splitter.py +131 -0
- augmentrum/utils/__init__.py +10 -0
- augmentrum/utils/philips.py +90 -0
- augmentrum/utils/plotting.py +651 -0
- augmentrum/utils/tensor_ops.py +203 -0
- augmentrum-0.0.1.dist-info/METADATA +189 -0
- augmentrum-0.0.1.dist-info/RECORD +42 -0
- augmentrum-0.0.1.dist-info/WHEEL +5 -0
- augmentrum-0.0.1.dist-info/licenses/LICENSE +202 -0
- 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)
|