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,1172 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# augmentrum.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# J. T. LaMaster (jlamaste@gmail.com) #
|
|
7
|
+
# K. C. Igwe (kci2104@columbia.edu) #
|
|
8
|
+
# #
|
|
9
|
+
# Created: 2026-02-07 #
|
|
10
|
+
# #
|
|
11
|
+
# Purpose: Main Augmentrum class - backend-agnostic MRS data augmentation #
|
|
12
|
+
# #
|
|
13
|
+
####################################################################################################
|
|
14
|
+
|
|
15
|
+
from typing import List, Optional, Union, Dict, Any
|
|
16
|
+
from augmentrum import __version__
|
|
17
|
+
from augmentrum.core import NIfTI_MRS_Plus, Backend
|
|
18
|
+
from augmentrum.core.pipeline import AugmentationPipeline
|
|
19
|
+
|
|
20
|
+
# Import helper functions from dataset_utils
|
|
21
|
+
from augmentrum.core.dataset_utils import (
|
|
22
|
+
create_random_generator,
|
|
23
|
+
create_fixed_generator,
|
|
24
|
+
create_deterministic_generator, # Legacy support
|
|
25
|
+
wrap_generator_for_framework,
|
|
26
|
+
)
|
|
27
|
+
from augmentrum.sampling.subject_splitter import SubjectSplitter
|
|
28
|
+
|
|
29
|
+
# Processing modules
|
|
30
|
+
from augmentrum.processing.nifti_raw_processor import NIfTI_RawProcessor
|
|
31
|
+
from augmentrum.sampling.coil_average_sampler import CoilAverageSampler
|
|
32
|
+
|
|
33
|
+
# Augmentation modules
|
|
34
|
+
from augmentrum.augmentation.gaussian_noise import GaussianNoise
|
|
35
|
+
from augmentrum.augmentation.line_broadening import LineBroadening
|
|
36
|
+
from augmentrum.augmentation.baseline_augmentation import BaselineAugmentation
|
|
37
|
+
from augmentrum.augmentation.residual_water import ResidualWater
|
|
38
|
+
from augmentrum.augmentation.spurious_echoes import SpuriousEchoes
|
|
39
|
+
from augmentrum.augmentation.artificial_peaks import ArtificialPeaks
|
|
40
|
+
from augmentrum.augmentation.eddy_current import EddyCurrent
|
|
41
|
+
from augmentrum.augmentation.apodization import Apodization
|
|
42
|
+
from augmentrum.augmentation.phase_frequency import PhaseShift, FrequencyShift
|
|
43
|
+
from augmentrum.augmentation.spatial_augmentations import SpatialAugmentations
|
|
44
|
+
from augmentrum.augmentation.zero_fill import ZeroFill
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ['Augmentrum']
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Augmentrum:
|
|
51
|
+
"""
|
|
52
|
+
Main Augmentrum class - backend-agnostic MRS data augmentation.
|
|
53
|
+
|
|
54
|
+
Provides easy-to-use interface for:
|
|
55
|
+
- Loading NIFTI_MRS data (metabolite + water)
|
|
56
|
+
- Optional train/val/test splitting
|
|
57
|
+
- Flexible augmentation pipelines (different per split)
|
|
58
|
+
- On-the-fly (random) or fixed (exact) augmentation parameters
|
|
59
|
+
- Multi-backend support (PyTorch, NumPy, TensorFlow, JAX)
|
|
60
|
+
|
|
61
|
+
Example 1: On-the-fly augmentation with RANGE sampling
|
|
62
|
+
```python
|
|
63
|
+
augmenter = Augmentrum(
|
|
64
|
+
data=nifti_list,
|
|
65
|
+
water=water_list,
|
|
66
|
+
pipeline=['coil_sampling', 'processing', 'noise', 'line_broadening'],
|
|
67
|
+
mode='on-the-fly',
|
|
68
|
+
n_coils=(1, 8), # Random 1-8 coils
|
|
69
|
+
n_averages=(4, 16), # Random 4-16 averages
|
|
70
|
+
sigma_frac=(0.01, 0.05), # Random noise 1-5%
|
|
71
|
+
lb_hz=(0, 10), # Random broadening 0-10 Hz
|
|
72
|
+
phase0_deg=(-180, 180), # Random phase -180 to 180°
|
|
73
|
+
param_distribution='uniform', # How to sample from ranges (default)
|
|
74
|
+
batch_size=32,
|
|
75
|
+
backend='numpy'
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Each batch will have DIFFERENT random augmentations!
|
|
79
|
+
for batch_data, batch_water in augmenter.dataloader(framework='numpy'):
|
|
80
|
+
train_model(batch_data)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Example 2: Fixed augmentation (exact values)
|
|
84
|
+
```python
|
|
85
|
+
augmenter = Augmentrum(
|
|
86
|
+
data=nifti_list,
|
|
87
|
+
water=water_list,
|
|
88
|
+
pipeline=['coil_sampling', 'processing', 'noise'],
|
|
89
|
+
mode='fixed',
|
|
90
|
+
n_coils=4, # Always exactly 4 coils
|
|
91
|
+
n_averages=8, # Always exactly 8 averages
|
|
92
|
+
sigma_frac=0.03, # Always 3% noise (scalar = exact value)
|
|
93
|
+
batch_size=32,
|
|
94
|
+
backend='pytorch'
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Each batch will have IDENTICAL augmentations
|
|
98
|
+
for batch_data, batch_water in augmenter.dataloader():
|
|
99
|
+
validate_model(batch_data)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Example 3: Gaussian distribution sampling
|
|
103
|
+
```python
|
|
104
|
+
augmenter = Augmentrum(
|
|
105
|
+
data=nifti_list,
|
|
106
|
+
pipeline=['noise', 'line_broadening'],
|
|
107
|
+
sigma_frac=(0.01, 0.05), # Range
|
|
108
|
+
lb_hz=(0, 10), # Range
|
|
109
|
+
param_distribution='gaussian', # Sample with Gaussian (centered at midpoint)
|
|
110
|
+
backend='pytorch'
|
|
111
|
+
)
|
|
112
|
+
# Samples will be more concentrated around 0.03 (midpoint) for sigma_frac
|
|
113
|
+
# and around 5.0 (midpoint) for lb_hz, with tails at the extremes
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Example 3: With train/val/test splitting
|
|
117
|
+
```python
|
|
118
|
+
augmenter = Augmentrum(
|
|
119
|
+
data=nifti_list,
|
|
120
|
+
water=water_list,
|
|
121
|
+
split_fractions={'val': 0.1, 'test': 0.1},
|
|
122
|
+
pipelines={
|
|
123
|
+
'train': ['processing', 'noise', 'line_broadening', 'baseline'],
|
|
124
|
+
'val': ['processing'],
|
|
125
|
+
'test': None
|
|
126
|
+
},
|
|
127
|
+
modes={
|
|
128
|
+
'train': 'on-the-fly', # Random augmentations for training
|
|
129
|
+
'val': 'fixed', # Fixed params for validation
|
|
130
|
+
'test': 'fixed' # Fixed params for testing
|
|
131
|
+
},
|
|
132
|
+
# Training uses ranges
|
|
133
|
+
n_coils=(1, 8),
|
|
134
|
+
sigma_frac=(0.01, 0.05),
|
|
135
|
+
# But validation/test use fixed values (extracted from ranges)
|
|
136
|
+
batch_size=32,
|
|
137
|
+
backend='pytorch'
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
train_dl = augmenter.train_dataloader()
|
|
141
|
+
val_dl = augmenter.val_dataloader()
|
|
142
|
+
```
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
# Available augmentation modules
|
|
146
|
+
AVAILABLE_MODULES = {
|
|
147
|
+
# Processing
|
|
148
|
+
'coil_sampling': CoilAverageSampler,
|
|
149
|
+
'average_sampling': CoilAverageSampler,
|
|
150
|
+
'processing': NIfTI_RawProcessor,
|
|
151
|
+
|
|
152
|
+
# Noise
|
|
153
|
+
'noise': GaussianNoise,
|
|
154
|
+
'gaussian_noise': GaussianNoise,
|
|
155
|
+
|
|
156
|
+
# Line broadening
|
|
157
|
+
'line_broadening': LineBroadening,
|
|
158
|
+
'broadening': LineBroadening,
|
|
159
|
+
|
|
160
|
+
# Baseline
|
|
161
|
+
'baseline': BaselineAugmentation,
|
|
162
|
+
'baseline_random_walk': BaselineAugmentation,
|
|
163
|
+
'baseline_bspline': BaselineAugmentation,
|
|
164
|
+
'baseline_polynomial': BaselineAugmentation,
|
|
165
|
+
|
|
166
|
+
# Phase & Frequency
|
|
167
|
+
'phase': PhaseShift,
|
|
168
|
+
'phase_shift': PhaseShift,
|
|
169
|
+
'frequency_shift': FrequencyShift,
|
|
170
|
+
|
|
171
|
+
# Artifacts
|
|
172
|
+
'residual_water': ResidualWater,
|
|
173
|
+
'water': ResidualWater,
|
|
174
|
+
'spurious_echoes': SpuriousEchoes,
|
|
175
|
+
'echoes': SpuriousEchoes,
|
|
176
|
+
'artificial_peaks': ArtificialPeaks,
|
|
177
|
+
'peaks': ArtificialPeaks,
|
|
178
|
+
'eddy_current': EddyCurrent,
|
|
179
|
+
'eddy': EddyCurrent,
|
|
180
|
+
|
|
181
|
+
# Modification
|
|
182
|
+
'apodization': Apodization,
|
|
183
|
+
'apod': Apodization,
|
|
184
|
+
'zero_filling': ZeroFill,
|
|
185
|
+
'zero_fill': ZeroFill,
|
|
186
|
+
|
|
187
|
+
# Spatial
|
|
188
|
+
'spatial': SpatialAugmentations,
|
|
189
|
+
'spatial_augmentations': SpatialAugmentations,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
data: Union[List, NIfTI_MRS_Plus],
|
|
195
|
+
water: Optional[Union[List, NIfTI_MRS_Plus]] = None,
|
|
196
|
+
|
|
197
|
+
# Splitting
|
|
198
|
+
split_fractions: Optional[Dict[str, float]] = None, # e.g., {'val': 0.1, 'test': 0.1}
|
|
199
|
+
seed: int = 42,
|
|
200
|
+
|
|
201
|
+
# Pre-processing (applied once and cached)
|
|
202
|
+
pre_pipeline: Optional[Union[List, AugmentationPipeline, Dict[str, Union[List, AugmentationPipeline]]]] = None, # Fixed preprocessing
|
|
203
|
+
|
|
204
|
+
# Augmentation
|
|
205
|
+
pipeline: Optional[Union[List, AugmentationPipeline]] = None, # Single pipeline
|
|
206
|
+
pipelines: Optional[Dict[str, Union[List, AugmentationPipeline]]] = None, # Per-split pipelines
|
|
207
|
+
|
|
208
|
+
# Augmentation mode
|
|
209
|
+
mode: str = 'on-the-fly', # 'on-the-fly' or 'fixed'
|
|
210
|
+
modes: Optional[Dict[str, str]] = None, # Per-split modes
|
|
211
|
+
|
|
212
|
+
# General
|
|
213
|
+
batch_size: int = 16,
|
|
214
|
+
backend: Union[str, Backend] = 'pytorch',
|
|
215
|
+
device: Optional[str] = None, # 'cuda', 'cpu', or None (auto-detect)
|
|
216
|
+
volatile: bool = False,
|
|
217
|
+
|
|
218
|
+
**kwargs # Module-specific parameters
|
|
219
|
+
):
|
|
220
|
+
"""
|
|
221
|
+
Initialize Augmentrum.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
data: List of NIFTI_MRS objects or NIfTI_MRS_Plus
|
|
225
|
+
water: Optional water references
|
|
226
|
+
split_fractions: Dict like {'val': 0.1, 'test': 0.1}, train gets rest
|
|
227
|
+
seed: Random seed for splitting
|
|
228
|
+
pre_pipeline: Fixed preprocessing applied ONCE and cached. Can be:
|
|
229
|
+
- List of module names: ['coil_sampling', 'processing'] (same for all splits)
|
|
230
|
+
- AugmentationPipeline object (same for all splits)
|
|
231
|
+
- Dict mapping split names to pipelines (different per split):
|
|
232
|
+
{'train': ['coil_sampling', 'processing'], 'val': ['processing'], 'test': None}
|
|
233
|
+
These steps run before the main pipeline and results are stored.
|
|
234
|
+
Useful for expensive operations that don't need randomization.
|
|
235
|
+
pipeline: Single pipeline for all splits (list of module names or AugmentationPipeline)
|
|
236
|
+
pipelines: Dict mapping split names to pipelines (overrides 'pipeline')
|
|
237
|
+
mode: Single mode for all splits:
|
|
238
|
+
'on-the-fly' = Random sampling from ranges (e.g., n_coils=(1,8) picks randomly)
|
|
239
|
+
'fixed' = Use exact values provided (e.g., n_coils=4 always uses 4 coils)
|
|
240
|
+
modes: Dict mapping split names to modes (overrides 'mode')
|
|
241
|
+
batch_size: Batch size
|
|
242
|
+
backend: 'pytorch', 'numpy', 'tensorflow', 'keras', 'jax', or Backend enum
|
|
243
|
+
device: Device for PyTorch backend ('cuda', 'cpu', etc.). Note: Use .to() method
|
|
244
|
+
on batches for GPU support instead of this parameter.
|
|
245
|
+
volatile: Skip metadata updates for speed
|
|
246
|
+
**kwargs: Module-specific parameters, including:
|
|
247
|
+
ALL PARAMETERS support both tuple ranges and exact values:
|
|
248
|
+
- Tuple (min, max) = randomly sample from range
|
|
249
|
+
- Scalar (float/int) = use exact value
|
|
250
|
+
|
|
251
|
+
SAMPLING PARAMETERS:
|
|
252
|
+
- n_coils: (1, 8) or 4
|
|
253
|
+
- n_averages: (4, 16) or 8
|
|
254
|
+
|
|
255
|
+
AUGMENTATION PARAMETERS
|
|
256
|
+
- sigma_frac: (0.01, 0.05) or 0.03
|
|
257
|
+
- lb_hz: (0, 10) or 5.0
|
|
258
|
+
- gb_hz: (0, 5) or 2.0
|
|
259
|
+
- phase0_deg: (-180, 180) or 0.0
|
|
260
|
+
- phase1_deg: (-90, 90) or 0.0
|
|
261
|
+
- shift_hz: (-5, 5) or 0.0
|
|
262
|
+
- baseline_frac: (0.01, 0.1) or 0.05
|
|
263
|
+
- water_amp: (0.05, 0.2) or 0.1
|
|
264
|
+
- eddy_std: (0.3, 1.0) or 0.6
|
|
265
|
+
- ... etc.
|
|
266
|
+
|
|
267
|
+
DISTRIBUTION (for sampling from ranges):
|
|
268
|
+
- param_distribution: 'uniform' (default), 'gaussian', 'exponential', 'beta'
|
|
269
|
+
Global default for ALL parameters
|
|
270
|
+
|
|
271
|
+
- param_distributions: Dict mapping parameter names to specific distributions
|
|
272
|
+
Per-parameter control (overrides param_distribution)
|
|
273
|
+
Examples:
|
|
274
|
+
param_distributions={
|
|
275
|
+
'sigma_frac': 'gaussian', # Gaussian for noise
|
|
276
|
+
'lb_hz': 'exponential', # Exponential for broadening
|
|
277
|
+
'phase0_deg': 'uniform' # Uniform for phase
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
Note: If neither specified, default is 'uniform'
|
|
281
|
+
|
|
282
|
+
PROCESSING OPTIONS:
|
|
283
|
+
- coil_method: 'fsl-mrs', 'adaptive'
|
|
284
|
+
- baseline_mode: 'random_walk', 'bspline', 'polynomial'
|
|
285
|
+
- ...
|
|
286
|
+
"""
|
|
287
|
+
# Convert backend
|
|
288
|
+
if isinstance(backend, str):
|
|
289
|
+
self.backend = Backend[backend.upper()]
|
|
290
|
+
else:
|
|
291
|
+
self.backend = backend
|
|
292
|
+
|
|
293
|
+
self.batch_size = batch_size
|
|
294
|
+
self.device = device # Store device ('cuda', 'cpu', or None)
|
|
295
|
+
self.volatile = volatile
|
|
296
|
+
self.kwargs = kwargs
|
|
297
|
+
|
|
298
|
+
# Convert data to NIfTI_MRS_Plus
|
|
299
|
+
if not isinstance(data, NIfTI_MRS_Plus):
|
|
300
|
+
self.data_all = NIfTI_MRS_Plus(nifti_list=data, backend=self.backend, volatile=volatile)
|
|
301
|
+
else:
|
|
302
|
+
self.data_all = data
|
|
303
|
+
|
|
304
|
+
if water is not None and not isinstance(water, NIfTI_MRS_Plus):
|
|
305
|
+
self.water_all = NIfTI_MRS_Plus(nifti_list=water, backend=self.backend, volatile=volatile)
|
|
306
|
+
elif water is not None:
|
|
307
|
+
self.water_all = water
|
|
308
|
+
else:
|
|
309
|
+
self.water_all = None
|
|
310
|
+
|
|
311
|
+
# Handle splitting
|
|
312
|
+
if split_fractions is not None:
|
|
313
|
+
self._create_splits(split_fractions, seed)
|
|
314
|
+
else:
|
|
315
|
+
# No splitting - all data goes to train, but create empty val/test
|
|
316
|
+
empty_data = NIfTI_MRS_Plus(nifti_list=[], backend=self.backend, volatile=self.volatile)
|
|
317
|
+
self.splits = {
|
|
318
|
+
'train': (self.data_all, self.water_all),
|
|
319
|
+
'val': (empty_data, None),
|
|
320
|
+
'test': (empty_data, None)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
# Handle pre-pipeline (applied once and cached)
|
|
324
|
+
self._create_pre_pipeline(pre_pipeline)
|
|
325
|
+
|
|
326
|
+
# Handle pipelines
|
|
327
|
+
self._create_pipelines(pipeline, pipelines)
|
|
328
|
+
|
|
329
|
+
# Handle modes
|
|
330
|
+
self._create_modes(mode, modes)
|
|
331
|
+
|
|
332
|
+
# Additional features for flexibility
|
|
333
|
+
self.callbacks = [] # Custom augmentation callbacks
|
|
334
|
+
self.stats = { # Statistics tracking
|
|
335
|
+
'batches_generated': 0,
|
|
336
|
+
'samples_generated': 0,
|
|
337
|
+
'split_stats': {split: {'batches': 0, 'samples': 0} for split in self.splits.keys()}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
def _create_splits(self, split_fractions: Dict[str, float], seed: int):
|
|
341
|
+
"""Create train/val/test splits."""
|
|
342
|
+
val_frac = split_fractions.get('val', 0.0)
|
|
343
|
+
test_frac = split_fractions.get('test', 0.0)
|
|
344
|
+
|
|
345
|
+
splitter = SubjectSplitter(
|
|
346
|
+
self.data_all.list(),
|
|
347
|
+
self.water_all.list() if self.water_all is not None else None,
|
|
348
|
+
seed=seed,
|
|
349
|
+
val_frac=val_frac,
|
|
350
|
+
test_frac=test_frac
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
splits_raw = splitter.split()
|
|
354
|
+
|
|
355
|
+
# Convert back to NIfTI_MRS_Plus
|
|
356
|
+
self.splits = {}
|
|
357
|
+
for split_name, (data_list, water_list) in splits_raw.items():
|
|
358
|
+
data_plus = NIfTI_MRS_Plus(nifti_list=data_list, backend=self.backend, volatile=self.volatile)
|
|
359
|
+
water_plus = NIfTI_MRS_Plus(nifti_list=water_list, backend=self.backend, volatile=self.volatile) if water_list else None
|
|
360
|
+
self.splits[split_name] = (data_plus, water_plus)
|
|
361
|
+
|
|
362
|
+
def _create_pre_pipeline(self, pre_pipeline):
|
|
363
|
+
"""
|
|
364
|
+
Create and apply pre-pipeline (fixed preprocessing that runs once and is cached).
|
|
365
|
+
|
|
366
|
+
The pre-pipeline is applied to all data ONCE during initialization, and the results
|
|
367
|
+
are cached. This is useful for expensive operations that don't need randomization,
|
|
368
|
+
like coil combination, eddy current correction, etc.
|
|
369
|
+
|
|
370
|
+
Supports per-split pre-pipelines for different preprocessing strategies per split.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
pre_pipeline: Can be:
|
|
374
|
+
- None: No pre-pipeline
|
|
375
|
+
- List of module names: same pre-pipeline for all splits
|
|
376
|
+
- AugmentationPipeline object: same for all splits
|
|
377
|
+
- Dict mapping split names to pipelines: different per split
|
|
378
|
+
"""
|
|
379
|
+
if pre_pipeline is None:
|
|
380
|
+
self.pre_pipeline = None
|
|
381
|
+
self.pre_pipelines = None
|
|
382
|
+
self.preprocessed_splits = None
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
# Check if it's a dict (per-split pre-pipelines)
|
|
386
|
+
if isinstance(pre_pipeline, dict):
|
|
387
|
+
# Per-split pre-pipelines
|
|
388
|
+
self.pre_pipelines = {}
|
|
389
|
+
|
|
390
|
+
for split_name in self.splits.keys():
|
|
391
|
+
split_pre_pipeline = pre_pipeline.get(split_name, None)
|
|
392
|
+
|
|
393
|
+
if split_pre_pipeline is None:
|
|
394
|
+
self.pre_pipelines[split_name] = None
|
|
395
|
+
elif isinstance(split_pre_pipeline, AugmentationPipeline):
|
|
396
|
+
self.pre_pipelines[split_name] = split_pre_pipeline
|
|
397
|
+
else:
|
|
398
|
+
# Build from list of module names
|
|
399
|
+
self.pre_pipelines[split_name] = self._build_pipeline_from_list(split_pre_pipeline)
|
|
400
|
+
|
|
401
|
+
self.pre_pipeline = None # Not used when per-split
|
|
402
|
+
|
|
403
|
+
else:
|
|
404
|
+
# Single pre-pipeline for all splits
|
|
405
|
+
if isinstance(pre_pipeline, AugmentationPipeline):
|
|
406
|
+
self.pre_pipeline = pre_pipeline
|
|
407
|
+
else:
|
|
408
|
+
self.pre_pipeline = self._build_pipeline_from_list(pre_pipeline)
|
|
409
|
+
|
|
410
|
+
# Create dict for all splits
|
|
411
|
+
self.pre_pipelines = {split_name: self.pre_pipeline for split_name in self.splits.keys()}
|
|
412
|
+
|
|
413
|
+
print(f"Applying pre-pipeline to all data (this happens ONCE)...")
|
|
414
|
+
|
|
415
|
+
# Apply pre-pipeline to each split and cache results
|
|
416
|
+
self.preprocessed_splits = {}
|
|
417
|
+
|
|
418
|
+
for split_name, (data, water) in self.splits.items():
|
|
419
|
+
split_pre_pipeline = self.pre_pipelines[split_name]
|
|
420
|
+
|
|
421
|
+
if split_pre_pipeline is None:
|
|
422
|
+
# No pre-pipeline for this split
|
|
423
|
+
print(f" {split_name} split: No pre-pipeline")
|
|
424
|
+
self.preprocessed_splits[split_name] = (data, water)
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
if len(data) == 0: # Empty split
|
|
428
|
+
self.preprocessed_splits[split_name] = (data, water)
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
print(f" Processing {split_name} split ({len(data)} subjects)...")
|
|
432
|
+
print(f" Pre-pipeline: {[m.__class__.__name__ for m in split_pre_pipeline.steps]}")
|
|
433
|
+
|
|
434
|
+
# Process each subject through pre-pipeline
|
|
435
|
+
processed_data_list = []
|
|
436
|
+
processed_water_list = [] if water is not None else None
|
|
437
|
+
|
|
438
|
+
for i in range(len(data)):
|
|
439
|
+
subj_data = data[i]
|
|
440
|
+
subj_water = water[i] if water is not None else None
|
|
441
|
+
|
|
442
|
+
# Apply pre-pipeline
|
|
443
|
+
proc_data, proc_water = split_pre_pipeline(subj_data, subj_water)
|
|
444
|
+
|
|
445
|
+
processed_data_list.append(proc_data.list()[0])
|
|
446
|
+
if processed_water_list is not None and proc_water is not None:
|
|
447
|
+
processed_water_list.append(proc_water.list()[0])
|
|
448
|
+
|
|
449
|
+
# Wrap processed data in target backend
|
|
450
|
+
# NIfTI_MRS_Plus will handle conversion automatically
|
|
451
|
+
processed_data = NIfTI_MRS_Plus(
|
|
452
|
+
nifti_list=processed_data_list,
|
|
453
|
+
backend=self.backend, # Use target backend directly
|
|
454
|
+
volatile=self.volatile
|
|
455
|
+
)
|
|
456
|
+
processed_water = None
|
|
457
|
+
if processed_water_list:
|
|
458
|
+
processed_water = NIfTI_MRS_Plus(
|
|
459
|
+
nifti_list=processed_water_list,
|
|
460
|
+
backend=self.backend,
|
|
461
|
+
volatile=self.volatile
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
# Store preprocessed data
|
|
465
|
+
self.preprocessed_splits[split_name] = (processed_data, processed_water)
|
|
466
|
+
|
|
467
|
+
print(f"✓ Pre-pipeline applied and cached!")
|
|
468
|
+
|
|
469
|
+
# Replace splits with preprocessed versions
|
|
470
|
+
self.splits = self.preprocessed_splits
|
|
471
|
+
|
|
472
|
+
def _create_pipelines(self, pipeline, pipelines):
|
|
473
|
+
"""Create augmentation pipelines for each split."""
|
|
474
|
+
self.pipelines = {}
|
|
475
|
+
|
|
476
|
+
if pipelines is not None:
|
|
477
|
+
# Per-split pipelines provided
|
|
478
|
+
for split_name in self.splits.keys():
|
|
479
|
+
split_pipeline = pipelines.get(split_name, None)
|
|
480
|
+
if split_pipeline is None:
|
|
481
|
+
self.pipelines[split_name] = AugmentationPipeline([])
|
|
482
|
+
elif isinstance(split_pipeline, AugmentationPipeline):
|
|
483
|
+
self.pipelines[split_name] = split_pipeline
|
|
484
|
+
else:
|
|
485
|
+
# Build from list of module names
|
|
486
|
+
self.pipelines[split_name] = self._build_pipeline_from_list(split_pipeline)
|
|
487
|
+
|
|
488
|
+
elif pipeline is not None:
|
|
489
|
+
# Single pipeline for all splits
|
|
490
|
+
if isinstance(pipeline, AugmentationPipeline):
|
|
491
|
+
built_pipeline = pipeline
|
|
492
|
+
else:
|
|
493
|
+
built_pipeline = self._build_pipeline_from_list(pipeline)
|
|
494
|
+
|
|
495
|
+
for split_name in self.splits.keys():
|
|
496
|
+
self.pipelines[split_name] = built_pipeline
|
|
497
|
+
|
|
498
|
+
else:
|
|
499
|
+
# Default: processing for all splits
|
|
500
|
+
default_pipeline = AugmentationPipeline([
|
|
501
|
+
NIfTI_RawProcessor(**self.kwargs)
|
|
502
|
+
])
|
|
503
|
+
for split_name in self.splits.keys():
|
|
504
|
+
self.pipelines[split_name] = default_pipeline
|
|
505
|
+
|
|
506
|
+
def _build_pipeline_from_list(self, module_names: List[str]) -> AugmentationPipeline:
|
|
507
|
+
"""Build pipeline from list of module name strings."""
|
|
508
|
+
import inspect
|
|
509
|
+
|
|
510
|
+
modules = []
|
|
511
|
+
|
|
512
|
+
# Default parameters for modules that require them at init
|
|
513
|
+
# TODO: Maybe solve at module level instead
|
|
514
|
+
DEFAULT_PARAMS = {
|
|
515
|
+
'noise': {'sigma_frac': 0.02},
|
|
516
|
+
'gaussian_noise': {'sigma_frac': 0.02},
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for name in module_names:
|
|
520
|
+
if name not in self.AVAILABLE_MODULES:
|
|
521
|
+
raise ValueError(f"Unknown module '{name}'. Available: {list(self.AVAILABLE_MODULES.keys())}")
|
|
522
|
+
|
|
523
|
+
module_class = self.AVAILABLE_MODULES[name]
|
|
524
|
+
|
|
525
|
+
# --- Introspect constructor to find which user kwargs apply ---
|
|
526
|
+
try:
|
|
527
|
+
sig = inspect.signature(module_class.__init__)
|
|
528
|
+
init_param_names = [
|
|
529
|
+
p.name for p in sig.parameters.values()
|
|
530
|
+
if p.name not in ('self', 'args', 'kwargs')
|
|
531
|
+
]
|
|
532
|
+
except Exception:
|
|
533
|
+
init_param_names = []
|
|
534
|
+
|
|
535
|
+
# Only inject scalar values (bool, int, float, str) from user_kwargs.
|
|
536
|
+
# Range tuples (e.g. lb_hz=(0, 10)) are meant for runtime sampling only.
|
|
537
|
+
scalar_kwargs = {
|
|
538
|
+
k: v for k, v in self.kwargs.items()
|
|
539
|
+
if k in init_param_names and isinstance(v, (bool, int, float, str))
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
construct_params = {**DEFAULT_PARAMS.get(name, {}), **scalar_kwargs}
|
|
543
|
+
modules.append(module_class(**construct_params))
|
|
544
|
+
|
|
545
|
+
# Pass ALL user kwargs to Pipeline — it handles parameter extraction/sampling
|
|
546
|
+
return AugmentationPipeline(
|
|
547
|
+
modules,
|
|
548
|
+
module_names=module_names,
|
|
549
|
+
user_kwargs=self.kwargs
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
def _create_modes(self, mode, modes):
|
|
553
|
+
"""Create augmentation modes for each split."""
|
|
554
|
+
self.modes = {}
|
|
555
|
+
|
|
556
|
+
if modes is not None:
|
|
557
|
+
# Per-split modes
|
|
558
|
+
for split_name in self.splits.keys():
|
|
559
|
+
self.modes[split_name] = modes.get(split_name, 'on-the-fly')
|
|
560
|
+
else:
|
|
561
|
+
# Single mode for all
|
|
562
|
+
for split_name in self.splits.keys():
|
|
563
|
+
self.modes[split_name] = mode
|
|
564
|
+
|
|
565
|
+
def _get_dataloader(self, split: str = 'train', framework: str = None, shuffle: bool = None):
|
|
566
|
+
"""
|
|
567
|
+
Get dataloader for a specific split.
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
split: 'train', 'val', or 'test'
|
|
571
|
+
framework: 'pytorch', 'numpy', 'tensorflow', 'keras', 'jax'
|
|
572
|
+
shuffle: Whether to shuffle (only for fixed mode)
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Framework-specific dataloader/generator
|
|
576
|
+
"""
|
|
577
|
+
if split not in self.splits:
|
|
578
|
+
raise ValueError(f"Split '{split}' not available. Available: {list(self.splits.keys())}")
|
|
579
|
+
|
|
580
|
+
# Get data for this split
|
|
581
|
+
data, water = self.splits[split]
|
|
582
|
+
pipeline = self.pipelines[split]
|
|
583
|
+
mode = self.modes[split]
|
|
584
|
+
|
|
585
|
+
# Create generator based on mode
|
|
586
|
+
if mode == 'on-the-fly' or mode == 'random': # Support legacy 'random'
|
|
587
|
+
# On-the-fly: pipeline samples randomly from ranges
|
|
588
|
+
generator = create_random_generator(
|
|
589
|
+
data=data,
|
|
590
|
+
water=water,
|
|
591
|
+
pipeline=pipeline,
|
|
592
|
+
batch_size=self.batch_size
|
|
593
|
+
)
|
|
594
|
+
elif mode == 'fixed' or mode == 'deterministic': # Support legacy 'deterministic'
|
|
595
|
+
# Fixed: use exact values (modules will use fixed params)
|
|
596
|
+
shuffle_val = shuffle if shuffle is not None else (split == 'train')
|
|
597
|
+
|
|
598
|
+
# For fixed mode, we just iterate over subjects (no combinatorial explosion)
|
|
599
|
+
generator = create_fixed_generator(
|
|
600
|
+
data=data,
|
|
601
|
+
water=water,
|
|
602
|
+
pipeline=pipeline,
|
|
603
|
+
batch_size=self.batch_size,
|
|
604
|
+
shuffle=shuffle_val
|
|
605
|
+
)
|
|
606
|
+
else:
|
|
607
|
+
raise ValueError(f"Unknown mode: {mode}. Use 'on-the-fly' or 'fixed'")
|
|
608
|
+
|
|
609
|
+
# Wrap for framework
|
|
610
|
+
return wrap_generator_for_framework(generator, self.backend, framework)
|
|
611
|
+
|
|
612
|
+
def dataloader(self, framework: str = None, **kwargs):
|
|
613
|
+
"""
|
|
614
|
+
Get dataloader (for single dataset without splitting).
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
framework: 'pytorch', 'numpy', 'tensorflow', 'keras', 'jax'
|
|
618
|
+
**kwargs: Additional arguments (e.g., shuffle=True)
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
Framework-specific dataloader/generator
|
|
622
|
+
"""
|
|
623
|
+
return self._get_dataloader('train', framework, **kwargs)
|
|
624
|
+
|
|
625
|
+
def train_dataloader(self, framework: str = None, **kwargs):
|
|
626
|
+
"""Get training dataloader."""
|
|
627
|
+
return self._get_dataloader('train', framework, kwargs.get('shuffle', True))
|
|
628
|
+
|
|
629
|
+
def val_dataloader(self, framework: str = None, **kwargs):
|
|
630
|
+
"""Get validation dataloader."""
|
|
631
|
+
return self._get_dataloader('val', framework, kwargs.get('shuffle', False))
|
|
632
|
+
|
|
633
|
+
def test_dataloader(self, framework: str = None, **kwargs):
|
|
634
|
+
"""Get test dataloader."""
|
|
635
|
+
return self._get_dataloader('test', framework, kwargs.get('shuffle', False))
|
|
636
|
+
|
|
637
|
+
def as_torch_dataloader(self, split: str = 'train', **dataloader_kwargs):
|
|
638
|
+
"""
|
|
639
|
+
Return a PyTorch DataLoader for the given split, ready for use with
|
|
640
|
+
PyTorch Lightning's Trainer — no external wrapper class required.
|
|
641
|
+
|
|
642
|
+
Each item yielded is a complex tensor (B, ..., N_time) directly
|
|
643
|
+
consumable by FrameworkNN. Batch size is controlled by the
|
|
644
|
+
Augmentrum batch_size parameter set at construction.
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
split: 'train', 'val', or 'test'
|
|
648
|
+
**dataloader_kwargs: Extra args forwarded to DataLoader
|
|
649
|
+
(e.g. num_workers, pin_memory).
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
torch.utils.data.DataLoader
|
|
653
|
+
"""
|
|
654
|
+
import torch
|
|
655
|
+
from torch.utils.data import DataLoader, IterableDataset
|
|
656
|
+
|
|
657
|
+
_aug = self
|
|
658
|
+
_split = split
|
|
659
|
+
|
|
660
|
+
class _AugIterableDataset(IterableDataset):
|
|
661
|
+
def __iter__(self_inner):
|
|
662
|
+
gen = _aug._get_dataloader(_split, framework='pytorch')
|
|
663
|
+
for batch_data, _ in gen:
|
|
664
|
+
if batch_data is None:
|
|
665
|
+
continue
|
|
666
|
+
if not batch_data.is_complex():
|
|
667
|
+
batch_data = batch_data.to(torch.cfloat)
|
|
668
|
+
yield batch_data
|
|
669
|
+
|
|
670
|
+
return DataLoader(_AugIterableDataset(), batch_size=None, **dataloader_kwargs)
|
|
671
|
+
|
|
672
|
+
def visualize_pipeline(self, split: str = 'train', detailed: bool = True) -> str:
|
|
673
|
+
"""
|
|
674
|
+
Create a beautiful visualization of the augmentation pipeline.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
split: Which split's pipeline to visualize
|
|
678
|
+
detailed: If True, show module parameters
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
Formatted string visualization
|
|
682
|
+
"""
|
|
683
|
+
if split not in self.pipelines:
|
|
684
|
+
return f"No pipeline for split '{split}'"
|
|
685
|
+
|
|
686
|
+
pipeline = self.pipelines[split]
|
|
687
|
+
|
|
688
|
+
# Build visualization
|
|
689
|
+
lines = []
|
|
690
|
+
lines.append("╔" + "═" * 78 + "╗")
|
|
691
|
+
lines.append("║" + f"{'🔬 AUGMENTRUM PIPELINE 🔬':^78}" + "║")
|
|
692
|
+
lines.append("╠" + "═" * 78 + "╣")
|
|
693
|
+
|
|
694
|
+
# Info
|
|
695
|
+
data, water = self.splits[split]
|
|
696
|
+
n_subjects = len(data)
|
|
697
|
+
n_water = len(water) if water is not None else 0
|
|
698
|
+
|
|
699
|
+
# Build info lines
|
|
700
|
+
info_line_1 = f"║ 📊 Split: {split:20} Subjects: {n_subjects:4} Mode: {self.modes[split]:15}║"
|
|
701
|
+
lines.append(info_line_1)
|
|
702
|
+
|
|
703
|
+
# Water info line
|
|
704
|
+
if water is not None:
|
|
705
|
+
water_info = f"Water: {n_water:4}"
|
|
706
|
+
else:
|
|
707
|
+
water_info = "Water: None"
|
|
708
|
+
backend_info = f"Backend: {self.backend.name:15}"
|
|
709
|
+
batch_info = f"Batch Size: {self.batch_size:4}"
|
|
710
|
+
volatile_info = f"Volatile: {self.volatile!s:5}"
|
|
711
|
+
|
|
712
|
+
info_line_2 = f"║ 🎯 {backend_info} {batch_info} {volatile_info} ║"
|
|
713
|
+
lines.append(info_line_2)
|
|
714
|
+
|
|
715
|
+
if water is not None:
|
|
716
|
+
water_line = f"║ 💧 {water_info}" + " " * (73 - len(water_info)) + "║"
|
|
717
|
+
lines.append(water_line)
|
|
718
|
+
|
|
719
|
+
lines.append("╠" + "═" * 78 + "╣")
|
|
720
|
+
lines.append("║ 🔄 Pipeline Steps:" + " " * 57 + "║")
|
|
721
|
+
lines.append("║" + " " * 78 + "║")
|
|
722
|
+
|
|
723
|
+
# Steps
|
|
724
|
+
if len(pipeline.steps) == 0:
|
|
725
|
+
lines.append("║ No augmentation modules configured" + " " * 40 + "║")
|
|
726
|
+
else:
|
|
727
|
+
for i, module in enumerate(pipeline.steps, 1):
|
|
728
|
+
module_name = module.__class__.__name__
|
|
729
|
+
emoji = self._get_module_emoji(module_name)
|
|
730
|
+
lines.append(f"║ {i}. {emoji} {module_name:50}║")
|
|
731
|
+
|
|
732
|
+
if detailed:
|
|
733
|
+
# Show key parameters
|
|
734
|
+
params = self._get_module_params(module)
|
|
735
|
+
if params:
|
|
736
|
+
lines.append(f"║ {params:70}║")
|
|
737
|
+
|
|
738
|
+
if i < len(pipeline.steps):
|
|
739
|
+
lines.append("║ ↓" + " " * 71 + "║")
|
|
740
|
+
|
|
741
|
+
lines.append("╚" + "═" * 78 + "╝")
|
|
742
|
+
|
|
743
|
+
return "\n".join(lines)
|
|
744
|
+
|
|
745
|
+
def _get_module_emoji(self, name: str) -> str:
|
|
746
|
+
"""Get emoji for module type."""
|
|
747
|
+
emoji_map = {
|
|
748
|
+
'CoilAverageSampler': '📡',
|
|
749
|
+
'NIfTI_RawProcessor': '⚙️',
|
|
750
|
+
'GaussianNoise': '🔊',
|
|
751
|
+
'LineBroadening': '〰️',
|
|
752
|
+
'BaselineAugmentation': '📈',
|
|
753
|
+
'PhaseShift': '🔄',
|
|
754
|
+
'FrequencyShift': '↔️',
|
|
755
|
+
'ResidualWater': '💧',
|
|
756
|
+
'SpuriousEchoes': '👻',
|
|
757
|
+
'ArtificialPeaks': '⛰️',
|
|
758
|
+
'EddyCurrent': '🌀',
|
|
759
|
+
'Apodization': '✂️',
|
|
760
|
+
'SpatialAugmentations': '🗺️',
|
|
761
|
+
}
|
|
762
|
+
return emoji_map.get(name, '🔧')
|
|
763
|
+
|
|
764
|
+
def _get_module_params(self, module) -> str:
|
|
765
|
+
"""Get key parameters of a module by introspecting its attributes."""
|
|
766
|
+
# List of common parameter names to display (in order of priority)
|
|
767
|
+
param_names = [
|
|
768
|
+
'n_coils', 'n_averages', 'mode',
|
|
769
|
+
'sigma_frac', 'snr', 'sigma',
|
|
770
|
+
'lb_hz', 'gb_hz',
|
|
771
|
+
'zero_order_deg', 'first_order_deg', 'shift_hz',
|
|
772
|
+
'baseline_frac', 'baseline_mode',
|
|
773
|
+
'water_amp', 'water_width',
|
|
774
|
+
'n_echoes', 'echo_time',
|
|
775
|
+
'n_peaks', 'peak_shift',
|
|
776
|
+
'eddy_std', 'apod_lb',
|
|
777
|
+
'dim', 'prob', 'min_coils', 'max_coils',
|
|
778
|
+
'max_z_angle_deg', 'zoom_min', 'zoom_max', 'shear_max',
|
|
779
|
+
]
|
|
780
|
+
|
|
781
|
+
params = []
|
|
782
|
+
for param_name in param_names:
|
|
783
|
+
if hasattr(module, param_name):
|
|
784
|
+
value = getattr(module, param_name)
|
|
785
|
+
if value is not None and value is not False:
|
|
786
|
+
# Skip default/empty values
|
|
787
|
+
if isinstance(value, (int, float)) and value == 0:
|
|
788
|
+
continue
|
|
789
|
+
if isinstance(value, str) and value == '':
|
|
790
|
+
continue
|
|
791
|
+
|
|
792
|
+
# Use original parameter name
|
|
793
|
+
params.append(f"{param_name}={value}")
|
|
794
|
+
|
|
795
|
+
# Limit to 3 params for readability
|
|
796
|
+
if len(params) >= 3:
|
|
797
|
+
break
|
|
798
|
+
|
|
799
|
+
return ", ".join(params) if params else ""
|
|
800
|
+
|
|
801
|
+
def show_pipeline(self, split: str = 'train', detailed: bool = True):
|
|
802
|
+
"""
|
|
803
|
+
Print a beautiful visualization of the pipeline.
|
|
804
|
+
|
|
805
|
+
Args:
|
|
806
|
+
split: Which split to visualize ('train', 'val', 'test')
|
|
807
|
+
detailed: If True, show module parameters; if False, show only names
|
|
808
|
+
|
|
809
|
+
Example:
|
|
810
|
+
>>> augmenter = Augmentrum(...)
|
|
811
|
+
>>> augmenter.show_pipeline('train', detailed=True)
|
|
812
|
+
"""
|
|
813
|
+
print(self.visualize_pipeline(split=split, detailed=detailed))
|
|
814
|
+
|
|
815
|
+
def add_callback(self, callback_fn):
|
|
816
|
+
"""
|
|
817
|
+
Add a custom callback function that will be called after each augmentation.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
callback_fn: Function with signature: fn(data, water, split_name, batch_idx) -> (data, water)
|
|
821
|
+
|
|
822
|
+
Example:
|
|
823
|
+
>>> def my_callback(data, water, split, batch_idx):
|
|
824
|
+
... print(f"Processing batch {batch_idx} from {split}")
|
|
825
|
+
... return data, water
|
|
826
|
+
>>> augmenter.add_callback(my_callback)
|
|
827
|
+
"""
|
|
828
|
+
self.callbacks.append(callback_fn)
|
|
829
|
+
|
|
830
|
+
def clear_callbacks(self):
|
|
831
|
+
"""Remove all callbacks."""
|
|
832
|
+
self.callbacks = []
|
|
833
|
+
|
|
834
|
+
def update_module_params(self, split: str, module_name: str, **params):
|
|
835
|
+
"""
|
|
836
|
+
Update parameters of a specific module in the pipeline on-the-fly.
|
|
837
|
+
|
|
838
|
+
Args:
|
|
839
|
+
split: Which split's pipeline to update ('train', 'val', 'test')
|
|
840
|
+
module_name: Name of the module class (e.g., 'GaussianNoise')
|
|
841
|
+
**params: Parameters to update
|
|
842
|
+
|
|
843
|
+
Example:
|
|
844
|
+
>>> # Change noise level dynamically
|
|
845
|
+
>>> augmenter.update_module_params('train', 'GaussianNoise', sigma_frac=0.05)
|
|
846
|
+
>>> # Change broadening parameters
|
|
847
|
+
>>> augmenter.update_module_params('train', 'LineBroadening', lb_hz=(0, 10))
|
|
848
|
+
"""
|
|
849
|
+
if split not in self.pipelines:
|
|
850
|
+
raise ValueError(f"Split '{split}' not found. Available: {list(self.pipelines.keys())}")
|
|
851
|
+
|
|
852
|
+
pipeline = self.pipelines[split]
|
|
853
|
+
updated = False
|
|
854
|
+
|
|
855
|
+
for module in pipeline.steps:
|
|
856
|
+
if module.__class__.__name__ == module_name:
|
|
857
|
+
for key, value in params.items():
|
|
858
|
+
if hasattr(module, key):
|
|
859
|
+
setattr(module, key, value)
|
|
860
|
+
updated = True
|
|
861
|
+
|
|
862
|
+
if not updated:
|
|
863
|
+
raise ValueError(f"Module '{module_name}' not found in {split} pipeline or parameters don't exist")
|
|
864
|
+
|
|
865
|
+
def get_stats(self, reset: bool = False) -> Dict[str, Any]:
|
|
866
|
+
"""
|
|
867
|
+
Get augmentation statistics.
|
|
868
|
+
|
|
869
|
+
Args:
|
|
870
|
+
reset: If True, reset statistics after returning
|
|
871
|
+
|
|
872
|
+
Returns:
|
|
873
|
+
Dictionary with statistics
|
|
874
|
+
|
|
875
|
+
Example:
|
|
876
|
+
>>> stats = augmenter.get_stats()
|
|
877
|
+
>>> print(f"Generated {stats['batches_generated']} batches")
|
|
878
|
+
"""
|
|
879
|
+
stats = self.stats.copy()
|
|
880
|
+
if reset:
|
|
881
|
+
self.reset_stats()
|
|
882
|
+
return stats
|
|
883
|
+
|
|
884
|
+
def reset_stats(self):
|
|
885
|
+
"""Reset all statistics counters."""
|
|
886
|
+
self.stats = {
|
|
887
|
+
'batches_generated': 0,
|
|
888
|
+
'samples_generated': 0,
|
|
889
|
+
'split_stats': {split: {'batches': 0, 'samples': 0} for split in self.splits.keys()}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
def profile_pipeline(self, split: str = 'train', n_batches: int = 10):
|
|
893
|
+
"""
|
|
894
|
+
Profile the pipeline performance.
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
split: Which split to profile
|
|
898
|
+
n_batches: Number of batches to process for profiling
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
Dictionary with timing statistics
|
|
902
|
+
|
|
903
|
+
Example:
|
|
904
|
+
>>> profile = augmenter.profile_pipeline('train', n_batches=100)
|
|
905
|
+
>>> print(f"Average time per batch: {profile['avg_time_per_batch']:.3f}s")
|
|
906
|
+
"""
|
|
907
|
+
import time
|
|
908
|
+
|
|
909
|
+
dataloader = self.dataloader(split=split, framework='numpy')
|
|
910
|
+
|
|
911
|
+
times = []
|
|
912
|
+
for i, (data, water) in enumerate(dataloader):
|
|
913
|
+
if i >= n_batches:
|
|
914
|
+
break
|
|
915
|
+
|
|
916
|
+
start = time.time()
|
|
917
|
+
# Process through pipeline (already done in dataloader)
|
|
918
|
+
elapsed = time.time() - start
|
|
919
|
+
times.append(elapsed)
|
|
920
|
+
|
|
921
|
+
import numpy as np
|
|
922
|
+
return {
|
|
923
|
+
'split': split,
|
|
924
|
+
'n_batches': len(times),
|
|
925
|
+
'total_time': sum(times),
|
|
926
|
+
'avg_time_per_batch': np.mean(times),
|
|
927
|
+
'std_time_per_batch': np.std(times),
|
|
928
|
+
'min_time': min(times),
|
|
929
|
+
'max_time': max(times),
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
def export_batch(self, output_dir, format='nifti-mrs', metadata=None, split='train',
|
|
933
|
+
n_batches=1, prefix='augmented', save_water=True):
|
|
934
|
+
"""
|
|
935
|
+
Export augmented batches to disk in various formats.
|
|
936
|
+
|
|
937
|
+
This method enables efficient batch export of augmented datasets in formats
|
|
938
|
+
compatible with existing MRS software (FSL-MRS, Osprey, LCModel, etc.).
|
|
939
|
+
|
|
940
|
+
Currently supports:
|
|
941
|
+
- NIFTI-MRS: Individual NIFTI files (one per augmented spectrum) ✓
|
|
942
|
+
- HDF5: Single file with all metadata (data only, limited reconstruction) ✓
|
|
943
|
+
|
|
944
|
+
Planned future formats:
|
|
945
|
+
- LCModel RAW format
|
|
946
|
+
- Osprey-compatible structure
|
|
947
|
+
|
|
948
|
+
Args:
|
|
949
|
+
output_dir: Directory to save exported data
|
|
950
|
+
format: Output format ('nifti-mrs', 'hdf5')
|
|
951
|
+
metadata: Optional metadata dictionary to embed
|
|
952
|
+
split: Which data split to export ('train', 'val', 'test')
|
|
953
|
+
n_batches: Number of batches to export
|
|
954
|
+
prefix: Filename prefix (default: 'augmented')
|
|
955
|
+
save_water: Whether to save water reference if available
|
|
956
|
+
|
|
957
|
+
Returns:
|
|
958
|
+
Dictionary with saved file paths and export info
|
|
959
|
+
|
|
960
|
+
Example:
|
|
961
|
+
>>> info = augmenter.export_batch(
|
|
962
|
+
... 'output/augmented_dataset',
|
|
963
|
+
... format='nifti-mrs',
|
|
964
|
+
... n_batches=10,
|
|
965
|
+
... prefix='aug'
|
|
966
|
+
... )
|
|
967
|
+
>>> print(f"Saved {info['n_files']} NIFTI-MRS files")
|
|
968
|
+
"""
|
|
969
|
+
from pathlib import Path
|
|
970
|
+
import numpy as np
|
|
971
|
+
|
|
972
|
+
output_path = Path(output_dir)
|
|
973
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
974
|
+
|
|
975
|
+
format = format.lower()
|
|
976
|
+
if format not in ['nifti-mrs', 'hdf5']:
|
|
977
|
+
raise ValueError(f"Unsupported format: {format}. Use 'nifti-mrs' or 'hdf5'")
|
|
978
|
+
|
|
979
|
+
# Get dataloader for the split
|
|
980
|
+
if split == 'train':
|
|
981
|
+
dataloader = self.train_dataloader(framework='numpy')
|
|
982
|
+
elif split == 'val':
|
|
983
|
+
dataloader = self.val_dataloader(framework='numpy')
|
|
984
|
+
elif split == 'test':
|
|
985
|
+
dataloader = self.test_dataloader(framework='numpy')
|
|
986
|
+
else:
|
|
987
|
+
raise ValueError(f"Invalid split: {split}. Use 'train', 'val', or 'test'")
|
|
988
|
+
|
|
989
|
+
saved_files = []
|
|
990
|
+
saved_water_files = []
|
|
991
|
+
batch_count = 0
|
|
992
|
+
total_spectra = 0
|
|
993
|
+
|
|
994
|
+
print(f"Exporting {n_batches} batch(es) to {format.upper()} format...")
|
|
995
|
+
|
|
996
|
+
for batch_idx, (data_batch, water_batch) in enumerate(dataloader):
|
|
997
|
+
if batch_idx >= n_batches:
|
|
998
|
+
break
|
|
999
|
+
|
|
1000
|
+
batch_count += 1
|
|
1001
|
+
|
|
1002
|
+
# Remove spatial dimensions if present
|
|
1003
|
+
while data_batch.ndim > 2:
|
|
1004
|
+
data_batch = np.squeeze(data_batch)
|
|
1005
|
+
|
|
1006
|
+
# Ensure 2D [batch, time]
|
|
1007
|
+
if data_batch.ndim == 1:
|
|
1008
|
+
data_batch = data_batch[np.newaxis, :]
|
|
1009
|
+
|
|
1010
|
+
n_spectra_in_batch = data_batch.shape[0]
|
|
1011
|
+
|
|
1012
|
+
if format == 'nifti-mrs':
|
|
1013
|
+
# Save each spectrum as individual NIFTI-MRS file
|
|
1014
|
+
# We need to reconstruct NIFTI_MRS objects from the augmented data
|
|
1015
|
+
|
|
1016
|
+
# Get reference NIFTI for metadata
|
|
1017
|
+
ref_data, _ = self.splits[split]
|
|
1018
|
+
ref_nifti = ref_data[0] if len(ref_data) > 0 else None
|
|
1019
|
+
|
|
1020
|
+
if ref_nifti is None:
|
|
1021
|
+
raise ValueError("No reference NIFTI-MRS data available for export")
|
|
1022
|
+
|
|
1023
|
+
# Import here to avoid circular dependency
|
|
1024
|
+
from fsl_mrs.core.nifti_mrs import gen_nifti_mrs
|
|
1025
|
+
|
|
1026
|
+
for spec_idx in range(n_spectra_in_batch):
|
|
1027
|
+
spectrum = data_batch[spec_idx]
|
|
1028
|
+
|
|
1029
|
+
# Ensure spectrum has proper dimensions for NIFTI-MRS (at least 4D: x, y, z, time)
|
|
1030
|
+
if spectrum.ndim == 1:
|
|
1031
|
+
# Add spatial dimensions: (1, 1, 1, time)
|
|
1032
|
+
spectrum = spectrum.reshape(1, 1, 1, -1)
|
|
1033
|
+
elif spectrum.ndim == 2:
|
|
1034
|
+
# Assume (coils, time) - add spatial: (1, 1, 1, time, coils)
|
|
1035
|
+
spectrum = spectrum.T.reshape(1, 1, 1, spectrum.shape[1], spectrum.shape[0])
|
|
1036
|
+
|
|
1037
|
+
# Create new NIFTI_MRS object with augmented data using gen_nifti_mrs
|
|
1038
|
+
# Get parameters from reference
|
|
1039
|
+
dwelltime = ref_nifti.dwelltime if hasattr(ref_nifti, 'dwelltime') else 1/2000
|
|
1040
|
+
spec_freq = ref_nifti.spectrometer_frequency[0] if hasattr(ref_nifti, 'spectrometer_frequency') else 123.0
|
|
1041
|
+
|
|
1042
|
+
# Create NIFTI_MRS with proper structure
|
|
1043
|
+
new_nifti = gen_nifti_mrs(spectrum, dwelltime, spec_freq)
|
|
1044
|
+
|
|
1045
|
+
# Add provenance metadata using add_hdr_field
|
|
1046
|
+
from datetime import datetime
|
|
1047
|
+
processing_entry = {
|
|
1048
|
+
'Time': datetime.now().isoformat(),
|
|
1049
|
+
'Program': 'augmentrum',
|
|
1050
|
+
'Version': __version__,
|
|
1051
|
+
'Method': 'export_batch',
|
|
1052
|
+
'Details': {
|
|
1053
|
+
'split': split,
|
|
1054
|
+
'batch': batch_idx,
|
|
1055
|
+
'spectrum_in_batch': spec_idx
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
# Add user metadata if provided
|
|
1060
|
+
if metadata:
|
|
1061
|
+
processing_entry['UserMetadata'] = metadata
|
|
1062
|
+
|
|
1063
|
+
# Add to ProcessingApplied list
|
|
1064
|
+
new_nifti.add_hdr_field('ProcessingApplied', [processing_entry])
|
|
1065
|
+
|
|
1066
|
+
# Save file
|
|
1067
|
+
global_idx = total_spectra + spec_idx
|
|
1068
|
+
filename = f"{prefix}_{global_idx:06d}.nii.gz"
|
|
1069
|
+
filepath = output_path / filename
|
|
1070
|
+
new_nifti.save(str(filepath))
|
|
1071
|
+
saved_files.append(str(filepath))
|
|
1072
|
+
|
|
1073
|
+
# Save water reference if available
|
|
1074
|
+
if save_water and water_batch is not None:
|
|
1075
|
+
while water_batch.ndim > 2:
|
|
1076
|
+
water_batch = np.squeeze(water_batch)
|
|
1077
|
+
if water_batch.ndim == 1:
|
|
1078
|
+
water_batch = water_batch[np.newaxis, :]
|
|
1079
|
+
|
|
1080
|
+
for spec_idx in range(water_batch.shape[0]):
|
|
1081
|
+
water_spectrum = water_batch[spec_idx]
|
|
1082
|
+
|
|
1083
|
+
# Ensure proper dimensions
|
|
1084
|
+
if water_spectrum.ndim == 1:
|
|
1085
|
+
water_spectrum = water_spectrum.reshape(1, 1, 1, -1)
|
|
1086
|
+
elif water_spectrum.ndim == 2:
|
|
1087
|
+
water_spectrum = water_spectrum.T.reshape(1, 1, 1, water_spectrum.shape[1], water_spectrum.shape[0])
|
|
1088
|
+
|
|
1089
|
+
# Create water NIFTI_MRS using gen_nifti_mrs
|
|
1090
|
+
dwelltime = ref_nifti.dwelltime if hasattr(ref_nifti, 'dwelltime') else 1/2000
|
|
1091
|
+
spec_freq = ref_nifti.spectrometer_frequency[0] if hasattr(ref_nifti, 'spectrometer_frequency') else 123.0
|
|
1092
|
+
|
|
1093
|
+
water_nifti = gen_nifti_mrs(water_spectrum, dwelltime, spec_freq)
|
|
1094
|
+
|
|
1095
|
+
# Add water-specific provenance
|
|
1096
|
+
from datetime import datetime
|
|
1097
|
+
water_processing = {
|
|
1098
|
+
'Time': datetime.now().isoformat(),
|
|
1099
|
+
'Program': 'augmentrum',
|
|
1100
|
+
'Version': __version__,
|
|
1101
|
+
'Method': 'export_batch',
|
|
1102
|
+
'DataType': 'water_reference',
|
|
1103
|
+
'Details': {
|
|
1104
|
+
'split': split,
|
|
1105
|
+
'batch': batch_idx,
|
|
1106
|
+
'spectrum_in_batch': spec_idx
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
water_nifti.add_hdr_field('ProcessingApplied', [water_processing])
|
|
1110
|
+
|
|
1111
|
+
global_idx = total_spectra + spec_idx
|
|
1112
|
+
water_filename = f"{prefix}_water_{global_idx:06d}.nii.gz"
|
|
1113
|
+
water_filepath = output_path / water_filename
|
|
1114
|
+
water_nifti.save(str(water_filepath))
|
|
1115
|
+
saved_water_files.append(str(water_filepath))
|
|
1116
|
+
|
|
1117
|
+
elif format == 'hdf5':
|
|
1118
|
+
# Save batch to HDF5
|
|
1119
|
+
import h5py
|
|
1120
|
+
|
|
1121
|
+
hdf5_path = output_path / f"{prefix}_batch_{batch_idx:04d}.h5"
|
|
1122
|
+
|
|
1123
|
+
with h5py.File(hdf5_path, 'w') as f:
|
|
1124
|
+
# Store data
|
|
1125
|
+
f.create_dataset('data', data=data_batch, compression='gzip')
|
|
1126
|
+
|
|
1127
|
+
if water_batch is not None and save_water:
|
|
1128
|
+
f.create_dataset('water', data=water_batch, compression='gzip')
|
|
1129
|
+
|
|
1130
|
+
# Store metadata
|
|
1131
|
+
f.attrs['augmentrum_version'] = __version__
|
|
1132
|
+
f.attrs['split'] = split
|
|
1133
|
+
f.attrs['batch_idx'] = batch_idx
|
|
1134
|
+
f.attrs['n_spectra'] = n_spectra_in_batch
|
|
1135
|
+
|
|
1136
|
+
if metadata:
|
|
1137
|
+
import json
|
|
1138
|
+
f.attrs['user_metadata'] = json.dumps(metadata)
|
|
1139
|
+
|
|
1140
|
+
saved_files.append(str(hdf5_path))
|
|
1141
|
+
|
|
1142
|
+
total_spectra += n_spectra_in_batch
|
|
1143
|
+
print(f" Batch {batch_idx + 1}/{n_batches}: Saved {n_spectra_in_batch} spectra")
|
|
1144
|
+
|
|
1145
|
+
result = {
|
|
1146
|
+
'format': format,
|
|
1147
|
+
'output_dir': str(output_path),
|
|
1148
|
+
'n_batches': batch_count,
|
|
1149
|
+
'n_spectra': total_spectra,
|
|
1150
|
+
'n_files': len(saved_files),
|
|
1151
|
+
'files': saved_files
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if saved_water_files:
|
|
1155
|
+
result['n_water_files'] = len(saved_water_files)
|
|
1156
|
+
result['water_files'] = saved_water_files
|
|
1157
|
+
|
|
1158
|
+
print(f"✓ Export complete: {total_spectra} spectra in {len(saved_files)} file(s)")
|
|
1159
|
+
|
|
1160
|
+
return result
|
|
1161
|
+
|
|
1162
|
+
def __repr__(self):
|
|
1163
|
+
n_total = sum(len(v[0]) for v in self.splits.values())
|
|
1164
|
+
splits_info = ", ".join([f"{k}={len(v[0])}" for k, v in self.splits.items()])
|
|
1165
|
+
return (f"Augmentrum({n_total} subjects, splits=[{splits_info}], "
|
|
1166
|
+
f"backend={self.backend.name}, batch_size={self.batch_size})")
|
|
1167
|
+
|
|
1168
|
+
def __str__(self):
|
|
1169
|
+
"""User-friendly string representation."""
|
|
1170
|
+
n_total = sum(len(v[0]) for v in self.splits.values())
|
|
1171
|
+
splits_detail = ", ".join([f"{k}:{len(v[0])}" for k, v in self.splits.items()])
|
|
1172
|
+
return f"Augmentrum({n_total} subjects | {splits_detail} | {self.backend.name})"
|