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