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,334 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# artificial_peaks.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: K. C. Igwe (kci2104@columbia.edu) #
|
|
6
|
+
# J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
7
|
+
# #
|
|
8
|
+
# Created: 2026-02-07 #
|
|
9
|
+
# #
|
|
10
|
+
# Purpose: Adds artificial contaminant peaks at specified frequencies in MRS data. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from typing import Optional, List, Dict
|
|
16
|
+
from augmentrum.core.base_module import BaseModule
|
|
17
|
+
from augmentrum.core.nifti_mrs_plus import Backend
|
|
18
|
+
from augmentrum.core.nifti_mrs_plus import Backend, NIfTI_MRS_Plus
|
|
19
|
+
from augmentrum.utils.tensor_ops import fft, ifft, fftshift, ifftshift, to_numpy, match_backend
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ArtificialPeaks(BaseModule):
|
|
23
|
+
"""
|
|
24
|
+
Add artificial contaminant peaks to MRS data.
|
|
25
|
+
|
|
26
|
+
Adds Lorentzian, Gaussian, or Voigt peaks at specified ppm positions
|
|
27
|
+
in the frequency domain to simulate contamination or additional metabolites.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
peaks : list of dicts
|
|
32
|
+
Each dict contains:
|
|
33
|
+
- ppm: Peak position in ppm
|
|
34
|
+
- amp: Amplitude as fraction of main spectrum peak (0.0-1.0)
|
|
35
|
+
- phase_deg: Complex phase in degrees
|
|
36
|
+
- lb_hz: Lorentzian FWHM in Hz (0 for none)
|
|
37
|
+
- gb_hz: Gaussian FWHM in Hz (0 for none)
|
|
38
|
+
If both lb_hz and gb_hz > 0, creates Voigt peak
|
|
39
|
+
ref_ppm : float
|
|
40
|
+
Reference ppm for axis calculation (default: 4.7)
|
|
41
|
+
amp_mode : str
|
|
42
|
+
How to calculate reference amplitude: 'real' or 'abs' (default: 'real')
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> # Add single Lorentzian peak at 3.0 ppm
|
|
47
|
+
>>> peaks = ArtificialPeaks(peaks=[
|
|
48
|
+
... {'ppm': 3.0, 'amp': 0.1, 'phase_deg': 0.0, 'lb_hz': 5.0, 'gb_hz': 0.0}
|
|
49
|
+
... ])
|
|
50
|
+
>>> result_data, _ = peaks(nifti_plus, None)
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
54
|
+
|
|
55
|
+
def __init__(self, peaks: List[Dict] = None, ref_ppm: float = 4.7, amp_mode: str = 'real'):
|
|
56
|
+
"""Initialize artificial peaks module."""
|
|
57
|
+
if peaks is None:
|
|
58
|
+
peaks = [{'ppm': 3.0, 'amp': 0.05, 'phase_deg': 0.0, 'lb_hz': 5.0, 'gb_hz': 0.0}]
|
|
59
|
+
|
|
60
|
+
super().__init__(peaks=peaks, ref_ppm=ref_ppm, amp_mode=amp_mode)
|
|
61
|
+
|
|
62
|
+
self.peaks = peaks
|
|
63
|
+
self.ref_ppm = ref_ppm
|
|
64
|
+
self.amp_mode = amp_mode
|
|
65
|
+
|
|
66
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
67
|
+
"""
|
|
68
|
+
Add artificial peaks to list of NIFTI_MRS objects.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
data_list: List of NIFTI_MRS objects
|
|
72
|
+
water_list: Optional list of water reference NIFTI_MRS objects
|
|
73
|
+
**kwargs: Additional arguments
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
77
|
+
"""
|
|
78
|
+
processed_data = []
|
|
79
|
+
|
|
80
|
+
for nifti in data_list:
|
|
81
|
+
# Get FID data
|
|
82
|
+
fid = nifti[:]
|
|
83
|
+
|
|
84
|
+
# Get parameters
|
|
85
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
86
|
+
sf_mhz = nifti.spectrometer_frequency[0]
|
|
87
|
+
|
|
88
|
+
# Add peaks
|
|
89
|
+
fid_with_peaks = self._add_peaks(fid, sw_hz, sf_mhz)
|
|
90
|
+
|
|
91
|
+
# Update NIFTI_MRS data
|
|
92
|
+
nifti[:] = fid_with_peaks
|
|
93
|
+
processed_data.append(nifti)
|
|
94
|
+
|
|
95
|
+
return processed_data, water_list
|
|
96
|
+
|
|
97
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
98
|
+
"""
|
|
99
|
+
Add artificial peaks to tensor/array data (**any backend**).
|
|
100
|
+
|
|
101
|
+
FFT/IFFT are done **natively** via ``tensor_ops`` (preserves gradients).
|
|
102
|
+
Peak shapes and amplitude references are computed in NumPy then
|
|
103
|
+
promoted to the target backend with ``match_backend``.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
107
|
+
water_array: Optional water reference (unchanged)
|
|
108
|
+
backend: Backend enum (unused)
|
|
109
|
+
**kwargs: Must contain ``'sw_hz'`` and ``'sf_mhz'``
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Tuple of (processed_data, water_array)
|
|
113
|
+
"""
|
|
114
|
+
sw_hz = kwargs.get('sw_hz')
|
|
115
|
+
sf_mhz = kwargs.get('sf_mhz')
|
|
116
|
+
if sw_hz is None or sf_mhz is None:
|
|
117
|
+
raise ValueError("ArtificialPeaks.process_tensor requires 'sw_hz' and 'sf_mhz' in kwargs")
|
|
118
|
+
|
|
119
|
+
N = data_array.shape[-1]
|
|
120
|
+
n_batch = int(np.prod(data_array.shape[:-1]))
|
|
121
|
+
|
|
122
|
+
# 1. Spectrum (backend-native FFT — preserves gradients)
|
|
123
|
+
spec = fftshift(ifft(data_array)) # shape = original_shape
|
|
124
|
+
|
|
125
|
+
# 2. ppm axis (numpy — coordinate, no gradients needed)
|
|
126
|
+
dt = 1.0 / float(sw_hz)
|
|
127
|
+
freq_hz = np.fft.fftshift(np.fft.fftfreq(N, d=dt))
|
|
128
|
+
ppm = self.ref_ppm - freq_hz / float(sf_mhz)
|
|
129
|
+
|
|
130
|
+
# 3. Amplitude reference and peak shapes (numpy, per-FID scalar pull)
|
|
131
|
+
spec_np = to_numpy(spec).reshape(n_batch, N)
|
|
132
|
+
contam_np = np.zeros((n_batch, N), dtype=np.complex128)
|
|
133
|
+
|
|
134
|
+
for i in range(n_batch):
|
|
135
|
+
if self.amp_mode == 'abs':
|
|
136
|
+
peak_ref = float(np.max(np.abs(spec_np[i]))) or 1.0
|
|
137
|
+
else:
|
|
138
|
+
peak_ref = float(np.max(np.abs(spec_np[i].real))) or 1.0
|
|
139
|
+
|
|
140
|
+
contam = np.zeros(N, dtype=np.complex128)
|
|
141
|
+
for p in self.peaks:
|
|
142
|
+
ppm0 = float(p['ppm'])
|
|
143
|
+
amp_frac = float(p.get('amp', p.get('amplitude', 0.05)))
|
|
144
|
+
phase_deg = float(p.get('phase_deg', 0.0))
|
|
145
|
+
lb_hz = float(p.get('lb_hz', 0.0))
|
|
146
|
+
gb_hz = float(p.get('gb_hz', 0.0))
|
|
147
|
+
|
|
148
|
+
if lb_hz > 0 and gb_hz > 0:
|
|
149
|
+
shape = self._voigt(ppm, ppm0, lb_hz, gb_hz, sf_mhz)
|
|
150
|
+
elif lb_hz > 0:
|
|
151
|
+
shape = self._lorentzian(ppm, ppm0, lb_hz, sf_mhz)
|
|
152
|
+
elif gb_hz > 0:
|
|
153
|
+
shape = self._gaussian(ppm, ppm0, gb_hz, sf_mhz)
|
|
154
|
+
else:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
contam += (amp_frac * peak_ref) * shape * np.exp(1j * np.deg2rad(phase_deg))
|
|
158
|
+
|
|
159
|
+
contam_np[i] = contam
|
|
160
|
+
|
|
161
|
+
contam_np = contam_np.reshape(data_array.shape)
|
|
162
|
+
|
|
163
|
+
# 4. Add contamination in spectral domain (backend-native)
|
|
164
|
+
spec_aug = spec + match_backend(contam_np, spec)
|
|
165
|
+
|
|
166
|
+
# 5. Back to FID (backend-native IFFT)
|
|
167
|
+
return fft(ifftshift(spec_aug)), water_array
|
|
168
|
+
|
|
169
|
+
def _add_peaks(self, fid: np.ndarray, sw_hz: float, sf_mhz: float) -> np.ndarray:
|
|
170
|
+
"""
|
|
171
|
+
Add artificial peaks to FID data.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
fid: Input FID data (shape: x, y, z, points, [coils], [averages], ...)
|
|
175
|
+
sw_hz: Spectral width in Hz
|
|
176
|
+
sf_mhz: Spectrometer frequency in MHz
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
FID with peaks added
|
|
180
|
+
"""
|
|
181
|
+
# In MRS data, dimension 3 (index 3) is typically the FID points
|
|
182
|
+
# Shape is typically: (x, y, z, points, coils, averages, ...)
|
|
183
|
+
original_shape = fid.shape
|
|
184
|
+
|
|
185
|
+
# Find the FID points dimension (should be dim 3, index 3)
|
|
186
|
+
# For standard MRS: shape is (1, 1, 1, points, coils, averages)
|
|
187
|
+
if len(original_shape) >= 4:
|
|
188
|
+
# FID points are at index 3 (4th dimension)
|
|
189
|
+
n_points = original_shape[3]
|
|
190
|
+
|
|
191
|
+
# Move FID dimension to the end for easier processing
|
|
192
|
+
# From: (x, y, z, points, coils, avg) -> (x, y, z, coils, avg, points)
|
|
193
|
+
fid_moved = np.moveaxis(fid, 3, -1)
|
|
194
|
+
moved_shape = fid_moved.shape
|
|
195
|
+
|
|
196
|
+
# Reshape to 2D: (all_other_dims, points)
|
|
197
|
+
fid_2d = fid_moved.reshape(-1, n_points)
|
|
198
|
+
result = np.zeros_like(fid_2d)
|
|
199
|
+
|
|
200
|
+
# Process each FID
|
|
201
|
+
for i in range(fid_2d.shape[0]):
|
|
202
|
+
fid_1d = fid_2d[i]
|
|
203
|
+
fid_with_peaks = self._add_peaks_1d(fid_1d, sw_hz, sf_mhz)
|
|
204
|
+
result[i] = fid_with_peaks
|
|
205
|
+
|
|
206
|
+
# Reshape back and move FID dimension back to position 3
|
|
207
|
+
result_moved = result.reshape(moved_shape)
|
|
208
|
+
result_final = np.moveaxis(result_moved, -1, 3)
|
|
209
|
+
|
|
210
|
+
return result_final
|
|
211
|
+
else:
|
|
212
|
+
# Simple 1D case or legacy format - last dimension is FID points
|
|
213
|
+
N = original_shape[-1]
|
|
214
|
+
fid_2d = fid.reshape(-1, N)
|
|
215
|
+
result = np.zeros_like(fid_2d)
|
|
216
|
+
|
|
217
|
+
for i in range(fid_2d.shape[0]):
|
|
218
|
+
fid_1d = fid_2d[i]
|
|
219
|
+
fid_with_peaks = self._add_peaks_1d(fid_1d, sw_hz, sf_mhz)
|
|
220
|
+
result[i] = fid_with_peaks
|
|
221
|
+
|
|
222
|
+
return result.reshape(original_shape)
|
|
223
|
+
|
|
224
|
+
def _add_peaks_1d(self, fid: np.ndarray, sw_hz: float, sf_mhz: float) -> np.ndarray:
|
|
225
|
+
"""
|
|
226
|
+
Add peaks to 1D FID.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
fid: 1D FID data
|
|
230
|
+
sw_hz: Spectral width in Hz
|
|
231
|
+
sf_mhz: Spectrometer frequency in MHz
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
FID with peaks
|
|
235
|
+
"""
|
|
236
|
+
# IFFT to frequency domain (correct MRS convention)
|
|
237
|
+
spec = np.fft.fftshift(np.fft.ifft(fid))
|
|
238
|
+
|
|
239
|
+
# Create ppm axis
|
|
240
|
+
n = fid.size
|
|
241
|
+
dt = 1.0 / float(sw_hz)
|
|
242
|
+
freq_hz = np.fft.fftshift(np.fft.fftfreq(n, d=dt))
|
|
243
|
+
# Legacy formula: ppm_offset = freq_hz_offset / larmor_freq_MHz
|
|
244
|
+
ppm = self.ref_ppm - freq_hz / float(sf_mhz)
|
|
245
|
+
|
|
246
|
+
# Calculate reference amplitude
|
|
247
|
+
if self.amp_mode == 'abs':
|
|
248
|
+
peak_ref = np.max(np.abs(spec)) or 1.0
|
|
249
|
+
else:
|
|
250
|
+
peak_ref = np.max(np.abs(spec.real)) or 1.0
|
|
251
|
+
|
|
252
|
+
# Generate contamination
|
|
253
|
+
contam = np.zeros_like(spec, dtype=np.complex128)
|
|
254
|
+
|
|
255
|
+
for p in self.peaks:
|
|
256
|
+
ppm0 = float(p['ppm'])
|
|
257
|
+
# Support both 'amp' and 'amplitude' keys
|
|
258
|
+
amp_frac = float(p.get('amp', p.get('amplitude', 0.05)))
|
|
259
|
+
phase_deg = float(p.get('phase_deg', 0.0))
|
|
260
|
+
lb_hz = float(p.get('lb_hz', 0.0))
|
|
261
|
+
gb_hz = float(p.get('gb_hz', 0.0))
|
|
262
|
+
|
|
263
|
+
# Generate peak shape
|
|
264
|
+
if lb_hz > 0 and gb_hz > 0:
|
|
265
|
+
shape = self._voigt(ppm, ppm0, lb_hz, gb_hz, sf_mhz)
|
|
266
|
+
elif lb_hz > 0:
|
|
267
|
+
shape = self._lorentzian(ppm, ppm0, lb_hz, sf_mhz)
|
|
268
|
+
elif gb_hz > 0:
|
|
269
|
+
shape = self._gaussian(ppm, ppm0, gb_hz, sf_mhz)
|
|
270
|
+
else:
|
|
271
|
+
continue # No width, skip
|
|
272
|
+
|
|
273
|
+
# Add to contamination
|
|
274
|
+
phase = np.exp(1j * np.deg2rad(phase_deg))
|
|
275
|
+
peak_contam = (amp_frac * peak_ref) * shape * phase
|
|
276
|
+
contam += peak_contam
|
|
277
|
+
|
|
278
|
+
# Add contamination to spectrum
|
|
279
|
+
spec_aug = spec + contam
|
|
280
|
+
|
|
281
|
+
# Back to time domain (FFT to invert IFFT)
|
|
282
|
+
return np.fft.fft(np.fft.ifftshift(spec_aug))
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _lorentzian(ppm: np.ndarray, ppm0: float, fwhm_hz: float, sf_mhz: float) -> np.ndarray:
|
|
286
|
+
"""Generate Lorentzian peak shape."""
|
|
287
|
+
if fwhm_hz <= 0:
|
|
288
|
+
return np.zeros_like(ppm)
|
|
289
|
+
|
|
290
|
+
# Convert Hz to ppm: divide by sf_mhz (which is in MHz)
|
|
291
|
+
# This gives: Hz / MHz = ppm (since 1 MHz = 1e6 Hz, the ratio is in ppm)
|
|
292
|
+
fwhm_ppm = fwhm_hz / float(sf_mhz)
|
|
293
|
+
x = ppm - ppm0
|
|
294
|
+
hw = 0.5 * fwhm_ppm
|
|
295
|
+
L = (hw / (x**2 + hw**2)) / np.pi
|
|
296
|
+
return L / (L.max() if L.max() > 0 else 1.0)
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _gaussian(ppm: np.ndarray, ppm0: float, fwhm_hz: float, sf_mhz: float) -> np.ndarray:
|
|
300
|
+
"""Generate Gaussian peak shape."""
|
|
301
|
+
if fwhm_hz <= 0:
|
|
302
|
+
return np.zeros_like(ppm)
|
|
303
|
+
|
|
304
|
+
# Convert Hz to ppm: divide by sf_mhz (which is in MHz)
|
|
305
|
+
fwhm_ppm = fwhm_hz / float(sf_mhz)
|
|
306
|
+
x = ppm - ppm0
|
|
307
|
+
G = np.exp(-4.0 * np.log(2.0) * (x**2) / (fwhm_ppm**2))
|
|
308
|
+
return G
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def _voigt(ppm: np.ndarray, ppm0: float, lb_hz: float, gb_hz: float, sf_mhz: float) -> np.ndarray:
|
|
312
|
+
"""Generate Voigt peak shape (product of Lorentzian and Gaussian)."""
|
|
313
|
+
if lb_hz <= 0 and gb_hz <= 0:
|
|
314
|
+
return np.zeros_like(ppm)
|
|
315
|
+
|
|
316
|
+
# Don't call the normalized functions - build them here
|
|
317
|
+
if lb_hz > 0:
|
|
318
|
+
fwhm_ppm_l = lb_hz / float(sf_mhz)
|
|
319
|
+
x = ppm - ppm0
|
|
320
|
+
hw = 0.5 * fwhm_ppm_l
|
|
321
|
+
L = (hw / (x**2 + hw**2)) / np.pi
|
|
322
|
+
else:
|
|
323
|
+
L = 1.0
|
|
324
|
+
|
|
325
|
+
if gb_hz > 0:
|
|
326
|
+
fwhm_ppm_g = gb_hz / float(sf_mhz)
|
|
327
|
+
x = ppm - ppm0
|
|
328
|
+
G = np.exp(-4.0 * np.log(2.0) * (x**2) / (fwhm_ppm_g**2))
|
|
329
|
+
else:
|
|
330
|
+
G = 1.0
|
|
331
|
+
|
|
332
|
+
V = L * G
|
|
333
|
+
vmax = V.max() if isinstance(V, np.ndarray) else V
|
|
334
|
+
return V / (vmax if vmax > 0 else 1.0)
|