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,423 @@
1
+ ####################################################################################################
2
+ # base_module.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
6
+ # #
7
+ # Created: 2026-02-06 #
8
+ # #
9
+ # Purpose: Unified base class for all pipeline modules - handles backends, logging, everything #
10
+ # #
11
+ ####################################################################################################
12
+
13
+ from abc import ABC, abstractmethod
14
+ from typing import List, Optional, Tuple, Union
15
+ from augmentrum.core import NIfTI_MRS_Plus, Backend
16
+
17
+
18
+ class BaseModule(ABC):
19
+ """
20
+ Unified base class for ALL augmentation/processing pipeline modules.
21
+
22
+ Handles:
23
+ - Multiple backend support (NIfTI list, NumPy, PyTorch, TensorFlow, JAX, Keras)
24
+ - Automatic backend detection and conversion
25
+ - Provenance logging (only if not volatile)
26
+ - Smart dispatching to appropriate processing method
27
+
28
+ Subclasses just implement the methods they support:
29
+ - process_nifti_list() for FSL-MRS style processing
30
+ - process_tensor() for array/tensor operations
31
+ - forward() as alias (for compatibility)
32
+ """
33
+
34
+ # Subclasses can override - if empty, module supports all backends
35
+ SUPPORTED_BACKENDS: List[Backend] = []
36
+
37
+ def __init__(self, **kwargs):
38
+ """
39
+ Initialize module with optional parameters.
40
+
41
+ Args:
42
+ **kwargs: Module-specific parameters, stored in self.params
43
+ """
44
+ self.params = kwargs
45
+
46
+ def __call__(self, data: NIfTI_MRS_Plus, water: Optional[NIfTI_MRS_Plus] = None,
47
+ **kwargs) -> Tuple[NIfTI_MRS_Plus, Optional[NIfTI_MRS_Plus]]:
48
+ """
49
+ Main entry point - handles everything automatically.
50
+
51
+ - Checks backend compatibility
52
+ - Routes to appropriate processing method
53
+ - Handles logging/provenance (if not volatile)
54
+ - Returns NIfTI_MRS_Plus objects
55
+ """
56
+ # Determine which method to use based on backend
57
+ backend = data.backend
58
+
59
+ # Check if we support this backend (if SUPPORTED_BACKENDS specified)
60
+ if self.SUPPORTED_BACKENDS and backend not in self.SUPPORTED_BACKENDS:
61
+ # Use first supported backend
62
+ backend = self.SUPPORTED_BACKENDS[0] if self.SUPPORTED_BACKENDS else Backend.NIFTI_LIST
63
+
64
+ # Check which methods are actually implemented (overridden from BaseModule)
65
+ has_process_nifti_list = (
66
+ self.__class__.process_nifti_list is not BaseModule.process_nifti_list
67
+ )
68
+ has_process_tensor = (
69
+ self.__class__.process_tensor is not BaseModule.process_tensor
70
+ )
71
+ has_forward = (
72
+ self.__class__.forward is not BaseModule.forward
73
+ )
74
+
75
+ # Try methods in order of preference
76
+ if backend == Backend.NIFTI_LIST:
77
+ # Use NIfTI list processing
78
+ if has_process_nifti_list:
79
+ return self._process_via_nifti_list(data, water, **kwargs)
80
+ elif has_forward:
81
+ return self._process_via_forward(data, water, **kwargs)
82
+ else:
83
+ # Use tensor processing
84
+ if has_process_tensor:
85
+ return self._process_via_tensor(data, water, backend, **kwargs)
86
+ elif has_forward:
87
+ return self._process_via_forward(data, water, **kwargs)
88
+ elif has_process_nifti_list:
89
+ # Fallback: use NIfTI list processing even for tensor backends
90
+ return self._process_via_nifti_list(data, water, **kwargs)
91
+
92
+ # Fallback to forward if nothing else works
93
+ if has_forward:
94
+ return self._process_via_forward(data, water, **kwargs)
95
+
96
+ # Last resort: try nifti list
97
+ if has_process_nifti_list:
98
+ return self._process_via_nifti_list(data, water, **kwargs)
99
+
100
+ raise NotImplementedError(
101
+ f"{self.__class__.__name__} must implement at least one of: "
102
+ f"process_nifti_list(), process_tensor(), or forward()"
103
+ )
104
+
105
+ def _process_via_nifti_list(self, data: NIfTI_MRS_Plus, water: Optional[NIfTI_MRS_Plus],
106
+ **kwargs) -> Tuple[NIfTI_MRS_Plus, Optional[NIfTI_MRS_Plus]]:
107
+ """Process using NIfTI list method."""
108
+ # Get lists
109
+ data_list = data.list()
110
+ water_list = water.list() if water is not None else None
111
+
112
+ # Process
113
+ processed_data, processed_water = self.process_nifti_list(
114
+ data_list, water_list, **kwargs
115
+ )
116
+
117
+ # Log provenance (only if not volatile)
118
+ operation_name = self.__class__.__name__
119
+ operation_details = {'method': 'process_nifti_list', 'params': self.params, **kwargs}
120
+
121
+ # Wrap back into NIfTI_MRS_Plus
122
+ data_out = NIfTI_MRS_Plus(
123
+ nifti_list=processed_data,
124
+ backend=data.backend,
125
+ volatile=data.volatile
126
+ )
127
+
128
+ # Update metadata if not volatile
129
+ if not data.volatile:
130
+ data_out.update_metadata(operation_name, operation_details)
131
+
132
+ water_out = None
133
+ if processed_water is not None:
134
+ water_out = NIfTI_MRS_Plus(
135
+ nifti_list=processed_water,
136
+ backend=water.backend if water else Backend.NIFTI_LIST,
137
+ volatile=water.volatile if water else data.volatile
138
+ )
139
+ if water and not water.volatile:
140
+ water_out.update_metadata(operation_name, operation_details)
141
+
142
+ return data_out, water_out
143
+
144
+ def _process_via_tensor(self, data: NIfTI_MRS_Plus, water: Optional[NIfTI_MRS_Plus],
145
+ backend: Backend, **kwargs) -> Tuple[NIfTI_MRS_Plus, Optional[NIfTI_MRS_Plus]]:
146
+ """Process using tensor method.
147
+
148
+ Following the zea pattern, data is passed to ``process_tensor()`` in its
149
+ **native backend format** (NumPy / PyTorch / JAX / TensorFlow) so that
150
+ gradients and device placement are preserved. The write-back into
151
+ NIFTI_MRS storage always goes through NumPy (unavoidable since NIfTI is
152
+ a NumPy-backed format).
153
+
154
+ Performance tiers
155
+ -----------------
156
+ 1. **Fast** — same N_PTS in and out (all augmentations except truncate/zerofill):
157
+ one vectorised tensor op, then ``nifti[:] = sample`` in-place. Zero extra
158
+ allocations.
159
+
160
+ 2. **Medium** — N_PTS changes (e.g. truncation, zero-fill): tensor slice/pad is
161
+ still vectorised, but write-back must rebuild each NIfTI_MRS object
162
+ (O(B) constructor calls). A ``RuntimeWarning`` is emitted.
163
+
164
+ 3. **Slow (~ routing)** — module only implements ``process_nifti_list``:
165
+ falls back to the NIfTI-list path, processes each subject individually.
166
+ This is what sampling modules (CoilAverageSampler, NIfTI_RawProcessor)
167
+ always use because they change DIM_COIL / DIM_DYN, not just values.
168
+
169
+ Rule of thumb: keep **N_PTS and all extra dimensions uniform** across the
170
+ whole batch for tier-1 speed everywhere.
171
+ """
172
+ from augmentrum.utils.tensor_ops import to_numpy
173
+
174
+ # Inject spectral metadata so modules can use sw_hz / sf_mhz without
175
+ # requiring FSL-MRS objects.
176
+ # Primary source: metadata_common (populated when volatile=False).
177
+ # Fallback: read directly from nifti_list[0] (works even in volatile mode).
178
+ if 'sw_hz' not in kwargs:
179
+ dwell = data.metadata_common.get('dwelltime')
180
+ if dwell is None and data.n_subjects > 0:
181
+ try:
182
+ dwell = data.nifti_list[0].dwelltime
183
+ except Exception:
184
+ pass
185
+ if dwell is not None:
186
+ kwargs['sw_hz'] = 1.0 / dwell
187
+
188
+ if 'sf_mhz' not in kwargs:
189
+ sf = data.metadata_common.get('spectrometer_frequency')
190
+ if sf is None and data.n_subjects > 0:
191
+ try:
192
+ sf = data.nifti_list[0].spectrometer_frequency
193
+ except Exception:
194
+ pass
195
+ if sf is not None:
196
+ kwargs['sf_mhz'] = sf[0] if hasattr(sf, '__getitem__') else sf
197
+
198
+ # ── Get data in native backend format (not forced to numpy!) ──
199
+ data_array = data.get_data(backend)
200
+ water_array = water.get_data(backend) if water is not None else None
201
+
202
+ # ── Process (receives native tensors — preserves gradients) ──
203
+ processed_data, processed_water = self.process_tensor(
204
+ data_array, water_array, backend=backend, **kwargs
205
+ )
206
+
207
+ # ── Write back into NIfTI_MRS list (must go through numpy) ──
208
+ nifti_list_out = data.list()
209
+ if processed_data is not None:
210
+ processed_np = to_numpy(processed_data)
211
+ # Detect N_PTS change (e.g. truncation, zero-fill)
212
+ in_npts = data_array.shape[-1] if hasattr(data_array, 'shape') else None
213
+ out_npts = processed_np.shape[-1] if processed_np is not None else None
214
+ if in_npts is not None and out_npts is not None and out_npts != in_npts:
215
+ import warnings
216
+ warnings.warn(
217
+ f"{self.__class__.__name__}: N_PTS changed {in_npts} → {out_npts} "
218
+ f"(same for all {processed_np.shape[0]} batch members — module params "
219
+ f"are uniform by design). "
220
+ f"NIfTI objects will be rebuilt with the new length. "
221
+ f"This is correct but ~slower than the normal in-place write-back path. "
222
+ f"For maximum throughput, keep N_PTS the same throughout the pipeline "
223
+ f"(e.g. prefer Apodization[exponential] over Apodization[truncate] "
224
+ f"when batching on a tensor backend).",
225
+ stacklevel=3,
226
+ )
227
+ for i, nifti in enumerate(nifti_list_out):
228
+ if i < processed_np.shape[0]:
229
+ sample_np = processed_np[i]
230
+ if sample_np.shape == nifti[:].shape:
231
+ nifti[:] = sample_np
232
+ else:
233
+ # N_PTS changed — rebuild this NIfTI_MRS object from scratch
234
+ from fsl_mrs.core.nifti_mrs import gen_nifti_mrs
235
+ try:
236
+ affine = nifti.getAffine('voxel', 'world')
237
+ except Exception:
238
+ affine = None
239
+ nucleus = (nifti.nucleus[0]
240
+ if hasattr(nifti, 'nucleus') and nifti.nucleus else '1H')
241
+ dim_tags = (list(nifti.dim_tags)
242
+ if hasattr(nifti, 'dim_tags') else [None, None, None])
243
+ new_nifti = gen_nifti_mrs(
244
+ data=sample_np,
245
+ dwelltime=nifti.dwelltime,
246
+ spec_freq=nifti.spectrometer_frequency[0],
247
+ nucleus=nucleus,
248
+ dim_tags=dim_tags,
249
+ affine=affine,
250
+ )
251
+ # Copy extra header fields
252
+ if hasattr(nifti, 'hdr_ext'):
253
+ for key in nifti.hdr_ext:
254
+ if key not in ('SpectrometerFrequency', 'ResonantNucleus',
255
+ 'dim_5', 'dim_6', 'dim_7'):
256
+ try:
257
+ new_nifti.add_hdr_field(key, nifti.hdr_ext[key])
258
+ except Exception:
259
+ pass
260
+ nifti_list_out[i] = new_nifti
261
+
262
+ # Log provenance
263
+ operation_name = self.__class__.__name__
264
+ operation_details = {'method': 'process_tensor', 'backend': backend.value,
265
+ 'params': self.params}
266
+
267
+ data_out = NIfTI_MRS_Plus(
268
+ nifti_list=nifti_list_out,
269
+ backend=backend,
270
+ volatile=data.volatile
271
+ )
272
+
273
+ if not data.volatile:
274
+ data_out.update_metadata(operation_name, operation_details)
275
+
276
+ water_out = None
277
+ if water is not None:
278
+ water_list_out = water.list()
279
+ if processed_water is not None:
280
+ processed_water_np = to_numpy(processed_water)
281
+ for i, nifti in enumerate(water_list_out):
282
+ if i < processed_water_np.shape[0]:
283
+ sample_np = processed_water_np[i]
284
+ if sample_np.shape == nifti[:].shape:
285
+ nifti[:] = sample_np
286
+ else:
287
+ from fsl_mrs.core.nifti_mrs import gen_nifti_mrs
288
+ try:
289
+ affine = nifti.getAffine('voxel', 'world')
290
+ except Exception:
291
+ affine = None
292
+ nucleus = (nifti.nucleus[0]
293
+ if hasattr(nifti, 'nucleus') and nifti.nucleus else '1H')
294
+ dim_tags = (list(nifti.dim_tags)
295
+ if hasattr(nifti, 'dim_tags') else [None, None, None])
296
+ new_nifti = gen_nifti_mrs(
297
+ data=sample_np,
298
+ dwelltime=nifti.dwelltime,
299
+ spec_freq=nifti.spectrometer_frequency[0],
300
+ nucleus=nucleus,
301
+ dim_tags=dim_tags,
302
+ affine=affine,
303
+ )
304
+ if hasattr(nifti, 'hdr_ext'):
305
+ for key in nifti.hdr_ext:
306
+ if key not in ('SpectrometerFrequency', 'ResonantNucleus',
307
+ 'dim_5', 'dim_6', 'dim_7'):
308
+ try:
309
+ new_nifti.add_hdr_field(key, nifti.hdr_ext[key])
310
+ except Exception:
311
+ pass
312
+ water_list_out[i] = new_nifti
313
+
314
+ water_out = NIfTI_MRS_Plus(
315
+ nifti_list=water_list_out,
316
+ backend=backend,
317
+ volatile=water.volatile
318
+ )
319
+ if not water.volatile:
320
+ water_out.update_metadata(operation_name, operation_details)
321
+
322
+ return data_out, water_out
323
+
324
+ def _process_via_forward(self, data: NIfTI_MRS_Plus, water: Optional[NIfTI_MRS_Plus],
325
+ **kwargs) -> Tuple[NIfTI_MRS_Plus, Optional[NIfTI_MRS_Plus]]:
326
+ """Process using legacy forward method (for compatibility)."""
327
+ # Get appropriate data format based on backend
328
+ if data.backend == Backend.NIFTI_LIST:
329
+ data_in = data.list()
330
+ water_in = water.list() if water is not None else None
331
+ else:
332
+ data_in = data.get_data()
333
+ water_in = water.get_data() if water is not None else None
334
+
335
+ # Process using forward
336
+ processed_data, processed_water = self.forward(data_in, water_in, **kwargs)
337
+
338
+ # Log provenance
339
+ operation_name = self.__class__.__name__
340
+ operation_details = {'method': 'forward', 'params': self.params, **kwargs}
341
+
342
+ # Wrap results
343
+ if isinstance(processed_data, list):
344
+ data_out = NIfTI_MRS_Plus(
345
+ nifti_list=processed_data,
346
+ backend=data.backend,
347
+ volatile=data.volatile
348
+ )
349
+ else:
350
+ # If forward returns something else, keep original structure
351
+ data_out = data.copy()
352
+
353
+ if not data.volatile:
354
+ data_out.update_metadata(operation_name, operation_details)
355
+
356
+ water_out = None
357
+ if processed_water is not None:
358
+ if isinstance(processed_water, list):
359
+ water_out = NIfTI_MRS_Plus(
360
+ nifti_list=processed_water,
361
+ backend=water.backend if water else Backend.NIFTI_LIST,
362
+ volatile=water.volatile if water else data.volatile
363
+ )
364
+ else:
365
+ water_out = water.copy() if water else None
366
+
367
+ if water_out and not water_out.volatile:
368
+ water_out.update_metadata(operation_name, operation_details)
369
+
370
+ return data_out, water_out
371
+
372
+ def supports_backend(self, backend: Backend) -> bool:
373
+ """Check if this module supports the given backend."""
374
+ if not self.SUPPORTED_BACKENDS:
375
+ return True # Supports all if not specified
376
+ return backend in self.SUPPORTED_BACKENDS
377
+
378
+ def get_preferred_backend(self) -> Backend:
379
+ """Get the preferred backend for this module."""
380
+ if self.SUPPORTED_BACKENDS:
381
+ return self.SUPPORTED_BACKENDS[0]
382
+ return Backend.NIFTI_LIST
383
+
384
+ # Subclasses can implement these methods as needed
385
+ def process_nifti_list(self, data_list: List, water_list: Optional[List] = None,
386
+ **kwargs) -> Tuple[List, Optional[List]]:
387
+ """
388
+ Process lists of NIfTI-MRS objects (for FSL-MRS style processing).
389
+
390
+ Override this if your module uses FSL-MRS functions.
391
+ """
392
+ raise NotImplementedError(
393
+ f"{self.__class__.__name__} does not implement process_nifti_list()"
394
+ )
395
+
396
+ def process_tensor(self, data_array, water_array=None, backend: Backend = Backend.NUMPY,
397
+ **kwargs) -> Tuple:
398
+ """
399
+ Process tensor/array data (for vectorized operations).
400
+
401
+ Override this if your module uses NumPy/PyTorch/etc. operations.
402
+ """
403
+ raise NotImplementedError(
404
+ f"{self.__class__.__name__} does not implement process_tensor()"
405
+ )
406
+
407
+ def forward(self, data, water=None, **kwargs):
408
+ """
409
+ Legacy processing method (for compatibility with old modules).
410
+
411
+ Override this for simple modules that don't need backend-specific logic.
412
+ """
413
+ raise NotImplementedError(
414
+ f"{self.__class__.__name__} does not implement forward()"
415
+ )
416
+
417
+ def __repr__(self) -> str:
418
+ backends = [b.value for b in self.SUPPORTED_BACKENDS] if self.SUPPORTED_BACKENDS else ['all']
419
+ return f"{self.__class__.__name__}(backends={backends})"
420
+
421
+
422
+
423
+