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,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
+