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,544 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# baseline_augmentation.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. T. LaMaster (jlamaste@gmail.com) #
|
|
6
|
+
# K. C. Igwe (kci2104@columbia.edu) #
|
|
7
|
+
# J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
8
|
+
# #
|
|
9
|
+
# Created: 2026-02-07 #
|
|
10
|
+
# #
|
|
11
|
+
# Purpose: Baseline augmentation - supports random walk, B-spline, and polynomial baselines #
|
|
12
|
+
# #
|
|
13
|
+
####################################################################################################
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from typing import Optional, List, Tuple
|
|
17
|
+
from scipy.signal import convolve
|
|
18
|
+
from scipy.interpolate import BSpline
|
|
19
|
+
|
|
20
|
+
from augmentrum.core.base_module import BaseModule
|
|
21
|
+
from augmentrum.utils.tensor_ops import to_numpy, match_backend
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# Helper Functions (from original augment_mrs.py lines 329-366)
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
def moving_average(x, w):
|
|
29
|
+
"""Apply moving average smoothing."""
|
|
30
|
+
if w <= 1:
|
|
31
|
+
return x
|
|
32
|
+
kernel = np.ones(int(w), float) / float(w)
|
|
33
|
+
return convolve(x, kernel, mode="same")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def bounded_random_walk(n, *, step_sd=1e-3, lo=-1.0, hi=1.0, seed=None):
|
|
37
|
+
"""
|
|
38
|
+
Reflecting bounded random walk of length n (real-valued).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
n: Number of points
|
|
42
|
+
step_sd: Standard deviation of step size
|
|
43
|
+
lo: Lower bound
|
|
44
|
+
hi: Upper bound
|
|
45
|
+
seed: Random seed
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Array of random walk values
|
|
49
|
+
"""
|
|
50
|
+
rng = np.random.default_rng(seed)
|
|
51
|
+
y = np.empty(n, float)
|
|
52
|
+
v = 0.0
|
|
53
|
+
for i in range(n):
|
|
54
|
+
v += rng.normal(0.0, step_sd)
|
|
55
|
+
# Reflect at the bounds
|
|
56
|
+
if v < lo:
|
|
57
|
+
v = lo + (lo - v)
|
|
58
|
+
if v > hi:
|
|
59
|
+
v = hi - (v - hi)
|
|
60
|
+
y[i] = v
|
|
61
|
+
return y
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_rw_baseline_freq(n_points, *, step_sd=1e-3, bounds_amp=1.0,
|
|
65
|
+
smooth_pts=101, seed=123):
|
|
66
|
+
"""
|
|
67
|
+
Create a smooth, bounded RW baseline (real) in frequency domain.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
n_points: Number of spectral points
|
|
71
|
+
step_sd: Step size standard deviation (smaller = smoother)
|
|
72
|
+
bounds_amp: Amplitude bounds [-bounds_amp, +bounds_amp]
|
|
73
|
+
smooth_pts: Moving average window size (bigger = smoother)
|
|
74
|
+
seed: Random seed
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Baseline array (fftshifted order)
|
|
78
|
+
"""
|
|
79
|
+
raw = bounded_random_walk(n_points, step_sd=step_sd,
|
|
80
|
+
lo=-bounds_amp, hi=bounds_amp, seed=seed)
|
|
81
|
+
base = moving_average(raw, smooth_pts)
|
|
82
|
+
return base
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scale_baseline_to_spec(baseline, spec, frac=0.05):
|
|
86
|
+
"""
|
|
87
|
+
Scale baseline to a fraction of the spectrum's real peak (visibility).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
baseline: Raw baseline array
|
|
91
|
+
spec: Complex spectrum
|
|
92
|
+
frac: Fraction of spectrum peak (e.g., 0.05 = 5%)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Scaled baseline
|
|
96
|
+
"""
|
|
97
|
+
scale = frac * np.max(np.abs(np.real(spec)))
|
|
98
|
+
return baseline * scale
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# B-spline helpers (from augment_mrs.py lines 688-713)
|
|
102
|
+
def _cubic_bspline_basis(x, knots, degree=3):
|
|
103
|
+
"""Return B-spline basis (design) matrix for given x and knot vector."""
|
|
104
|
+
# Open uniform knot vector with clamped ends for cubic splines
|
|
105
|
+
k = degree
|
|
106
|
+
# Pad end knots (k+1 repeats) for clamping
|
|
107
|
+
t0, t1 = knots[0], knots[-1]
|
|
108
|
+
t = np.r_[t0*np.ones(k), knots, t1*np.ones(k)]
|
|
109
|
+
n_b = len(t) - (k + 1) # number of basis funcs
|
|
110
|
+
B = np.empty((x.size, n_b))
|
|
111
|
+
# coefficient vectors are unit vectors to evaluate each basis function
|
|
112
|
+
for j in range(n_b):
|
|
113
|
+
c = np.zeros(n_b)
|
|
114
|
+
c[j] = 1.0
|
|
115
|
+
B[:, j] = BSpline(t, c, k, extrapolate=True)(x)
|
|
116
|
+
return B
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _diff_matrix_2(n):
|
|
120
|
+
"""Second-order finite-difference matrix (n-2 rows by n cols)."""
|
|
121
|
+
D = np.zeros((n-2, n))
|
|
122
|
+
r = np.arange(n-2)
|
|
123
|
+
D[r, r ] = 1.0
|
|
124
|
+
D[r, r+1] = -2.0
|
|
125
|
+
D[r, r+2] = 1.0
|
|
126
|
+
return D
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Polynomial helpers (from augment_mrs.py lines 633-685)
|
|
130
|
+
def auto_baseline_windows(ppm, margin_ppm=0.5):
|
|
131
|
+
"""
|
|
132
|
+
Suggests two baseline windows near the edges of the spectral range.
|
|
133
|
+
"""
|
|
134
|
+
ppm_min, ppm_max = ppm.min(), ppm.max()
|
|
135
|
+
|
|
136
|
+
# Ensure the windows are returned in an intuitive, increasing (or decreasing) order
|
|
137
|
+
# based on the natural flow of the ppm axis.
|
|
138
|
+
if ppm_min < ppm_max:
|
|
139
|
+
# Example: [0 ppm, 10 ppm] -> [0, 0.5] and [9.5, 10]
|
|
140
|
+
return [
|
|
141
|
+
(ppm_min, ppm_min + margin_ppm), # lower edge window (e.g., ~0.0 to 0.5 ppm)
|
|
142
|
+
(ppm_max - margin_ppm, ppm_max) # upper edge window (e.g., ~9.5 to 10.0 ppm)
|
|
143
|
+
]
|
|
144
|
+
else:
|
|
145
|
+
# Example: [10 ppm, 0 ppm] -> [10, 9.5] and [0.5, 0]
|
|
146
|
+
return [
|
|
147
|
+
(ppm_min, ppm_min - margin_ppm), # lower edge window (e.g., ~10.0 to 9.5 ppm)
|
|
148
|
+
(ppm_max + margin_ppm, ppm_max) # upper edge window (e.g., ~0.5 to 0.0 ppm)
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def calculate_ppm_and_mask(fid, sw_hz, sf_hz, ref_ppm, ppm_windows):
|
|
153
|
+
"""Internal function to transform FID, calculate PPM axis, and create the baseline mask.
|
|
154
|
+
|
|
155
|
+
Note: ``sf_hz`` is the Larmor frequency in **MHz** (matching NIfTI-MRS convention).
|
|
156
|
+
It is converted to Hz internally for the ppm calculation, exactly as in the legacy
|
|
157
|
+
``augment_mrs.py`` implementation.
|
|
158
|
+
"""
|
|
159
|
+
spec = np.fft.fftshift(np.fft.ifft(fid))
|
|
160
|
+
N = spec.size
|
|
161
|
+
|
|
162
|
+
# Larmor frequency in Hz (sf_hz is passed in MHz, hence *1e6)
|
|
163
|
+
sf_hz_abs = sf_hz * 1e6
|
|
164
|
+
|
|
165
|
+
# Calculate frequency offset (in Hz), centered at 0
|
|
166
|
+
freq_offset_hz = np.fft.fftshift(np.fft.fftfreq(N, d=1.0/sw_hz))
|
|
167
|
+
|
|
168
|
+
# Calculate ppm axis: ref_ppm - (f - f_ref) / sf_hz_abs
|
|
169
|
+
ppm = ref_ppm - freq_offset_hz / sf_hz_abs
|
|
170
|
+
ppm = np.asarray(ppm)
|
|
171
|
+
|
|
172
|
+
mask_baseline = np.zeros_like(ppm, dtype=bool)
|
|
173
|
+
|
|
174
|
+
if ppm_windows is None:
|
|
175
|
+
ppm_windows = auto_baseline_windows(ppm)
|
|
176
|
+
|
|
177
|
+
# Iterate through all defined ppm windows
|
|
178
|
+
for (p1, p2) in ppm_windows:
|
|
179
|
+
# Determine the low and high ppm values for masking, regardless of p1/p2 order
|
|
180
|
+
p_low = min(p1, p2)
|
|
181
|
+
p_high = max(p1, p2)
|
|
182
|
+
|
|
183
|
+
region_mask = (ppm >= p_low) & (ppm <= p_high)
|
|
184
|
+
mask_baseline = np.logical_or(mask_baseline, region_mask)
|
|
185
|
+
|
|
186
|
+
return spec, N, mask_baseline
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ============================================================================
|
|
190
|
+
# BaselineAugmentation Module
|
|
191
|
+
# ============================================================================
|
|
192
|
+
|
|
193
|
+
class BaselineAugmentation(BaseModule):
|
|
194
|
+
"""
|
|
195
|
+
Add baseline distortions to MRS data.
|
|
196
|
+
|
|
197
|
+
Supports three modes:
|
|
198
|
+
- 'random_walk': Bounded random walk with Hilbert transform (default)
|
|
199
|
+
- 'bspline': Cubic B-spline baseline with P-spline smoothing
|
|
200
|
+
- 'polynomial': Polynomial baseline fitted to edge regions
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
mode : str
|
|
205
|
+
Baseline mode: 'random_walk', 'bspline', or 'polynomial' (default: 'random_walk')
|
|
206
|
+
baseline_frac : float
|
|
207
|
+
Fraction of spectrum peak for baseline amplitude (default: 0.05 = 5%)
|
|
208
|
+
|
|
209
|
+
**Random Walk Parameters:**
|
|
210
|
+
step_sd : float
|
|
211
|
+
Step size standard deviation for random walk (default: 0.02)
|
|
212
|
+
bounds_amp : float
|
|
213
|
+
Amplitude bounds for random walk (default: 1.0)
|
|
214
|
+
smooth_pts : int
|
|
215
|
+
Moving average window size (default: 101)
|
|
216
|
+
|
|
217
|
+
**B-Spline Parameters:**
|
|
218
|
+
knots_per_ppm : int
|
|
219
|
+
Number of B-spline knots per ppm (default: 12)
|
|
220
|
+
ed_per_ppm : float
|
|
221
|
+
Target effective degrees of freedom per ppm (default: 3.0)
|
|
222
|
+
phase_deg : float
|
|
223
|
+
Baseline phase rotation in degrees (default: 0.0)
|
|
224
|
+
|
|
225
|
+
**Polynomial Parameters:**
|
|
226
|
+
order : int
|
|
227
|
+
Polynomial order (default: 3)
|
|
228
|
+
ppm_windows : list of tuples or None
|
|
229
|
+
Baseline fitting windows as [(ppm_min1, ppm_max1), ...] (default: None = auto)
|
|
230
|
+
|
|
231
|
+
seed : int or None
|
|
232
|
+
Random seed for reproducibility (default: None)
|
|
233
|
+
|
|
234
|
+
Examples
|
|
235
|
+
--------
|
|
236
|
+
>>> # Random walk baseline (default)
|
|
237
|
+
>>> baseline = BaselineAugmentation(mode='random_walk', baseline_frac=0.05)
|
|
238
|
+
>>> result_data, _ = baseline(nifti_plus, None)
|
|
239
|
+
|
|
240
|
+
>>> # B-spline baseline
|
|
241
|
+
>>> baseline = BaselineAugmentation(mode='bspline', baseline_frac=0.10, knots_per_ppm=12)
|
|
242
|
+
>>> result_data, _ = baseline(nifti_plus, None)
|
|
243
|
+
|
|
244
|
+
>>> # Polynomial baseline
|
|
245
|
+
>>> baseline = BaselineAugmentation(mode='polynomial', baseline_frac=0.08, order=5)
|
|
246
|
+
>>> result_data, _ = baseline(nifti_plus, None)
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
250
|
+
|
|
251
|
+
def __init__(self, mode: str = 'random_walk', baseline_frac: float = 0.05,
|
|
252
|
+
# Random walk params
|
|
253
|
+
step_sd: float = 0.02, bounds_amp: float = 1.0, smooth_pts: int = 101,
|
|
254
|
+
# B-spline params
|
|
255
|
+
knots_per_ppm: int = 12, ed_per_ppm: float = 3.0, phase_deg: float = 0.0,
|
|
256
|
+
# Polynomial params
|
|
257
|
+
order: int = 3, ppm_windows: Optional[List[Tuple[float, float]]] = None,
|
|
258
|
+
# General
|
|
259
|
+
seed: Optional[int] = None):
|
|
260
|
+
"""Initialize baseline augmentation module."""
|
|
261
|
+
super().__init__(mode=mode, baseline_frac=baseline_frac, step_sd=step_sd,
|
|
262
|
+
bounds_amp=bounds_amp, smooth_pts=smooth_pts,
|
|
263
|
+
knots_per_ppm=knots_per_ppm, ed_per_ppm=ed_per_ppm,
|
|
264
|
+
phase_deg=phase_deg, order=order, ppm_windows=ppm_windows, seed=seed)
|
|
265
|
+
|
|
266
|
+
if mode not in ['random_walk', 'bspline', 'polynomial']:
|
|
267
|
+
raise ValueError(f"mode must be 'random_walk', 'bspline', or 'polynomial', got '{mode}'")
|
|
268
|
+
|
|
269
|
+
self.mode = mode
|
|
270
|
+
self.baseline_frac = baseline_frac
|
|
271
|
+
self.seed = seed
|
|
272
|
+
|
|
273
|
+
# Random walk params
|
|
274
|
+
self.step_sd = step_sd
|
|
275
|
+
self.bounds_amp = bounds_amp
|
|
276
|
+
self.smooth_pts = smooth_pts
|
|
277
|
+
|
|
278
|
+
# B-spline params
|
|
279
|
+
self.knots_per_ppm = knots_per_ppm
|
|
280
|
+
self.ed_per_ppm = ed_per_ppm
|
|
281
|
+
self.phase_deg = phase_deg
|
|
282
|
+
|
|
283
|
+
# Polynomial params
|
|
284
|
+
self.order = order
|
|
285
|
+
self.ppm_windows = ppm_windows
|
|
286
|
+
|
|
287
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
288
|
+
"""
|
|
289
|
+
Add baseline to list of NIFTI_MRS objects.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
data_list: List of NIFTI_MRS objects
|
|
293
|
+
water_list: Optional list of water reference NIFTI_MRS objects (unchanged)
|
|
294
|
+
**kwargs: Additional arguments
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Tuple of (processed_data_list, water_list)
|
|
298
|
+
"""
|
|
299
|
+
processed_data = []
|
|
300
|
+
|
|
301
|
+
for nifti in data_list:
|
|
302
|
+
# Get FID data
|
|
303
|
+
fid = nifti[:]
|
|
304
|
+
|
|
305
|
+
# Get parameters
|
|
306
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
307
|
+
sf_mhz = nifti.spectrometer_frequency[0]
|
|
308
|
+
|
|
309
|
+
# Add baseline based on mode
|
|
310
|
+
if self.mode == 'random_walk':
|
|
311
|
+
fid_with_baseline = self._add_random_walk_baseline(fid, sw_hz, sf_mhz)
|
|
312
|
+
elif self.mode == 'bspline':
|
|
313
|
+
fid_with_baseline = self._add_bspline_baseline(fid, sw_hz, sf_mhz)
|
|
314
|
+
elif self.mode == 'polynomial':
|
|
315
|
+
fid_with_baseline = self._add_polynomial_baseline(fid, sw_hz, sf_mhz)
|
|
316
|
+
else:
|
|
317
|
+
raise ValueError(f"Invalid baseline mode: {self.mode}")
|
|
318
|
+
|
|
319
|
+
# Update NIFTI_MRS data
|
|
320
|
+
nifti[:] = fid_with_baseline
|
|
321
|
+
processed_data.append(nifti)
|
|
322
|
+
|
|
323
|
+
return processed_data, water_list
|
|
324
|
+
|
|
325
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
326
|
+
"""
|
|
327
|
+
Add baseline to tensor/array data (**any backend**).
|
|
328
|
+
|
|
329
|
+
Baseline generation uses NumPy/SciPy (non-differentiable), but the
|
|
330
|
+
final addition is backend-agnostic: ``data_tensor + numpy_baseline``
|
|
331
|
+
auto-promotes to the correct backend and preserves gradients.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
335
|
+
water_array: Optional water reference tensor (unchanged)
|
|
336
|
+
backend: Backend enum (unused — ops dispatch automatically)
|
|
337
|
+
**kwargs: Must contain ``'sw_hz'`` and ``'sf_mhz'``
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Tuple of (processed_data, water_array)
|
|
341
|
+
"""
|
|
342
|
+
sw_hz = kwargs.get('sw_hz')
|
|
343
|
+
sf_mhz = kwargs.get('sf_mhz')
|
|
344
|
+
if sw_hz is None or sf_mhz is None:
|
|
345
|
+
raise ValueError("BaselineAugmentation.process_tensor requires 'sw_hz' and 'sf_mhz' in kwargs")
|
|
346
|
+
|
|
347
|
+
# Pull data to numpy ONLY for baseline generation (scipy-dependent)
|
|
348
|
+
data_np = to_numpy(data_array)
|
|
349
|
+
original_shape = data_np.shape
|
|
350
|
+
batch_size = original_shape[0]
|
|
351
|
+
|
|
352
|
+
# Generate baselines on numpy (non-differentiable, uses scipy)
|
|
353
|
+
baseline_np = np.zeros_like(data_np)
|
|
354
|
+
for b in range(batch_size):
|
|
355
|
+
fid = data_np[b]
|
|
356
|
+
if self.mode == 'random_walk':
|
|
357
|
+
augmented = self._add_random_walk_baseline(fid, sw_hz, sf_mhz)
|
|
358
|
+
elif self.mode == 'bspline':
|
|
359
|
+
augmented = self._add_bspline_baseline(fid, sw_hz, sf_mhz)
|
|
360
|
+
elif self.mode == 'polynomial':
|
|
361
|
+
augmented = self._add_polynomial_baseline(fid, sw_hz, sf_mhz)
|
|
362
|
+
baseline_np[b] = augmented - fid # isolate the baseline additive term
|
|
363
|
+
|
|
364
|
+
# Add baseline to original tensor (backend-agnostic addition)
|
|
365
|
+
# convert to same backend, preserves gradients
|
|
366
|
+
return data_array + match_backend(baseline_np, data_array), water_array
|
|
367
|
+
|
|
368
|
+
def _add_random_walk_baseline(self, fid: np.ndarray, sw_hz: float, sf_mhz: float,
|
|
369
|
+
ref_ppm: float = 4.7) -> np.ndarray:
|
|
370
|
+
"""Add random walk baseline with Hilbert transform for complex baseline (from baseline_augmentation_BROKEN.py)."""
|
|
371
|
+
from scipy.signal import hilbert
|
|
372
|
+
|
|
373
|
+
original_shape = fid.shape
|
|
374
|
+
N = original_shape[-1]
|
|
375
|
+
fid_2d = fid.reshape(-1, N)
|
|
376
|
+
result = np.zeros_like(fid_2d)
|
|
377
|
+
|
|
378
|
+
for i in range(fid_2d.shape[0]):
|
|
379
|
+
fid_1d = fid_2d[i]
|
|
380
|
+
spec = np.fft.fftshift(np.fft.ifft(fid_1d)) # MRS convention: ifft for FID -> spectrum
|
|
381
|
+
|
|
382
|
+
# Get spectrum peak for scaling
|
|
383
|
+
spec_max = np.max(np.abs(np.real(spec)))
|
|
384
|
+
|
|
385
|
+
# Generate normalized random walk (bounded to [-bounds_amp, +bounds_amp])
|
|
386
|
+
base_rw_real = self._make_rw_baseline(N)
|
|
387
|
+
|
|
388
|
+
# Apply Hilbert transform to create complex baseline
|
|
389
|
+
# This makes the baseline causal and adds imaginary component
|
|
390
|
+
base_rw_complex = hilbert(base_rw_real)
|
|
391
|
+
|
|
392
|
+
# Scale to spectrum: first to bounds_amp * spec_max, then by baseline_frac
|
|
393
|
+
# This makes bounds_amp relative to the spectrum, and baseline_frac controls the final amplitude
|
|
394
|
+
baseline = base_rw_complex * self.bounds_amp * spec_max * self.baseline_frac
|
|
395
|
+
|
|
396
|
+
spec_with_base = spec + baseline
|
|
397
|
+
fid_augmented = np.fft.fft(np.fft.ifftshift(spec_with_base)) # MRS convention: fft for spectrum -> FID
|
|
398
|
+
result[i] = fid_augmented
|
|
399
|
+
|
|
400
|
+
return result.reshape(original_shape)
|
|
401
|
+
|
|
402
|
+
def _make_rw_baseline(self, n_points: int) -> np.ndarray:
|
|
403
|
+
"""Create smooth, bounded random walk baseline."""
|
|
404
|
+
raw = self._bounded_random_walk(n_points)
|
|
405
|
+
baseline = moving_average(raw, self.smooth_pts)
|
|
406
|
+
return baseline
|
|
407
|
+
|
|
408
|
+
def _bounded_random_walk(self, n: int) -> np.ndarray:
|
|
409
|
+
"""Generate bounded random walk."""
|
|
410
|
+
rng = np.random.default_rng(self.seed)
|
|
411
|
+
y = np.empty(n, dtype=float)
|
|
412
|
+
v = 0.0
|
|
413
|
+
|
|
414
|
+
for i in range(n):
|
|
415
|
+
v += rng.normal(0.0, self.step_sd)
|
|
416
|
+
if v < -self.bounds_amp:
|
|
417
|
+
v = -self.bounds_amp + (-self.bounds_amp - v)
|
|
418
|
+
if v > self.bounds_amp:
|
|
419
|
+
v = self.bounds_amp - (v - self.bounds_amp)
|
|
420
|
+
y[i] = v
|
|
421
|
+
|
|
422
|
+
return y
|
|
423
|
+
|
|
424
|
+
def _add_bspline_baseline(self, fid: np.ndarray, sw_hz: float, sf_mhz: float,
|
|
425
|
+
ref_ppm: float = 4.7) -> np.ndarray:
|
|
426
|
+
"""Add B-spline baseline to FID (uses exact code from augment_mrs.py)."""
|
|
427
|
+
original_shape = fid.shape
|
|
428
|
+
N = original_shape[-1]
|
|
429
|
+
fid_2d = fid.reshape(-1, N)
|
|
430
|
+
result = np.zeros_like(fid_2d)
|
|
431
|
+
|
|
432
|
+
for i in range(fid_2d.shape[0]):
|
|
433
|
+
fid_1d = fid_2d[i]
|
|
434
|
+
|
|
435
|
+
# ---- Go to (shifted) frequency domain (EXACT from augment_mrs.py) ----
|
|
436
|
+
spec = np.fft.fftshift(np.fft.ifft(fid_1d))
|
|
437
|
+
dt = 1.0 / float(sw_hz)
|
|
438
|
+
freq_hz = np.fft.fftshift(np.fft.fftfreq(N, d=dt))
|
|
439
|
+
ppm = ref_ppm - freq_hz / float(sf_mhz) # descending axis (e.g., 6→0)
|
|
440
|
+
|
|
441
|
+
# ---- Build cubic B-spline basis over ppm domain ----
|
|
442
|
+
ppm_min, ppm_max = ppm.min(), ppm.max()
|
|
443
|
+
span_ppm = float(ppm_max - ppm_min)
|
|
444
|
+
n_knots = max(8, int(np.ceil(span_ppm * self.knots_per_ppm))) # interior knots
|
|
445
|
+
knots = np.linspace(ppm_min, ppm_max, n_knots)
|
|
446
|
+
B = _cubic_bspline_basis(ppm, knots, degree=3) # [N x n_b]
|
|
447
|
+
n_b = B.shape[1]
|
|
448
|
+
|
|
449
|
+
# ---- P-spline penalty (2nd-diff on coefficients) ----
|
|
450
|
+
D = _diff_matrix_2(n_b)
|
|
451
|
+
|
|
452
|
+
# ---- Choose lambda from desired ED/ppm (rough heuristic) ----
|
|
453
|
+
target_ED = max(2.0, self.ed_per_ppm * span_ppm) # lower bound 2 for straight line with 2 dof
|
|
454
|
+
# λ grid (logspace) – wide range covers most cases
|
|
455
|
+
lam_grid = 10.0 ** np.linspace(-6, 8, 30)
|
|
456
|
+
BtB = B.T @ B
|
|
457
|
+
DtD = D.T @ D
|
|
458
|
+
best = (None, None, None) # (lam, ED, Ainv)
|
|
459
|
+
for lam in lam_grid:
|
|
460
|
+
A = BtB + lam * DtD
|
|
461
|
+
# Use trace trick: tr(S) = tr(B A^{-1} B^T) = tr(A^{-1} BtB)
|
|
462
|
+
Ainv = np.linalg.pinv(A)
|
|
463
|
+
ED = np.trace(Ainv @ BtB)
|
|
464
|
+
if best[0] is None or abs(ED - target_ED) < abs(best[1] - target_ED):
|
|
465
|
+
best = (lam, ED, Ainv)
|
|
466
|
+
lam_star, ED_star, Ainv = best
|
|
467
|
+
|
|
468
|
+
# ---- Draw smooth random coefficients consistent with the penalty ----
|
|
469
|
+
I = np.eye(n_b)
|
|
470
|
+
A = BtB + lam_star * DtD + 1e-10 * I # small ridge for stability
|
|
471
|
+
Ainv = np.linalg.pinv(A)
|
|
472
|
+
|
|
473
|
+
# z must be length N to match B.T @ z
|
|
474
|
+
rng = np.random.default_rng(self.seed)
|
|
475
|
+
z = rng.normal(size=N) # N-length white noise
|
|
476
|
+
a = Ainv @ (B.T @ z) # n_b-length smooth coefficient vector
|
|
477
|
+
|
|
478
|
+
# ---- Make baseline and normalize/scale/phase ----
|
|
479
|
+
baseline = B @ a
|
|
480
|
+
# Remove DC so it doesn't just offset the spectrum
|
|
481
|
+
baseline = baseline - baseline.mean()
|
|
482
|
+
# Scale relative to your spectrum amplitude
|
|
483
|
+
peak = np.max(np.abs(np.real(spec))) or 1.0
|
|
484
|
+
bas_peak = np.max(np.abs(np.real(baseline))) or 1.0
|
|
485
|
+
baseline = baseline * (self.baseline_frac * peak / bas_peak)
|
|
486
|
+
if self.phase_deg:
|
|
487
|
+
baseline = baseline * np.exp(1j * np.deg2rad(self.phase_deg))
|
|
488
|
+
|
|
489
|
+
# ---- Add to spectrum and return to FID ----
|
|
490
|
+
spec_aug = spec + baseline
|
|
491
|
+
fid_with_baseline = np.fft.fft(np.fft.ifftshift(spec_aug))
|
|
492
|
+
result[i] = fid_with_baseline
|
|
493
|
+
|
|
494
|
+
return result.reshape(original_shape)
|
|
495
|
+
|
|
496
|
+
def _add_polynomial_baseline(self, fid: np.ndarray, sw_hz: float, sf_mhz: float,
|
|
497
|
+
ref_ppm: float = 4.7) -> np.ndarray:
|
|
498
|
+
"""Add polynomial baseline to FID (matches legacy baseline_add_poly exactly).
|
|
499
|
+
|
|
500
|
+
Fits a polynomial to the actual spectrum values at baseline-region points,
|
|
501
|
+
then adds the resulting smooth polynomial curve to the full spectrum.
|
|
502
|
+
This reproduces the behaviour of augment_mrs.py ``baseline_add_poly``.
|
|
503
|
+
"""
|
|
504
|
+
original_shape = fid.shape
|
|
505
|
+
N = original_shape[-1]
|
|
506
|
+
fid_2d = fid.reshape(-1, N)
|
|
507
|
+
result = np.zeros_like(fid_2d)
|
|
508
|
+
|
|
509
|
+
for i in range(fid_2d.shape[0]):
|
|
510
|
+
fid_1d = fid_2d[i]
|
|
511
|
+
|
|
512
|
+
# calculate_ppm_and_mask uses ifft internally (correct for FID→spectrum)
|
|
513
|
+
spec, N_pts, mask_baseline = calculate_ppm_and_mask(fid_1d, sw_hz, sf_mhz, ref_ppm, self.ppm_windows)
|
|
514
|
+
|
|
515
|
+
idx = np.flatnonzero(mask_baseline)
|
|
516
|
+
x = np.arange(N_pts)
|
|
517
|
+
|
|
518
|
+
# Fit polynomial to the actual spectrum values at baseline points
|
|
519
|
+
# (exact match to legacy baseline_add_poly)
|
|
520
|
+
cr = np.polyfit(idx, np.real(spec)[idx], self.order)
|
|
521
|
+
ci = np.polyfit(idx, np.imag(spec)[idx], self.order)
|
|
522
|
+
fit = np.polyval(cr, x) + 1j * np.polyval(ci, x)
|
|
523
|
+
|
|
524
|
+
# Add baseline to spectrum and convert back using fft (spectrum→FID, MRS convention)
|
|
525
|
+
spec_corr = spec + fit
|
|
526
|
+
fid_with_baseline = np.fft.fft(np.fft.ifftshift(spec_corr))
|
|
527
|
+
result[i] = fid_with_baseline
|
|
528
|
+
|
|
529
|
+
return result.reshape(original_shape)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
|