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,651 @@
1
+ ####################################################################################################
2
+ # plotting.py #
3
+ ####################################################################################################
4
+ # #
5
+ # Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
6
+ # #
7
+ # Created: 2025-02-13 #
8
+ # #
9
+ # Purpose: State-of-the-art plotting utilities for NIfTI_MRS_Plus objects #
10
+ # Implements batch-aware plotting following NIfTI-MRS conventions #
11
+ # #
12
+ ####################################################################################################
13
+
14
+ import os
15
+
16
+ import numpy as np
17
+ import matplotlib.pyplot as plt
18
+ from typing import Optional, Union, List, Tuple
19
+ import warnings
20
+
21
+
22
+ # ══════════════════════════════════════════════════════════════════════════════
23
+ # Simple spectrum / fit plots (MRS paper style)
24
+ # Ported from scripts/visualizations.py so there is one canonical location.
25
+ # ══════════════════════════════════════════════════════════════════════════════
26
+
27
+ def _crop_ppm(arr, ppmAxis, ppmLim):
28
+ """Return (arr_cropped, ppmAxis_cropped) within *ppmLim* = (lo, hi)."""
29
+ beg = int((np.abs(ppmAxis - ppmLim[0])).argmin())
30
+ end = int((np.abs(ppmAxis - ppmLim[1])).argmin())
31
+ if beg > end:
32
+ beg, end = end, beg
33
+ return arr[beg:end], ppmAxis[beg:end]
34
+
35
+
36
+ def _pick_part(arr, real):
37
+ """Select real or imaginary part of a (possibly already real) array."""
38
+ return np.real(arr) if real else np.imag(arr)
39
+
40
+
41
+ def _style_mrs_ax(ax, clean):
42
+ """Canonical MRS axis style (inverted ppm, y-hidden); everything hidden when *clean*."""
43
+ for spine in ('right', 'top', 'left'):
44
+ ax.spines[spine].set_visible(False)
45
+ ax.yaxis.set_visible(False)
46
+ ax.yaxis.set_ticks_position('none')
47
+ ax.invert_xaxis()
48
+ if clean:
49
+ ax.spines['bottom'].set_visible(False)
50
+ ax.xaxis.set_visible(False)
51
+ ax.xaxis.set_ticks_position('none')
52
+
53
+
54
+ def _save_fig(fig, save_path, name):
55
+ """Save *fig* to ``save_path/name.png``, close it, and return the path."""
56
+ if save_path is not None:
57
+ os.makedirs(save_path, exist_ok=True)
58
+ out = os.path.join(save_path, f'{name}.png')
59
+ fig.savefig(out, dpi=300, bbox_inches='tight', transparent=True)
60
+ plt.close(fig)
61
+ return out
62
+ return None
63
+
64
+
65
+ def plot_spec_and_fit(spec, fit, true=None, ppmAxis=None, ppmLim=None, name='',
66
+ save_path=None, real=True, clean=False):
67
+ """Plot a measured spectrum, the model fit, and the residual on a ppm axis.
68
+
69
+ Visual style: Data (black) / Fit (red, α=0.6) / Residual offset above (dimgrey) /
70
+ optional True spectrum (green dashed). ppm-axis inverted, y-axis hidden.
71
+
72
+ Parameters
73
+ ----------
74
+ spec : np.ndarray (complex) measured spectrum (1-D).
75
+ fit : np.ndarray (complex) forward-modelled fit (1-D).
76
+ true : np.ndarray (complex), optional ground-truth spectrum.
77
+ ppmAxis : np.ndarray, optional chemical-shift axis matching *spec*. Defaults to
78
+ ``linspace(0.5, 4.0, len(spec))``.
79
+ ppmLim : tuple(float, float), optional (lo, hi) ppm window to display.
80
+ name : str filename stem (without extension).
81
+ save_path : str, optional directory to save PNG into; created if missing.
82
+ real : bool if True plot real part, else imaginary.
83
+ clean : bool if True hide all axes/labels (publication snippet style).
84
+ """
85
+ if ppmAxis is None:
86
+ ppmAxis = np.linspace(0.5, 4.0, spec.shape[0])
87
+
88
+ if ppmLim is not None:
89
+ orig_ppm = ppmAxis # save BEFORE first crop
90
+ spec, ppmAxis = _crop_ppm(spec, orig_ppm, ppmLim)
91
+ fit_ppm = orig_ppm if fit.shape[0] == orig_ppm.shape[0] \
92
+ else np.linspace(orig_ppm[0], orig_ppm[-1], fit.shape[0])
93
+ fit, _ = _crop_ppm(fit, fit_ppm, ppmLim)
94
+ if true is not None:
95
+ true_ppm = orig_ppm if true.shape[0] == orig_ppm.shape[0] \
96
+ else np.linspace(orig_ppm[0], orig_ppm[-1], true.shape[0])
97
+ true, _ = _crop_ppm(true, true_ppm, ppmLim)
98
+
99
+ spec = _pick_part(spec, real)
100
+ fit = _pick_part(fit, real)
101
+ residual = spec - fit + 1.1 * spec.max()
102
+
103
+ fig, ax = plt.subplots(figsize=(4, 3.5))
104
+ ax.plot(ppmAxis, spec, 'k', label='Data', linewidth=1)
105
+ ax.plot(ppmAxis, fit, 'r', label='Fit', alpha=0.6, linewidth=2)
106
+ ax.plot(ppmAxis, residual, 'dimgrey', label='Residual', alpha=0.8, linewidth=1)
107
+ if true is not None:
108
+ true = _pick_part(true, real)
109
+ ax.plot(ppmAxis, true, 'g', label='True Spectrum',
110
+ alpha=0.8, linewidth=1, linestyle='--')
111
+
112
+ if not clean:
113
+ ax.set_xlabel('Chemical Shift [ppm]')
114
+ ax.legend(frameon=False, fontsize=8, loc='upper left')
115
+ _style_mrs_ax(ax, clean)
116
+ return _save_fig(fig, save_path, name)
117
+
118
+
119
+ def plot_spec(spec, ppmAxis=None, ppmLim=None, name='', save_path=None,
120
+ real=True, color='k', title=None, clean=False):
121
+ """Plot a single complex spectrum on a ppm axis (no fit / residual).
122
+
123
+ Same visual style as :func:`plot_spec_and_fit` — useful for sanity-checking
124
+ the simulated input in isolation.
125
+
126
+ Parameters
127
+ ----------
128
+ spec : np.ndarray (complex) spectrum to display (1-D).
129
+ ppmAxis : np.ndarray, optional chemical-shift axis matching *spec*.
130
+ ppmLim : tuple(float, float), optional (lo, hi) ppm window to display.
131
+ name : str filename stem (without extension).
132
+ save_path : str, optional directory to save PNG into; created if missing.
133
+ real : bool if True plot real part, else imaginary.
134
+ color : str matplotlib colour string for the spectrum line.
135
+ title : str, optional axes title (small font, publication-friendly).
136
+ clean : bool if True hide all axes/labels (publication snippet style).
137
+ """
138
+ if ppmAxis is None:
139
+ ppmAxis = np.linspace(0.5, 4.0, spec.shape[0])
140
+
141
+ if ppmLim is not None:
142
+ spec, ppmAxis = _crop_ppm(spec, ppmAxis, ppmLim)
143
+
144
+ spec = _pick_part(spec, real)
145
+
146
+ fig, ax = plt.subplots(figsize=(4, 3.5))
147
+ ax.plot(ppmAxis, spec, color, label='Data', linewidth=1)
148
+ if not clean:
149
+ ax.set_xlabel('Chemical Shift [ppm]')
150
+ if title:
151
+ ax.set_title(title, fontsize=9)
152
+ ax.legend(frameon=False, fontsize=8, loc='upper left')
153
+ _style_mrs_ax(ax, clean)
154
+ return _save_fig(fig, save_path, name)
155
+
156
+
157
+ # ══════════════════════════════════════════════════════════════════════════════
158
+ # NIfTI-MRS batch plotting (existing code below, unchanged)
159
+ # ══════════════════════════════════════════════════════════════════════════════
160
+
161
+ try:
162
+ from fsl_mrs.utils.plotting import plot_spectrum, plot_spectra
163
+ from fsl_mrs.core.mrs import MRS
164
+ from fsl_mrs.utils import constants
165
+ FSL_MRS_AVAILABLE = True
166
+ except ImportError:
167
+ FSL_MRS_AVAILABLE = False
168
+ warnings.warn(
169
+ "FSL-MRS not available. Some plotting features will be limited. "
170
+ "Install FSL-MRS for full functionality: pip install fsl-mrs"
171
+ )
172
+
173
+
174
+ def vis_nifti_mrs_plus(
175
+ data,
176
+ display_dim=None,
177
+ ppmlim=None,
178
+ plot_avg=False,
179
+ batch_index=None,
180
+ max_batch_display=6,
181
+ grid_layout=None,
182
+ legend=True,
183
+ figsize=None,
184
+ title=None
185
+ ):
186
+ """
187
+ Visualize NIfTI_MRS_Plus objects with batch-aware plotting.
188
+
189
+ This function mirrors the NIfTI-MRS plot() method but adds batch support.
190
+
191
+ Args:
192
+ data: NIfTI_MRS_Plus object
193
+ display_dim: Dimension to display (if multiple dims exist)
194
+ ppmlim: Tuple of (min_ppm, max_ppm) for x-axis limits
195
+ plot_avg: If True, plot average spectrum
196
+ batch_index: Which batch element to plot (None = plot all up to max_batch_display)
197
+ max_batch_display: Maximum number of batch elements to display
198
+ grid_layout: Tuple (rows, cols) for grid layout. Auto if None
199
+ legend: Whether to show legend
200
+ figsize: Figure size (width, height)
201
+ title: Plot title
202
+
203
+ Returns:
204
+ matplotlib figure object
205
+
206
+ Example:
207
+ >>> nifti_plus.plot() # Plot first spectrum
208
+ >>> nifti_plus.plot(batch_index=0) # Plot first batch element
209
+ >>> nifti_plus.plot(batch_index=None, max_batch_display=4) # Plot first 4
210
+ >>> nifti_plus.plot(ppmlim=(0.5, 4.2)) # Custom ppm range
211
+ """
212
+ from augmentrum.core.nifti_mrs_plus import NIfTI_MRS_Plus
213
+
214
+ if not isinstance(data, NIfTI_MRS_Plus):
215
+ raise TypeError("data must be a NIfTI_MRS_Plus object")
216
+
217
+ # Get list of NIFTI_MRS objects
218
+ nifti_list = data.list()
219
+
220
+ if len(nifti_list) == 0:
221
+ raise ValueError("NIfTI_MRS_Plus object is empty")
222
+
223
+ # Determine ppm limits
224
+ if ppmlim is None and len(nifti_list) > 0:
225
+ first_nifti = nifti_list[0]
226
+ if hasattr(first_nifti, 'nucleus') and FSL_MRS_AVAILABLE:
227
+ nuc_info = constants.nucleus_constants(first_nifti.nucleus[0])
228
+ if nuc_info.ppm_range:
229
+ ppmlim = nuc_info.ppm_range
230
+ else:
231
+ ppmlim = (0.2, 4.2)
232
+ else:
233
+ ppmlim = (0.2, 4.2)
234
+
235
+ # Handle batch indexing
236
+ if batch_index is not None:
237
+ # Plot single batch element
238
+ if batch_index < 0 or batch_index >= len(nifti_list):
239
+ raise IndexError(f"batch_index {batch_index} out of range [0, {len(nifti_list)})")
240
+
241
+ nifti_to_plot = nifti_list[batch_index]
242
+
243
+ if FSL_MRS_AVAILABLE and hasattr(nifti_to_plot, 'plot'):
244
+ # Use native NIfTI-MRS plotting
245
+ fig = nifti_to_plot.plot(
246
+ display_dim=display_dim,
247
+ ppmlim=ppmlim,
248
+ plot_avg=plot_avg,
249
+ legend=legend
250
+ )
251
+ if title:
252
+ fig.suptitle(title, fontsize=14, fontweight='bold')
253
+ elif not title and len(nifti_list) > 1:
254
+ fig.suptitle(f"Batch Element {batch_index+1}/{len(nifti_list)}", fontsize=12)
255
+ return fig
256
+ else:
257
+ # Fallback plotting
258
+ return _plot_single_spectrum(
259
+ nifti_to_plot,
260
+ ppmlim=ppmlim,
261
+ title=title or f"Batch Element {batch_index+1}/{len(nifti_list)}"
262
+ )
263
+
264
+ else:
265
+ # Plot multiple batch elements
266
+ n_to_plot = min(len(nifti_list), max_batch_display)
267
+
268
+ if n_to_plot == 1:
269
+ # Just plot the single element
270
+ return vis_nifti_mrs_plus(
271
+ data,
272
+ display_dim=display_dim,
273
+ ppmlim=ppmlim,
274
+ plot_avg=plot_avg,
275
+ batch_index=0,
276
+ legend=legend,
277
+ title=title
278
+ )
279
+
280
+ # Determine grid layout
281
+ if grid_layout is None:
282
+ if n_to_plot <= 2:
283
+ rows, cols = 1, n_to_plot
284
+ elif n_to_plot <= 4:
285
+ rows, cols = 2, 2
286
+ elif n_to_plot <= 6:
287
+ rows, cols = 2, 3
288
+ elif n_to_plot <= 9:
289
+ rows, cols = 3, 3
290
+ else:
291
+ rows, cols = 4, 3
292
+ else:
293
+ rows, cols = grid_layout
294
+
295
+ # Create figure
296
+ if figsize is None:
297
+ figsize = (5 * cols, 3.5 * rows)
298
+
299
+ fig, axes = plt.subplots(rows, cols, figsize=figsize, squeeze=False)
300
+ axes = axes.flatten()
301
+
302
+ # Plot each batch element
303
+ for i in range(n_to_plot):
304
+ nifti = nifti_list[i]
305
+ ax = axes[i]
306
+
307
+ # Plot spectrum on this axis
308
+ _plot_spectrum_on_axis(
309
+ nifti,
310
+ ax=ax,
311
+ ppmlim=ppmlim,
312
+ title=f"Batch {i+1}",
313
+ legend=False
314
+ )
315
+
316
+ # Hide unused subplots
317
+ for i in range(n_to_plot, len(axes)):
318
+ axes[i].axis('off')
319
+
320
+ # Overall title
321
+ if title:
322
+ fig.suptitle(title, fontsize=16, fontweight='bold', y=0.995)
323
+ else:
324
+ fig.suptitle(
325
+ f"NIfTI_MRS_Plus Batch ({n_to_plot}/{len(nifti_list)} displayed)",
326
+ fontsize=14,
327
+ fontweight='bold',
328
+ y=0.995
329
+ )
330
+
331
+ plt.tight_layout()
332
+ return fig
333
+
334
+
335
+ def _plot_single_spectrum(nifti, ppmlim=(0.2, 4.2), title="MRS Spectrum"):
336
+ """Plot a single NIfTI-MRS spectrum using basic matplotlib."""
337
+ fig, ax = plt.subplots(1, 1, figsize=(10, 4))
338
+ _plot_spectrum_on_axis(nifti, ax, ppmlim, title)
339
+ plt.tight_layout()
340
+ return fig
341
+
342
+
343
+ def _plot_spectrum_on_axis(nifti, ax, ppmlim=(0.2, 4.2), title="", legend=True):
344
+ """Plot spectrum on axis, properly handling raw multi-coil/average data."""
345
+ from fsl_mrs.utils.preproc import nifti_mrs_proc
346
+ from fsl_mrs.core.mrs import MRS
347
+
348
+ # Process data like vis_nifti_mrs does
349
+ processed = nifti
350
+
351
+ # Handle coil combination
352
+ dim_tags = getattr(processed, 'dim_tags', None)
353
+ # Check if dim_tags is iterable (not None, not Mock)
354
+ try:
355
+ has_coil_dim = dim_tags is not None and 'DIM_COIL' in dim_tags
356
+ except TypeError:
357
+ has_coil_dim = False
358
+
359
+ if has_coil_dim and processed.shape[processed.dim_position('DIM_COIL')] > 1:
360
+ processed = nifti_mrs_proc.coilcombine(processed)
361
+
362
+ # Handle all other dimensions
363
+ if dim_tags:
364
+ try:
365
+ for dim in dim_tags:
366
+ if dim is None:
367
+ continue
368
+ if processed.shape[processed.dim_position(dim)] > 1:
369
+ if dim in ('DIM_EDIT', 'DIM_METCYCLE', 'DIM_ISIS'):
370
+ processed = nifti_mrs_proc.subtract(processed, dim=dim)
371
+ else:
372
+ processed = nifti_mrs_proc.average(processed, dim)
373
+ except TypeError:
374
+ # dim_tags is not iterable (e.g., Mock object)
375
+ pass
376
+
377
+ # Create MRS object
378
+ mrs = MRS(
379
+ processed[:].squeeze(),
380
+ bw=processed.bandwidth,
381
+ cf=processed.spectrometer_frequency[0],
382
+ nucleus=processed.nucleus[0]
383
+ )
384
+
385
+ # Get spectrum
386
+ ppm = mrs.getAxes(ppmlim=ppmlim)
387
+ spec = mrs.get_spec(ppmlim=ppmlim)
388
+
389
+ # Plot
390
+ ax.plot(ppm, np.real(spec), 'k-', linewidth=1.0, label='Real' if legend else None)
391
+
392
+ # Formatting
393
+ ax.invert_xaxis()
394
+ if ppmlim is not None:
395
+ ax.set_xlim(ppmlim[1], ppmlim[0])
396
+ ax.set_xlabel("Chemical Shift (ppm)", fontsize=10)
397
+ ax.set_ylabel("Amplitude (a.u.)", fontsize=10)
398
+ if title:
399
+ ax.set_title(title, fontsize=11, fontweight='bold')
400
+ ax.grid(alpha=0.3, linestyle='--', linewidth=0.5)
401
+
402
+ if legend:
403
+ ax.legend(loc='best', fontsize=8)
404
+
405
+
406
+ def plot_batch_comparison(
407
+ nifti_plus,
408
+ indices=None,
409
+ ppmlim=(0.2, 4.2),
410
+ labels=None,
411
+ title="Batch Comparison",
412
+ colors=None,
413
+ alpha=0.7,
414
+ figsize=(12, 5)
415
+ ):
416
+ """
417
+ Plot multiple spectra from a batch overlaid for comparison.
418
+
419
+ Args:
420
+ nifti_plus: NIfTI_MRS_Plus object
421
+ indices: List of batch indices to plot (None = all up to 6)
422
+ ppmlim: PPM limits (min, max)
423
+ labels: List of labels for each spectrum
424
+ title: Plot title
425
+ colors: List of colors for each spectrum
426
+ alpha: Transparency for overlaid spectra
427
+ figsize: Figure size
428
+
429
+ Returns:
430
+ matplotlib figure
431
+
432
+ Example:
433
+ >>> plot_batch_comparison(nifti_plus, indices=[0, 1, 2])
434
+ >>> plot_batch_comparison(nifti_plus, labels=['Original', 'Augmented 1', 'Augmented 2'])
435
+ """
436
+ from augmentrum.core.nifti_mrs_plus import NIfTI_MRS_Plus
437
+
438
+ if not isinstance(nifti_plus, NIfTI_MRS_Plus):
439
+ raise TypeError("nifti_plus must be a NIfTI_MRS_Plus object")
440
+
441
+ nifti_list = nifti_plus.list()
442
+
443
+ if indices is None:
444
+ indices = list(range(min(6, len(nifti_list))))
445
+
446
+ if labels is None:
447
+ labels = [f"Spectrum {i+1}" for i in indices]
448
+
449
+ if colors is None:
450
+ colors = plt.cm.tab10(np.linspace(0, 1, len(indices)))
451
+
452
+ from fsl_mrs.utils.preproc import nifti_mrs_proc
453
+ from fsl_mrs.core.mrs import MRS
454
+ from fsl_mrs.utils.plotting import plot_spectra
455
+
456
+ # Process each spectrum properly
457
+ mrs_list = []
458
+ for idx in indices:
459
+ if idx >= len(nifti_list):
460
+ warnings.warn(f"Index {idx} out of range, skipping")
461
+ continue
462
+
463
+ nifti = nifti_list[idx]
464
+
465
+ # Process like vis_nifti_mrs
466
+ processed = nifti
467
+ dim_tags = getattr(processed, 'dim_tags', None)
468
+ try:
469
+ has_coil_dim = dim_tags is not None and 'DIM_COIL' in dim_tags
470
+ except TypeError:
471
+ has_coil_dim = False
472
+
473
+ if has_coil_dim and processed.shape[processed.dim_position('DIM_COIL')] > 1:
474
+ processed = nifti_mrs_proc.coilcombine(processed)
475
+
476
+ if dim_tags:
477
+ try:
478
+ for dim in dim_tags:
479
+ if dim is None:
480
+ continue
481
+ if processed.shape[processed.dim_position(dim)] > 1:
482
+ if dim in ('DIM_EDIT', 'DIM_METCYCLE', 'DIM_ISIS'):
483
+ processed = nifti_mrs_proc.subtract(processed, dim=dim)
484
+ else:
485
+ processed = nifti_mrs_proc.average(processed, dim)
486
+ except TypeError:
487
+ pass
488
+
489
+ # Create MRS object
490
+ mrs = MRS(
491
+ processed[:].squeeze(),
492
+ bw=processed.bandwidth,
493
+ cf=processed.spectrometer_frequency[0],
494
+ nucleus=processed.nucleus[0]
495
+ )
496
+ mrs_list.append(mrs)
497
+
498
+ # Use FSL-MRS plot_spectra for overlay in ONE figure
499
+ fig = plot_spectra(mrs_list, ppmlim=ppmlim, legend=True)
500
+
501
+ # Customize
502
+ if fig and len(fig.axes) > 0:
503
+ ax = fig.axes[0]
504
+ ax.set_title(title, fontsize=14, fontweight='bold')
505
+ if len(labels) == len(mrs_list):
506
+ ax.legend(labels, loc='best', fontsize=10)
507
+
508
+ plt.tight_layout()
509
+ plt.show()
510
+ return fig
511
+
512
+
513
+ def plot_batch_grid_detailed(
514
+ nifti_plus,
515
+ max_display=9,
516
+ ppmlim=(0.2, 4.2),
517
+ title="Batch Grid",
518
+ show_metabolites=False,
519
+ figsize=None
520
+ ):
521
+ """
522
+ Plot batch elements in a detailed grid with optional metabolite regions.
523
+
524
+ Args:
525
+ nifti_plus: NIfTI_MRS_Plus object
526
+ max_display: Maximum number of spectra to display
527
+ ppmlim: PPM limits
528
+ title: Overall title
529
+ show_metabolites: If True, highlight common metabolite regions
530
+ figsize: Figure size (auto if None)
531
+
532
+ Returns:
533
+ matplotlib figure
534
+ """
535
+ from augmentrum.core.nifti_mrs_plus import NIfTI_MRS_Plus
536
+
537
+ if not isinstance(nifti_plus, NIfTI_MRS_Plus):
538
+ raise TypeError("nifti_plus must be a NIfTI_MRS_Plus object")
539
+
540
+ nifti_list = nifti_plus.list()
541
+ n_to_plot = min(len(nifti_list), max_display)
542
+
543
+ # Grid layout
544
+ if n_to_plot <= 3:
545
+ rows, cols = 1, n_to_plot
546
+ elif n_to_plot <= 6:
547
+ rows, cols = 2, 3
548
+ else:
549
+ rows, cols = 3, 3
550
+
551
+ if figsize is None:
552
+ figsize = (5 * cols, 3.5 * rows)
553
+
554
+ fig, axes = plt.subplots(rows, cols, figsize=figsize, squeeze=False)
555
+ axes = axes.flatten()
556
+
557
+ # Metabolite regions (1H MRS)
558
+ metabolite_regions = {
559
+ 'NAA': (1.9, 2.1, '#E74C3C'),
560
+ 'Cr': (2.9, 3.1, '#3498DB'),
561
+ 'Cho': (3.1, 3.3, '#2ECC71'),
562
+ }
563
+
564
+ from fsl_mrs.utils.preproc import nifti_mrs_proc
565
+ from fsl_mrs.core.mrs import MRS
566
+
567
+ for i in range(n_to_plot):
568
+ ax = axes[i]
569
+ nifti = nifti_list[i]
570
+
571
+ # Process like vis_nifti_mrs
572
+ processed = nifti
573
+ dim_tags = getattr(processed, 'dim_tags', None)
574
+ try:
575
+ has_coil_dim = dim_tags is not None and 'DIM_COIL' in dim_tags
576
+ except TypeError:
577
+ has_coil_dim = False
578
+
579
+ if has_coil_dim and processed.shape[processed.dim_position('DIM_COIL')] > 1:
580
+ processed = nifti_mrs_proc.coilcombine(processed)
581
+
582
+ if dim_tags:
583
+ try:
584
+ for dim in dim_tags:
585
+ if dim is None:
586
+ continue
587
+ if processed.shape[processed.dim_position(dim)] > 1:
588
+ if dim in ('DIM_EDIT', 'DIM_METCYCLE', 'DIM_ISIS'):
589
+ processed = nifti_mrs_proc.subtract(processed, dim=dim)
590
+ else:
591
+ processed = nifti_mrs_proc.average(processed, dim)
592
+ except TypeError:
593
+ pass
594
+
595
+ # Create MRS object and get spectrum
596
+ mrs = MRS(
597
+ processed[:].squeeze(),
598
+ bw=processed.bandwidth,
599
+ cf=processed.spectrometer_frequency[0],
600
+ nucleus=processed.nucleus[0]
601
+ )
602
+
603
+ ppm = mrs.getAxes(ppmlim=ppmlim)
604
+ spec = mrs.get_spec(ppmlim=ppmlim)
605
+
606
+ # Plot
607
+ ax.plot(ppm, np.real(spec), 'k-', linewidth=1.0)
608
+
609
+ # Highlight metabolites
610
+ if show_metabolites:
611
+ for name, (ppm_min, ppm_max, color) in metabolite_regions.items():
612
+ ax.axvspan(ppm_min, ppm_max, alpha=0.15, color=color)
613
+
614
+ ax.invert_xaxis()
615
+ if ppmlim:
616
+ ax.set_xlim(ppmlim[1], ppmlim[0])
617
+ ax.set_ylabel("Amplitude", fontsize=9)
618
+ ax.set_title(f"Sample {i+1}", fontsize=10, fontweight='bold')
619
+ ax.grid(alpha=0.3, linestyle='--', linewidth=0.5)
620
+
621
+ # Only show x-label on bottom row
622
+ if i >= (rows - 1) * cols:
623
+ ax.set_xlabel("Chemical Shift (ppm)", fontsize=9)
624
+
625
+ # Hide unused subplots
626
+ for i in range(n_to_plot, len(axes)):
627
+ axes[i].axis('off')
628
+
629
+ fig.suptitle(title, fontsize=14, fontweight='bold', y=0.995)
630
+ plt.tight_layout()
631
+ plt.show()
632
+ return fig
633
+
634
+
635
+ # Convenience function for interactive use
636
+ def quick_plot(nifti_plus, index=0, ppmlim=(0.2, 4.2)):
637
+ """
638
+ Quick plot of a single spectrum from batch.
639
+
640
+ Args:
641
+ nifti_plus: NIfTI_MRS_Plus object
642
+ index: Batch index to plot
643
+ ppmlim: PPM range
644
+
645
+ Example:
646
+ >>> quick_plot(nifti_plus, 0) # Plot first spectrum
647
+ """
648
+ return vis_nifti_mrs_plus(nifti_plus, batch_index=index, ppmlim=ppmlim)
649
+
650
+
651
+