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,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})"