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,278 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# eddy_current.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: Implements water-derived and synthetic eddy current phase distortions for MRS data. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from typing import Optional, List
|
|
16
|
+
from scipy.signal import butter, filtfilt
|
|
17
|
+
from augmentrum.core.base_module import BaseModule
|
|
18
|
+
from augmentrum.core.nifti_mrs_plus import Backend
|
|
19
|
+
from augmentrum.core.nifti_mrs_plus import Backend, NIfTI_MRS_Plus
|
|
20
|
+
from augmentrum.utils.tensor_ops import to_numpy, match_backend
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EddyCurrent(BaseModule):
|
|
24
|
+
"""
|
|
25
|
+
Add eddy current phase distortions to MRS data.
|
|
26
|
+
|
|
27
|
+
Supports two modes:
|
|
28
|
+
- 'synthetic': Generate synthetic eddy current phase trajectory
|
|
29
|
+
- 'water': Derive eddy current phase from water reference
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
mode : str
|
|
34
|
+
Eddy current mode: 'synthetic' or 'water' (default: 'synthetic')
|
|
35
|
+
|
|
36
|
+
**Synthetic Mode Parameters:**
|
|
37
|
+
std_rad : float
|
|
38
|
+
Standard deviation of phase noise in radians (default: 0.6)
|
|
39
|
+
lp_cut_hz : float
|
|
40
|
+
Low-pass filter cutoff frequency in Hz (default: 25.0)
|
|
41
|
+
seed : int, optional
|
|
42
|
+
Random seed for reproducibility
|
|
43
|
+
|
|
44
|
+
**Common Parameters:**
|
|
45
|
+
strength : float
|
|
46
|
+
Strength multiplier for eddy current effect (default: 1.0)
|
|
47
|
+
remove_linear : bool
|
|
48
|
+
Remove linear trend from phase (default: True)
|
|
49
|
+
|
|
50
|
+
**Water Mode Parameters:**
|
|
51
|
+
Uses water reference from water_list parameter in __call__
|
|
52
|
+
lp_cut_hz : float
|
|
53
|
+
Low-pass filter cutoff for water-derived phase (default: 20.0)
|
|
54
|
+
|
|
55
|
+
Examples
|
|
56
|
+
--------
|
|
57
|
+
>>> # Synthetic eddy current
|
|
58
|
+
>>> ec = EddyCurrent(mode='synthetic', std_rad=0.8, lp_cut_hz=30.0)
|
|
59
|
+
>>> result_data, _ = ec(nifti_plus, None)
|
|
60
|
+
>>>
|
|
61
|
+
>>> # Water-derived eddy current
|
|
62
|
+
>>> ec = EddyCurrent(mode='water', lp_cut_hz=20.0, strength=1.0)
|
|
63
|
+
>>> result_data, result_water = ec(nifti_plus, water_plus)
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
SUPPORTED_BACKENDS = [] # Supports all backends via process_tensor
|
|
67
|
+
|
|
68
|
+
def __init__(self, mode: str = 'synthetic',
|
|
69
|
+
std_rad: float = 0.6, lp_cut_hz: float = 25.0,
|
|
70
|
+
strength: float = 1.0, remove_linear: bool = True, seed: Optional[int] = None):
|
|
71
|
+
"""Initialize eddy current module."""
|
|
72
|
+
super().__init__(mode=mode, std_rad=std_rad, lp_cut_hz=lp_cut_hz,
|
|
73
|
+
strength=strength, remove_linear=remove_linear, seed=seed)
|
|
74
|
+
|
|
75
|
+
self.mode = mode.lower()
|
|
76
|
+
self.std_rad = std_rad
|
|
77
|
+
self.lp_cut_hz = lp_cut_hz
|
|
78
|
+
self.strength = strength
|
|
79
|
+
self.remove_linear = remove_linear
|
|
80
|
+
self.seed = seed
|
|
81
|
+
|
|
82
|
+
if self.mode not in ['synthetic', 'water']:
|
|
83
|
+
raise ValueError(f"mode must be 'synthetic' or 'water', got '{mode}'")
|
|
84
|
+
|
|
85
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
86
|
+
"""
|
|
87
|
+
Add eddy current distortion to list of NIFTI_MRS objects.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
data_list: List of NIFTI_MRS objects
|
|
91
|
+
water_list: Optional list of water reference NIFTI_MRS objects (required for 'water' mode)
|
|
92
|
+
**kwargs: Additional arguments
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
96
|
+
"""
|
|
97
|
+
if self.mode == 'water' and water_list is None:
|
|
98
|
+
raise ValueError("Water reference required for 'water' mode eddy current")
|
|
99
|
+
|
|
100
|
+
processed_data = []
|
|
101
|
+
|
|
102
|
+
for idx, nifti in enumerate(data_list):
|
|
103
|
+
# Get FID data
|
|
104
|
+
fid = nifti[:]
|
|
105
|
+
|
|
106
|
+
# Get spectral width
|
|
107
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
108
|
+
|
|
109
|
+
# Get water reference if available
|
|
110
|
+
water_fid = None
|
|
111
|
+
if water_list is not None and idx < len(water_list):
|
|
112
|
+
water_fid = water_list[idx][:]
|
|
113
|
+
|
|
114
|
+
# Apply eddy current
|
|
115
|
+
fid_with_ec = self._add_eddy_current(fid, sw_hz, water_fid)
|
|
116
|
+
|
|
117
|
+
# Update NIFTI_MRS data
|
|
118
|
+
nifti[:] = fid_with_ec
|
|
119
|
+
processed_data.append(nifti)
|
|
120
|
+
|
|
121
|
+
return processed_data, water_list
|
|
122
|
+
|
|
123
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
124
|
+
"""
|
|
125
|
+
Add eddy current distortion to tensor/array data (**any backend**).
|
|
126
|
+
|
|
127
|
+
The phase trajectory is generated in NumPy (uses SciPy butter filter),
|
|
128
|
+
then applied as a complex phasor multiplication which stays in the
|
|
129
|
+
native backend — ``data * match_backend(phasor, data)``.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
133
|
+
water_array: Optional water reference tensor
|
|
134
|
+
backend: Backend enum (unused)
|
|
135
|
+
**kwargs: Must contain ``'sw_hz'`` (spectral width in Hz)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Tuple of (processed_data, water_array)
|
|
139
|
+
"""
|
|
140
|
+
sw_hz = kwargs.get('sw_hz')
|
|
141
|
+
if sw_hz is None:
|
|
142
|
+
raise ValueError("EddyCurrent.process_tensor requires 'sw_hz' in kwargs")
|
|
143
|
+
|
|
144
|
+
if self.mode == 'water' and water_array is None:
|
|
145
|
+
raise ValueError("Water reference required for 'water' mode eddy current")
|
|
146
|
+
|
|
147
|
+
original_shape = data_array.shape
|
|
148
|
+
N = original_shape[-1]
|
|
149
|
+
n_batch = int(np.prod(original_shape[:-1]))
|
|
150
|
+
|
|
151
|
+
# Build per-FID complex phasors in NumPy (scipy-backed, data-independent for synthetic)
|
|
152
|
+
phasors_np = np.ones((n_batch, N), dtype=np.complex128)
|
|
153
|
+
|
|
154
|
+
if self.mode == 'water':
|
|
155
|
+
water_np = to_numpy(water_array).reshape(-1, N)
|
|
156
|
+
for i in range(n_batch):
|
|
157
|
+
w1d = water_np[min(i, water_np.shape[0] - 1)]
|
|
158
|
+
phi = self._ec_phase_from_water(w1d, sw_hz)
|
|
159
|
+
phasors_np[i] = np.exp(1j * self.strength * phi)
|
|
160
|
+
else: # synthetic — no data dependency
|
|
161
|
+
for i in range(n_batch):
|
|
162
|
+
phi = self._synth_ec_phase(N, sw_hz)
|
|
163
|
+
phasors_np[i] = np.exp(1j * self.strength * phi)
|
|
164
|
+
|
|
165
|
+
phasors_np = phasors_np.reshape(original_shape)
|
|
166
|
+
|
|
167
|
+
# Apply phasor: backend-native multiply (preserves gradients for data)
|
|
168
|
+
return data_array * match_backend(phasors_np, data_array), water_array
|
|
169
|
+
|
|
170
|
+
def _add_eddy_current(self, fid: np.ndarray, sw_hz: float, water_fid: Optional[np.ndarray] = None) -> np.ndarray:
|
|
171
|
+
"""
|
|
172
|
+
Add eddy current to FID data.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
fid: Input FID data
|
|
176
|
+
sw_hz: Spectral width in Hz
|
|
177
|
+
water_fid: Optional water reference FID (for 'water' mode)
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
FID with eddy current
|
|
181
|
+
"""
|
|
182
|
+
# Work with last dimension (FID points)
|
|
183
|
+
original_shape = fid.shape
|
|
184
|
+
N = original_shape[-1]
|
|
185
|
+
|
|
186
|
+
# Reshape to 2D for processing
|
|
187
|
+
fid_2d = fid.reshape(-1, N)
|
|
188
|
+
result = np.zeros_like(fid_2d)
|
|
189
|
+
|
|
190
|
+
# If water mode, also reshape water FID
|
|
191
|
+
if self.mode == 'water' and water_fid is not None:
|
|
192
|
+
water_2d = water_fid.reshape(-1, N)
|
|
193
|
+
else:
|
|
194
|
+
water_2d = None
|
|
195
|
+
|
|
196
|
+
# Process each FID
|
|
197
|
+
for i in range(fid_2d.shape[0]):
|
|
198
|
+
fid_1d = fid_2d[i]
|
|
199
|
+
|
|
200
|
+
# Generate phase distortion based on mode
|
|
201
|
+
if self.mode == 'water' and water_2d is not None:
|
|
202
|
+
# Extract phase from water reference
|
|
203
|
+
water_1d = water_2d[min(i, water_2d.shape[0] - 1)] # Handle different sizes
|
|
204
|
+
phi_ec = self._ec_phase_from_water(water_1d, sw_hz)
|
|
205
|
+
else:
|
|
206
|
+
# Generate synthetic phase
|
|
207
|
+
phi_ec = self._synth_ec_phase(N, sw_hz)
|
|
208
|
+
|
|
209
|
+
# Apply phase distortion
|
|
210
|
+
fid_with_ec = fid_1d * np.exp(1j * self.strength * phi_ec)
|
|
211
|
+
result[i] = fid_with_ec
|
|
212
|
+
|
|
213
|
+
# Reshape back to original shape
|
|
214
|
+
return result.reshape(original_shape)
|
|
215
|
+
|
|
216
|
+
def _ec_phase_from_water(self, fid_water: np.ndarray, sw_hz: float) -> np.ndarray:
|
|
217
|
+
"""
|
|
218
|
+
Extract eddy current phase trajectory from water reference.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
fid_water: Water reference FID
|
|
222
|
+
sw_hz: Spectral width in Hz
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Phase values in radians
|
|
226
|
+
"""
|
|
227
|
+
fid_water = np.asarray(fid_water)
|
|
228
|
+
N = fid_water.size
|
|
229
|
+
t = np.arange(N, dtype=float) / float(sw_hz)
|
|
230
|
+
|
|
231
|
+
# Unwrap phase
|
|
232
|
+
phi = np.unwrap(np.angle(fid_water))
|
|
233
|
+
|
|
234
|
+
# Apply low-pass filter
|
|
235
|
+
if self.lp_cut_hz is not None and self.lp_cut_hz > 0:
|
|
236
|
+
nyq = 0.5 * float(sw_hz)
|
|
237
|
+
Wn = min(max(self.lp_cut_hz / nyq, 1e-6), 0.999999)
|
|
238
|
+
b, a = butter(2, Wn, btype='low')
|
|
239
|
+
phi = filtfilt(b, a, phi)
|
|
240
|
+
|
|
241
|
+
# Remove linear trend if requested
|
|
242
|
+
if self.remove_linear:
|
|
243
|
+
A = np.c_[np.ones(N), t]
|
|
244
|
+
k0, k1 = np.linalg.lstsq(A, phi, rcond=None)[0]
|
|
245
|
+
phi = phi - (k0 + k1 * t)
|
|
246
|
+
|
|
247
|
+
return phi
|
|
248
|
+
|
|
249
|
+
def _synth_ec_phase(self, N: int, sw_hz: float) -> np.ndarray:
|
|
250
|
+
"""
|
|
251
|
+
Synthesize eddy current phase trajectory.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
N: Number of points
|
|
255
|
+
sw_hz: Spectral width in Hz
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Phase values in radians
|
|
259
|
+
"""
|
|
260
|
+
rng = np.random.default_rng(self.seed)
|
|
261
|
+
t = np.arange(N, dtype=float) / float(sw_hz)
|
|
262
|
+
|
|
263
|
+
# Generate white noise
|
|
264
|
+
w = rng.normal(scale=self.std_rad, size=N)
|
|
265
|
+
|
|
266
|
+
# Apply low-pass filter
|
|
267
|
+
nyq = 0.5 * float(sw_hz)
|
|
268
|
+
Wn = min(max(self.lp_cut_hz / nyq, 1e-6), 0.999999)
|
|
269
|
+
b, a = butter(2, Wn, btype='low')
|
|
270
|
+
phi = filtfilt(b, a, w)
|
|
271
|
+
|
|
272
|
+
# Remove linear trend if requested
|
|
273
|
+
if self.remove_linear:
|
|
274
|
+
A = np.c_[np.ones(N), t]
|
|
275
|
+
k0, k1 = np.linalg.lstsq(A, phi, rcond=None)[0]
|
|
276
|
+
phi = phi - (k0 + k1 * t)
|
|
277
|
+
|
|
278
|
+
return phi
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# gaussian_noise.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: Implements uncorrelated complex Gaussian noise (AWGN) for MRS data augmentation. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from typing import Optional, List
|
|
16
|
+
|
|
17
|
+
from augmentrum.core.base_module import BaseModule
|
|
18
|
+
from augmentrum.utils.tensor_ops import to_numpy, match_backend, _is_torch, _is_jax, _is_tf
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GaussianNoise(BaseModule):
|
|
22
|
+
"""
|
|
23
|
+
Add uncorrelated complex Gaussian noise (AWGN) to MRS data.
|
|
24
|
+
|
|
25
|
+
**Backend-agnostic** (zea pattern): ``process_tensor`` works transparently
|
|
26
|
+
with NumPy, PyTorch, JAX, or TensorFlow tensors.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
snr: float, optional
|
|
31
|
+
Signal-to-noise ratio (unitless, signal_power / noise_power)
|
|
32
|
+
snr_db : float, optional
|
|
33
|
+
Signal-to-noise ratio in dB (signal_power / noise_power)
|
|
34
|
+
sigma : float, optional
|
|
35
|
+
Standard deviation per dimension (real and imaginary)
|
|
36
|
+
sigma_frac : float, optional
|
|
37
|
+
Sigma as fraction of max|FID| (alternative to sigma)
|
|
38
|
+
seed : int, optional
|
|
39
|
+
Random seed for reproducibility
|
|
40
|
+
|
|
41
|
+
Notes
|
|
42
|
+
-----
|
|
43
|
+
Provide EITHER snr_db, sigma, OR sigma_frac (not multiple).
|
|
44
|
+
If snr_db is used, sigma is computed from mean(|fid|^2).
|
|
45
|
+
|
|
46
|
+
Examples
|
|
47
|
+
--------
|
|
48
|
+
>>> noise = GaussianNoise(snr=20.0)
|
|
49
|
+
>>> noise = GaussianNoise(snr_db=10.0)
|
|
50
|
+
>>> noise = GaussianNoise(sigma=0.01)
|
|
51
|
+
>>> noise = GaussianNoise(sigma_frac=0.02)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
55
|
+
|
|
56
|
+
def __init__(self,
|
|
57
|
+
snr: Optional[float] = None,
|
|
58
|
+
snr_db: Optional[float] = None,
|
|
59
|
+
sigma: Optional[float] = None,
|
|
60
|
+
sigma_frac: Optional[float] = None,
|
|
61
|
+
seed: Optional[int] = None):
|
|
62
|
+
super().__init__(snr=snr, snr_db=snr_db, sigma=sigma, sigma_frac=sigma_frac, seed=seed)
|
|
63
|
+
|
|
64
|
+
self.snr = snr
|
|
65
|
+
self.snr_db = snr_db
|
|
66
|
+
self.sigma = sigma
|
|
67
|
+
self.sigma_frac = sigma_frac
|
|
68
|
+
self.seed = seed
|
|
69
|
+
|
|
70
|
+
# Validate parameters
|
|
71
|
+
params_provided = sum([snr is not None, snr_db is not None,
|
|
72
|
+
sigma is not None, sigma_frac is not None])
|
|
73
|
+
if params_provided == 0:
|
|
74
|
+
raise ValueError("Must provide one of: snr, snr_db, sigma, or sigma_frac")
|
|
75
|
+
if params_provided > 1:
|
|
76
|
+
raise ValueError("Provide only ONE of: snr, snr_db, sigma, or sigma_frac")
|
|
77
|
+
|
|
78
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
79
|
+
"""Add Gaussian noise to list of NIFTI_MRS objects."""
|
|
80
|
+
processed_data = []
|
|
81
|
+
for nifti in data_list:
|
|
82
|
+
fid = nifti[:]
|
|
83
|
+
fid_noisy = self._add_noise_numpy(fid)
|
|
84
|
+
nifti[:] = fid_noisy
|
|
85
|
+
processed_data.append(nifti)
|
|
86
|
+
return processed_data, water_list
|
|
87
|
+
|
|
88
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
89
|
+
"""
|
|
90
|
+
Add Gaussian noise to tensor/array data (**any backend, natively**).
|
|
91
|
+
|
|
92
|
+
Scale statistics (sigma_frac / snr modes) are measured with a tiny
|
|
93
|
+
``to_numpy`` scalar reduction. Noise is then **generated in the
|
|
94
|
+
native framework's own RNG** — ``torch.randn`` for PyTorch,
|
|
95
|
+
``jax.random.normal`` for JAX, ``tf.random.normal`` for TF/Keras,
|
|
96
|
+
or ``numpy`` as the universal fallback. This keeps gradients intact
|
|
97
|
+
and avoids the old "numpy noise → match_backend" workaround that
|
|
98
|
+
broke under JIT tracing.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
102
|
+
water_array: Optional water reference tensor (unchanged)
|
|
103
|
+
backend: Backend enum (unused — ops dispatch automatically)
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
Tuple of (noisy_data, water_array)
|
|
107
|
+
"""
|
|
108
|
+
original_shape = data_array.shape
|
|
109
|
+
N = original_shape[-1]
|
|
110
|
+
n_batch = int(np.prod(original_shape[:-1]))
|
|
111
|
+
|
|
112
|
+
# ── Step 1: compute noise scale (small scalar reduction → numpy OK) ──
|
|
113
|
+
if self.sigma is not None:
|
|
114
|
+
# Fixed sigma — no data stats needed at all
|
|
115
|
+
scale_np = np.full((n_batch, 1), float(self.sigma), dtype=np.float64)
|
|
116
|
+
else:
|
|
117
|
+
flat_np = to_numpy(data_array).reshape(n_batch, N)
|
|
118
|
+
if self.sigma_frac is not None:
|
|
119
|
+
peak = np.max(np.abs(flat_np), axis=-1, keepdims=True)
|
|
120
|
+
peak = np.where(peak > 0, peak, 1.0)
|
|
121
|
+
scale_np = self.sigma_frac * peak
|
|
122
|
+
else:
|
|
123
|
+
sig_pow = np.mean(np.abs(flat_np) ** 2, axis=-1, keepdims=True) + 1e-16
|
|
124
|
+
if self.snr is not None:
|
|
125
|
+
noise_pow = sig_pow / float(self.snr)
|
|
126
|
+
else:
|
|
127
|
+
noise_pow = sig_pow / (10.0 ** (float(self.snr_db) / 10.0))
|
|
128
|
+
scale_np = np.sqrt(noise_pow / 2.0) # per-channel std
|
|
129
|
+
|
|
130
|
+
# scale_np shape: (n_batch, 1) — broadcast over last N axis
|
|
131
|
+
# Reshape to broadcast correctly over all dims
|
|
132
|
+
scale_np = scale_np.reshape([-1] + [1] * (len(original_shape) - 1))
|
|
133
|
+
|
|
134
|
+
# ── Step 2: generate noise in the native framework ────────────────────
|
|
135
|
+
if _is_torch(data_array):
|
|
136
|
+
import torch
|
|
137
|
+
gen = torch.Generator(device=data_array.device)
|
|
138
|
+
if self.seed is not None:
|
|
139
|
+
gen.manual_seed(self.seed)
|
|
140
|
+
# Determine real dtype for noise
|
|
141
|
+
if data_array.is_complex():
|
|
142
|
+
rdtype = data_array.real.dtype
|
|
143
|
+
else:
|
|
144
|
+
rdtype = data_array.dtype
|
|
145
|
+
scale_t = torch.as_tensor(scale_np, device=data_array.device, dtype=rdtype)
|
|
146
|
+
r = torch.randn(*original_shape, generator=gen, device=data_array.device, dtype=rdtype)
|
|
147
|
+
i_ = torch.randn(*original_shape, generator=gen, device=data_array.device, dtype=rdtype)
|
|
148
|
+
noise = torch.complex(r, i_) * scale_t
|
|
149
|
+
return data_array + noise, water_array
|
|
150
|
+
|
|
151
|
+
elif _is_jax(data_array):
|
|
152
|
+
import jax
|
|
153
|
+
import jax.numpy as jnp
|
|
154
|
+
seed = self.seed if self.seed is not None else 0
|
|
155
|
+
key = jax.random.PRNGKey(seed)
|
|
156
|
+
k1, k2 = jax.random.split(key)
|
|
157
|
+
r = jax.random.normal(k1, shape=original_shape, dtype=jnp.float32)
|
|
158
|
+
i_ = jax.random.normal(k2, shape=original_shape, dtype=jnp.float32)
|
|
159
|
+
scale_j = jnp.array(scale_np, dtype=jnp.float32)
|
|
160
|
+
noise = (r + 1j * i_).astype(jnp.complex64) * scale_j
|
|
161
|
+
return data_array + noise, water_array
|
|
162
|
+
|
|
163
|
+
elif _is_tf(data_array):
|
|
164
|
+
import tensorflow as tf
|
|
165
|
+
if self.seed is not None:
|
|
166
|
+
tf.random.set_seed(self.seed)
|
|
167
|
+
r = tf.random.normal(original_shape, dtype=tf.float32)
|
|
168
|
+
i_ = tf.random.normal(original_shape, dtype=tf.float32)
|
|
169
|
+
scale_t = tf.constant(scale_np, dtype=tf.float32)
|
|
170
|
+
noise = tf.complex(r * scale_t, i_ * scale_t)
|
|
171
|
+
return data_array + noise, water_array
|
|
172
|
+
|
|
173
|
+
else:
|
|
174
|
+
# NumPy and any other framework (e.g. Keras-native): numpy noise + match_backend
|
|
175
|
+
rng = np.random.default_rng(self.seed)
|
|
176
|
+
r = rng.normal(0.0, 1.0, size=original_shape)
|
|
177
|
+
i_ = rng.normal(0.0, 1.0, size=original_shape)
|
|
178
|
+
noise = (r + 1j * i_) * scale_np
|
|
179
|
+
return data_array + match_backend(noise, data_array), water_array
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Pure-numpy path for process_nifti_list (input is always numpy)
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _add_noise_numpy(self, fid: np.ndarray) -> np.ndarray:
|
|
186
|
+
"""Add Gaussian noise to numpy FID data."""
|
|
187
|
+
rng = np.random.default_rng(self.seed)
|
|
188
|
+
|
|
189
|
+
original_shape = fid.shape
|
|
190
|
+
fid_flat = fid.reshape(-1, original_shape[-1])
|
|
191
|
+
out_dtype = np.result_type(fid.dtype, np.complex64)
|
|
192
|
+
fid_flat = fid_flat.astype(out_dtype, copy=False)
|
|
193
|
+
|
|
194
|
+
if self.sigma is not None:
|
|
195
|
+
scale = float(self.sigma)
|
|
196
|
+
elif self.sigma_frac is not None:
|
|
197
|
+
peak = np.max(np.abs(fid_flat), axis=-1)
|
|
198
|
+
peak = np.where(peak > 0, peak, 1.0)
|
|
199
|
+
scale = (self.sigma_frac * peak)[:, None]
|
|
200
|
+
else:
|
|
201
|
+
sig_pow = np.mean(np.abs(fid_flat) ** 2, axis=-1) + 1e-16
|
|
202
|
+
if self.snr is not None:
|
|
203
|
+
noise_pow = sig_pow / float(self.snr)
|
|
204
|
+
else:
|
|
205
|
+
noise_pow = sig_pow / (10.0 ** (float(self.snr_db) / 10.0))
|
|
206
|
+
scale = np.sqrt(noise_pow / 2.0)[:, None]
|
|
207
|
+
|
|
208
|
+
noise = (rng.normal(0.0, 1.0, size=fid_flat.shape)
|
|
209
|
+
+ 1j * rng.normal(0.0, 1.0, size=fid_flat.shape)) * scale
|
|
210
|
+
|
|
211
|
+
return (fid_flat + noise).reshape(original_shape)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# line_broadening.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: Implements Lorentzian, Gaussian, and Voigt line broadening for MRS data. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import numpy as np
|
|
16
|
+
from typing import Optional, List
|
|
17
|
+
|
|
18
|
+
from augmentrum.core.base_module import BaseModule
|
|
19
|
+
from augmentrum.utils.tensor_ops import match_backend
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LineBroadening(BaseModule):
|
|
23
|
+
"""
|
|
24
|
+
Apply line broadening to MRS data.
|
|
25
|
+
|
|
26
|
+
Supports Lorentzian (exponential), Gaussian, and Voigt (combined) broadening.
|
|
27
|
+
|
|
28
|
+
**Backend-agnostic** (zea pattern): all ``process_tensor`` operations use
|
|
29
|
+
``keras.ops`` so they work transparently with NumPy arrays, PyTorch tensors,
|
|
30
|
+
JAX arrays, and TensorFlow tensors without losing gradients.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
lb_hz : float
|
|
35
|
+
Lorentzian broadening Full Width at Half Maximum (FWHM) in Hz
|
|
36
|
+
gb_hz : float
|
|
37
|
+
Gaussian broadening FWHM in Hz
|
|
38
|
+
mode : str
|
|
39
|
+
Broadening type: 'lorentzian', 'gaussian', or 'voigt' (default)
|
|
40
|
+
|
|
41
|
+
Examples
|
|
42
|
+
--------
|
|
43
|
+
>>> # Lorentzian broadening only
|
|
44
|
+
>>> broadening = LineBroadening(lb_hz=5.0, mode='lorentzian')
|
|
45
|
+
>>>
|
|
46
|
+
>>> # Gaussian broadening only
|
|
47
|
+
>>> broadening = LineBroadening(gb_hz=3.0, mode='gaussian')
|
|
48
|
+
>>>
|
|
49
|
+
>>> # Voigt (combined) broadening
|
|
50
|
+
>>> broadening = LineBroadening(lb_hz=5.0, gb_hz=3.0, mode='voigt')
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
54
|
+
|
|
55
|
+
def __init__(self, lb_hz: float = 0.0, gb_hz: float = 0.0, mode: str = 'voigt'):
|
|
56
|
+
"""Initialize line broadening module."""
|
|
57
|
+
super().__init__(lb_hz=lb_hz, gb_hz=gb_hz, mode=mode)
|
|
58
|
+
|
|
59
|
+
self.lb_hz = lb_hz
|
|
60
|
+
self.gb_hz = gb_hz
|
|
61
|
+
self.mode = mode.lower()
|
|
62
|
+
|
|
63
|
+
if self.mode not in ['lorentzian', 'gaussian', 'voigt']:
|
|
64
|
+
raise ValueError(f"mode must be 'lorentzian', 'gaussian', or 'voigt', got '{mode}'")
|
|
65
|
+
|
|
66
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
67
|
+
"""
|
|
68
|
+
Apply line broadening 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 spectral width from NIFTI_MRS
|
|
82
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
83
|
+
|
|
84
|
+
# Get FID data
|
|
85
|
+
fid = nifti[:]
|
|
86
|
+
|
|
87
|
+
# Apply broadening based on mode
|
|
88
|
+
if self.mode == 'lorentzian':
|
|
89
|
+
fid_broadened = self._apply_lorentzian(fid, sw_hz, self.lb_hz)
|
|
90
|
+
elif self.mode == 'gaussian':
|
|
91
|
+
fid_broadened = self._apply_gaussian(fid, sw_hz, self.gb_hz)
|
|
92
|
+
else: # voigt
|
|
93
|
+
fid_broadened = self._apply_voigt(fid, sw_hz, self.lb_hz, self.gb_hz)
|
|
94
|
+
|
|
95
|
+
# Update NIFTI_MRS data
|
|
96
|
+
nifti[:] = fid_broadened
|
|
97
|
+
processed_data.append(nifti)
|
|
98
|
+
|
|
99
|
+
return processed_data, water_list
|
|
100
|
+
|
|
101
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
102
|
+
"""
|
|
103
|
+
Apply line broadening to tensor/array data (**any backend**).
|
|
104
|
+
|
|
105
|
+
Uses ``keras.ops`` — works identically with NumPy, PyTorch, JAX, or
|
|
106
|
+
TensorFlow tensors. Gradients are preserved for differentiable
|
|
107
|
+
training pipelines.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
111
|
+
water_array: Optional water reference tensor (unchanged)
|
|
112
|
+
backend: Backend enum (unused — ops dispatch automatically)
|
|
113
|
+
**kwargs: Must contain ``'sw_hz'`` (spectral width in Hz)
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Tuple of (processed_data, water_array)
|
|
117
|
+
"""
|
|
118
|
+
sw_hz = kwargs.get('sw_hz')
|
|
119
|
+
if sw_hz is None:
|
|
120
|
+
raise ValueError("LineBroadening.process_tensor requires 'sw_hz' in kwargs")
|
|
121
|
+
|
|
122
|
+
if self.mode == 'lorentzian':
|
|
123
|
+
result = self._apply_lorentzian(data_array, sw_hz, self.lb_hz)
|
|
124
|
+
elif self.mode == 'gaussian':
|
|
125
|
+
result = self._apply_gaussian(data_array, sw_hz, self.gb_hz)
|
|
126
|
+
else: # voigt
|
|
127
|
+
result = self._apply_voigt(data_array, sw_hz, self.lb_hz, self.gb_hz)
|
|
128
|
+
|
|
129
|
+
return result, water_array
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Envelope helpers — fully backend-agnostic via keras.ops
|
|
133
|
+
#
|
|
134
|
+
# The time axis `t` is a plain numpy array (no gradients needed for
|
|
135
|
+
# coordinates). The envelope is also numpy. The final multiplication
|
|
136
|
+
# `fid * envelope` is handled by the framework's own __mul__, which
|
|
137
|
+
# auto-promotes the numpy envelope to the correct backend and keeps
|
|
138
|
+
# the fid tensor on its original device / graph.
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _make_time_envelope(fid, sw_hz, lb_hz, gb_hz):
|
|
143
|
+
"""Build the broadening envelope (numpy) shaped for broadcasting."""
|
|
144
|
+
N = fid.shape[-1]
|
|
145
|
+
t = np.arange(N, dtype=np.float64) / float(sw_hz)
|
|
146
|
+
|
|
147
|
+
# Reshape t for broadcasting: (1, 1, ..., N)
|
|
148
|
+
t = t.reshape([1] * (len(fid.shape) - 1) + [N])
|
|
149
|
+
|
|
150
|
+
env = np.ones_like(t)
|
|
151
|
+
if lb_hz > 0:
|
|
152
|
+
env *= np.exp(-math.pi * lb_hz * t)
|
|
153
|
+
if gb_hz > 0:
|
|
154
|
+
env *= np.exp(-((math.pi * gb_hz * t) ** 2) / (4 * math.log(2)))
|
|
155
|
+
return env
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _apply_lorentzian(fid, sw_hz, lb_hz):
|
|
159
|
+
"""Apply Lorentzian (exponential) line broadening."""
|
|
160
|
+
if lb_hz <= 0:
|
|
161
|
+
return fid
|
|
162
|
+
envelope = LineBroadening._make_time_envelope(fid, sw_hz, lb_hz, 0.0)
|
|
163
|
+
return fid * match_backend(envelope, fid)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def _apply_gaussian(fid, sw_hz, gb_hz):
|
|
167
|
+
"""Apply Gaussian line broadening."""
|
|
168
|
+
if gb_hz <= 0:
|
|
169
|
+
return fid
|
|
170
|
+
envelope = LineBroadening._make_time_envelope(fid, sw_hz, 0.0, gb_hz)
|
|
171
|
+
return fid * match_backend(envelope, fid)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _apply_voigt(fid, sw_hz, lb_hz, gb_hz):
|
|
175
|
+
"""Apply Voigt (Lorentzian × Gaussian) line broadening."""
|
|
176
|
+
if lb_hz <= 0 and gb_hz <= 0:
|
|
177
|
+
return fid
|
|
178
|
+
envelope = LineBroadening._make_time_envelope(fid, sw_hz, lb_hz, gb_hz)
|
|
179
|
+
return fid * match_backend(envelope, fid)
|