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,259 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# phase_frequency.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 zero-order phase, first-order phase, and frequency shift augmentations. #
|
|
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 fft, ifft, fftshift, ifftshift, match_backend
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PhaseShift(BaseModule):
|
|
23
|
+
"""
|
|
24
|
+
Apply phase shifts to MRS data.
|
|
25
|
+
|
|
26
|
+
Supports:
|
|
27
|
+
- Zero-order phase: Constant phase shift across entire spectrum
|
|
28
|
+
- First-order phase: Linear phase ramp across spectrum
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
zero_order_deg : float
|
|
33
|
+
Zero-order phase shift in degrees (default: 0.0)
|
|
34
|
+
first_order_deg : float
|
|
35
|
+
First-order phase shift in degrees (default: 0.0)
|
|
36
|
+
Applied as linear ramp from left to right edge
|
|
37
|
+
|
|
38
|
+
Examples
|
|
39
|
+
--------
|
|
40
|
+
>>> # Zero-order phase only
|
|
41
|
+
>>> phase = PhaseShift(zero_order_deg=60.0)
|
|
42
|
+
>>>
|
|
43
|
+
>>> # First-order phase only
|
|
44
|
+
>>> phase = PhaseShift(first_order_deg=90.0)
|
|
45
|
+
>>>
|
|
46
|
+
>>> # Both
|
|
47
|
+
>>> phase = PhaseShift(zero_order_deg=30.0, first_order_deg=45.0)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
51
|
+
|
|
52
|
+
def __init__(self, zero_order_deg: float = 0.0, first_order_deg: float = 0.0):
|
|
53
|
+
"""Initialize phase shift module."""
|
|
54
|
+
super().__init__(zero_order_deg=zero_order_deg, first_order_deg=first_order_deg)
|
|
55
|
+
|
|
56
|
+
self.zero_order_deg = zero_order_deg
|
|
57
|
+
self.first_order_deg = first_order_deg
|
|
58
|
+
|
|
59
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
60
|
+
"""
|
|
61
|
+
Apply phase shift to list of NIFTI_MRS objects.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
data_list: List of NIFTI_MRS objects
|
|
65
|
+
water_list: Optional list of water reference NIFTI_MRS objects
|
|
66
|
+
**kwargs: Additional arguments
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
70
|
+
"""
|
|
71
|
+
processed_data = []
|
|
72
|
+
|
|
73
|
+
for nifti in data_list:
|
|
74
|
+
# Get FID data
|
|
75
|
+
fid = nifti[:]
|
|
76
|
+
|
|
77
|
+
# Get spectral width (for first-order phase)
|
|
78
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
79
|
+
|
|
80
|
+
# Apply phase shifts
|
|
81
|
+
fid_phased = self._apply_phase(fid, sw_hz)
|
|
82
|
+
|
|
83
|
+
# Update NIFTI_MRS data
|
|
84
|
+
nifti[:] = fid_phased
|
|
85
|
+
processed_data.append(nifti)
|
|
86
|
+
|
|
87
|
+
return processed_data, water_list
|
|
88
|
+
|
|
89
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
90
|
+
"""
|
|
91
|
+
Apply phase shift to tensor/array data (**any backend**).
|
|
92
|
+
|
|
93
|
+
Uses ``keras.ops`` — works with NumPy, PyTorch, JAX, or TensorFlow.
|
|
94
|
+
Gradients are preserved.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
98
|
+
water_array: Optional water reference tensor (unchanged)
|
|
99
|
+
backend: Backend enum (unused — ops dispatch automatically)
|
|
100
|
+
**kwargs: Must contain ``'sw_hz'`` (spectral width in Hz)
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Tuple of (processed_data, water_array)
|
|
104
|
+
"""
|
|
105
|
+
sw_hz = kwargs.get('sw_hz', 1.0)
|
|
106
|
+
result = self._apply_phase(data_array, sw_hz)
|
|
107
|
+
return result, water_array
|
|
108
|
+
|
|
109
|
+
def _apply_phase(self, fid, sw_hz: float):
|
|
110
|
+
"""
|
|
111
|
+
Apply phase shifts to FID data (any backend tensor).
|
|
112
|
+
|
|
113
|
+
Zero-order phase is a scalar complex multiplication (fully vectorised).
|
|
114
|
+
First-order phase requires FFT → ramp → IFFT (uses tensor_ops).
|
|
115
|
+
"""
|
|
116
|
+
# Zero-order: fully vectorised scalar multiply
|
|
117
|
+
if self.zero_order_deg != 0.0:
|
|
118
|
+
phi = math.radians(self.zero_order_deg)
|
|
119
|
+
# complex scalar * any-backend tensor — works everywhere
|
|
120
|
+
phase_factor = np.exp(-1j * phi)
|
|
121
|
+
fid = fid * match_backend(np.array(phase_factor), fid)
|
|
122
|
+
|
|
123
|
+
# First-order: needs spectral domain
|
|
124
|
+
if self.first_order_deg != 0.0:
|
|
125
|
+
fid = self._first_order_phase(fid, self.first_order_deg)
|
|
126
|
+
|
|
127
|
+
return fid
|
|
128
|
+
|
|
129
|
+
@staticmethod
|
|
130
|
+
def _zero_order_phase(fid, phase_deg: float):
|
|
131
|
+
"""Apply zero-order phase shift (any backend tensor)."""
|
|
132
|
+
phi_rad = math.radians(phase_deg)
|
|
133
|
+
factor = np.array(np.exp(-1j * phi_rad))
|
|
134
|
+
return fid * match_backend(factor, fid)
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _first_order_phase(fid, phc1_deg: float):
|
|
138
|
+
"""
|
|
139
|
+
Apply first-order phase shift (any backend tensor).
|
|
140
|
+
|
|
141
|
+
Uses backend-agnostic FFT from tensor_ops.
|
|
142
|
+
The linear phase ramp is a numpy array; multiplication with the
|
|
143
|
+
spectrum tensor auto-promotes to the correct backend.
|
|
144
|
+
"""
|
|
145
|
+
# FFT to spectrum (backend-agnostic)
|
|
146
|
+
spec = fftshift(ifft(fid))
|
|
147
|
+
N = fid.shape[-1]
|
|
148
|
+
|
|
149
|
+
# Linear ramp (numpy — no gradients needed for coordinates)
|
|
150
|
+
u = np.linspace(0.0, 1.0, N, dtype=np.float64)
|
|
151
|
+
ramp_shape = [1] * (len(fid.shape) - 1) + [N]
|
|
152
|
+
ramp = np.exp(1j * np.deg2rad(phc1_deg * u)).reshape(ramp_shape)
|
|
153
|
+
|
|
154
|
+
# Apply ramp and back to FID (backend-agnostic)
|
|
155
|
+
return fft(ifftshift(spec * match_backend(ramp, spec)))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class FrequencyShift(BaseModule):
|
|
159
|
+
"""
|
|
160
|
+
Apply frequency shift to MRS data.
|
|
161
|
+
|
|
162
|
+
Shifts the entire spectrum by a specified frequency offset in Hz.
|
|
163
|
+
Typical range: [-40, 40] Hz, safe mode: [-20, 20] Hz.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
shift_hz : float
|
|
168
|
+
Frequency shift in Hz (positive = upfield shift)
|
|
169
|
+
|
|
170
|
+
Examples
|
|
171
|
+
--------
|
|
172
|
+
>>> # Shift by +10 Hz
|
|
173
|
+
>>> freq_shift = FrequencyShift(shift_hz=10.0)
|
|
174
|
+
>>>
|
|
175
|
+
>>> # Shift by -20 Hz
|
|
176
|
+
>>> freq_shift = FrequencyShift(shift_hz=-20.0)
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
180
|
+
|
|
181
|
+
def __init__(self, shift_hz: float = 0.0):
|
|
182
|
+
"""Initialize frequency shift module."""
|
|
183
|
+
super().__init__(shift_hz=shift_hz)
|
|
184
|
+
|
|
185
|
+
self.shift_hz = shift_hz
|
|
186
|
+
|
|
187
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
188
|
+
"""
|
|
189
|
+
Apply frequency shift to list of NIFTI_MRS objects.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
data_list: List of NIFTI_MRS objects
|
|
193
|
+
water_list: Optional list of water reference NIFTI_MRS objects
|
|
194
|
+
**kwargs: Additional arguments
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
198
|
+
"""
|
|
199
|
+
processed_data = []
|
|
200
|
+
|
|
201
|
+
for nifti in data_list:
|
|
202
|
+
# Get FID data
|
|
203
|
+
fid = nifti[:]
|
|
204
|
+
|
|
205
|
+
# Get spectral width
|
|
206
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
207
|
+
|
|
208
|
+
# Apply frequency shift
|
|
209
|
+
fid_shifted = self._apply_shift(fid, sw_hz)
|
|
210
|
+
|
|
211
|
+
# Update NIFTI_MRS data
|
|
212
|
+
nifti[:] = fid_shifted
|
|
213
|
+
processed_data.append(nifti)
|
|
214
|
+
|
|
215
|
+
return processed_data, water_list
|
|
216
|
+
|
|
217
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
218
|
+
"""
|
|
219
|
+
Apply frequency shift to tensor/array data (**any backend**).
|
|
220
|
+
|
|
221
|
+
Uses ``keras.ops`` — works with NumPy, PyTorch, JAX, or TensorFlow.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
225
|
+
water_array: Optional water reference tensor (unchanged)
|
|
226
|
+
backend: Backend enum (unused — ops dispatch automatically)
|
|
227
|
+
**kwargs: Must contain ``'sw_hz'`` (spectral width in Hz)
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Tuple of (processed_data, water_array)
|
|
231
|
+
"""
|
|
232
|
+
sw_hz = kwargs.get('sw_hz')
|
|
233
|
+
if sw_hz is None:
|
|
234
|
+
raise ValueError("FrequencyShift.process_tensor requires 'sw_hz' in kwargs")
|
|
235
|
+
|
|
236
|
+
result = self._apply_shift(data_array, sw_hz)
|
|
237
|
+
return result, water_array
|
|
238
|
+
|
|
239
|
+
def _apply_shift(self, fid, sw_hz: float):
|
|
240
|
+
"""
|
|
241
|
+
Apply frequency shift to FID data (any backend tensor).
|
|
242
|
+
|
|
243
|
+
The shift phasor is a numpy array; multiplication with the FID tensor
|
|
244
|
+
auto-promotes to the correct backend.
|
|
245
|
+
"""
|
|
246
|
+
if self.shift_hz == 0.0:
|
|
247
|
+
return fid
|
|
248
|
+
|
|
249
|
+
N = fid.shape[-1]
|
|
250
|
+
|
|
251
|
+
# Time axis (numpy — no gradients needed for coordinates)
|
|
252
|
+
t = np.arange(N, dtype=np.float64) / float(sw_hz)
|
|
253
|
+
t = t.reshape([1] * (len(fid.shape) - 1) + [N])
|
|
254
|
+
|
|
255
|
+
# Shift phasor (numpy complex)
|
|
256
|
+
shift_factor = np.exp(1j * 2.0 * math.pi * float(self.shift_hz) * t)
|
|
257
|
+
|
|
258
|
+
# Multiply: convert phasor to same backend, preserves gradients
|
|
259
|
+
return fid * match_backend(shift_factor, fid)
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# residual_water.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: Residual water augmentation using original working implementation from augment_mrs.py #
|
|
12
|
+
# (LaMaster et al.'s Lorentzian water peaks in frequency domain) #
|
|
13
|
+
# #
|
|
14
|
+
####################################################################################################
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from typing import Optional, List
|
|
18
|
+
from augmentrum.core.base_module import BaseModule
|
|
19
|
+
from augmentrum.core.nifti_mrs_plus import Backend, NIfTI_MRS_Plus
|
|
20
|
+
from augmentrum.utils.tensor_ops import fft, ifft, fftshift, ifftshift, to_numpy, match_backend
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ============================================================================
|
|
24
|
+
# Helper Function (from original augment_mrs.py lines 379-408)
|
|
25
|
+
# ============================================================================
|
|
26
|
+
|
|
27
|
+
def add_residual_water(spec, ppm_axis, *, center_ppm=4.7,
|
|
28
|
+
peaks=((0.0, 0.20, 1.0), # (delta_ppm, FWHM_ppm, rel_amp)
|
|
29
|
+
(+0.12, 0.18, 0.4),
|
|
30
|
+
(-0.15, 0.25, 0.3)),
|
|
31
|
+
phase_deg=0.0, amplitude_scale=0.1):
|
|
32
|
+
"""
|
|
33
|
+
Add Lorentzian residual water lobes (original implementation).
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
spec: Complex spectrum
|
|
37
|
+
ppm_axis: PPM axis
|
|
38
|
+
center_ppm: Center position of water peak (default: 4.7 ppm)
|
|
39
|
+
peaks: Tuple of (delta_ppm, FWHM_ppm, rel_amp) for each lobe
|
|
40
|
+
phase_deg: Phase of water peaks in degrees
|
|
41
|
+
amplitude_scale: Scale factor relative to spectrum max (default: 0.1 = 10%)
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Spectrum with water peaks added
|
|
45
|
+
"""
|
|
46
|
+
s = np.asarray(spec, dtype=complex)
|
|
47
|
+
ppm = np.asarray(ppm_axis, float)
|
|
48
|
+
|
|
49
|
+
phi = np.deg2rad(phase_deg)
|
|
50
|
+
w = np.zeros_like(ppm, float)
|
|
51
|
+
|
|
52
|
+
# Simple Lorentzians
|
|
53
|
+
for dppm, fwhm_ppm, rel_amp in peaks:
|
|
54
|
+
x = ppm - (center_ppm + dppm)
|
|
55
|
+
hw = 0.5 * fwhm_ppm
|
|
56
|
+
w += rel_amp * (hw / (x**2 + hw**2)) / np.pi
|
|
57
|
+
|
|
58
|
+
# Normalize lobes to ~unit max, then scale to a fraction of spec
|
|
59
|
+
w /= np.max(w) if np.max(w) > 0 else 1.0
|
|
60
|
+
water_amp = amplitude_scale * np.max(np.abs(np.real(s)))
|
|
61
|
+
water = water_amp * w * (np.cos(phi) + 1j*np.sin(phi))
|
|
62
|
+
|
|
63
|
+
return s + water
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ============================================================================
|
|
67
|
+
# ResidualWater Module
|
|
68
|
+
# ============================================================================
|
|
69
|
+
|
|
70
|
+
class ResidualWater(BaseModule):
|
|
71
|
+
"""
|
|
72
|
+
Add residual water peaks to MRS data.
|
|
73
|
+
|
|
74
|
+
Based on the original implementation from augment_mrs.py (LaMaster et al.).
|
|
75
|
+
Adds Lorentzian-shaped water peaks around 4.7 ppm to simulate imperfect
|
|
76
|
+
water suppression.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
center_ppm : float
|
|
81
|
+
Center position of water peak in ppm (default: 4.7)
|
|
82
|
+
peaks : tuple of tuples
|
|
83
|
+
Each tuple is (delta_ppm, FWHM_ppm, rel_amp)
|
|
84
|
+
- delta_ppm: offset from center in ppm
|
|
85
|
+
- FWHM_ppm: Full Width at Half Maximum in ppm
|
|
86
|
+
- rel_amp: relative amplitude
|
|
87
|
+
Default: Three lobes at 0.0, +0.12, -0.15 ppm
|
|
88
|
+
phase_deg : float
|
|
89
|
+
Phase of water peaks in degrees (default: 0.0)
|
|
90
|
+
amplitude_scale : float
|
|
91
|
+
Scale factor for water amplitude relative to spectrum (default: 0.1 = 10%)
|
|
92
|
+
|
|
93
|
+
Examples
|
|
94
|
+
--------
|
|
95
|
+
>>> # Default water (10% amplitude, 3 lobes)
|
|
96
|
+
>>> water = ResidualWater()
|
|
97
|
+
>>> result_data, _ = water(nifti_plus, None)
|
|
98
|
+
|
|
99
|
+
>>> # Subtle water (5%)
|
|
100
|
+
>>> water = ResidualWater(amplitude_scale=0.05)
|
|
101
|
+
|
|
102
|
+
>>> # Strong water (30%)
|
|
103
|
+
>>> water = ResidualWater(amplitude_scale=0.30)
|
|
104
|
+
|
|
105
|
+
>>> # Custom peaks
|
|
106
|
+
>>> water = ResidualWater(
|
|
107
|
+
... peaks=((0.0, 0.25, 1.0), (0.10, 0.20, 0.5)),
|
|
108
|
+
... phase_deg=15.0
|
|
109
|
+
... )
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
113
|
+
|
|
114
|
+
def __init__(self, center_ppm: float = 4.7,
|
|
115
|
+
peaks: tuple = ((0.0, 0.20, 1.0),
|
|
116
|
+
(0.12, 0.18, 0.4),
|
|
117
|
+
(-0.15, 0.25, 0.3)),
|
|
118
|
+
phase_deg: float = 0.0,
|
|
119
|
+
amplitude_scale: float = 0.1):
|
|
120
|
+
"""Initialize residual water module."""
|
|
121
|
+
super().__init__(center_ppm=center_ppm, peaks=peaks,
|
|
122
|
+
phase_deg=phase_deg, amplitude_scale=amplitude_scale)
|
|
123
|
+
|
|
124
|
+
self.center_ppm = center_ppm
|
|
125
|
+
self.peaks = peaks
|
|
126
|
+
self.phase_deg = phase_deg
|
|
127
|
+
self.amplitude_scale = amplitude_scale
|
|
128
|
+
|
|
129
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
130
|
+
"""
|
|
131
|
+
Add residual water to list of NIFTI_MRS objects.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
data_list: List of NIFTI_MRS objects
|
|
135
|
+
water_list: Optional list of water reference NIFTI_MRS objects (unchanged)
|
|
136
|
+
**kwargs: Additional arguments
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Tuple of (processed_data_list, water_list)
|
|
140
|
+
"""
|
|
141
|
+
processed_data = []
|
|
142
|
+
|
|
143
|
+
for nifti in data_list:
|
|
144
|
+
# Get FID data
|
|
145
|
+
fid = nifti[:]
|
|
146
|
+
|
|
147
|
+
# Get parameters
|
|
148
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
149
|
+
sf_mhz = nifti.spectrometer_frequency[0]
|
|
150
|
+
|
|
151
|
+
# Add water to FID
|
|
152
|
+
fid_with_water = self._add_water_to_fid(fid, sw_hz, sf_mhz)
|
|
153
|
+
|
|
154
|
+
# Update NIFTI_MRS data
|
|
155
|
+
nifti[:] = fid_with_water
|
|
156
|
+
processed_data.append(nifti)
|
|
157
|
+
|
|
158
|
+
return processed_data, water_list
|
|
159
|
+
|
|
160
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
161
|
+
"""
|
|
162
|
+
Add residual water peaks to tensor/array data (**any backend**).
|
|
163
|
+
|
|
164
|
+
FFT/IFFT are done **natively** via ``tensor_ops``. Water peak shapes
|
|
165
|
+
and amplitude scaling are computed in NumPy per-FID, then promoted
|
|
166
|
+
to the target backend via ``match_backend``.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
170
|
+
water_array: Optional water reference (unchanged)
|
|
171
|
+
backend: Backend enum (unused)
|
|
172
|
+
**kwargs: Must contain ``'sw_hz'`` and ``'sf_mhz'``
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Tuple of (processed_data, water_array)
|
|
176
|
+
"""
|
|
177
|
+
sw_hz = kwargs.get('sw_hz')
|
|
178
|
+
sf_mhz = kwargs.get('sf_mhz')
|
|
179
|
+
if sw_hz is None or sf_mhz is None:
|
|
180
|
+
raise ValueError("ResidualWater.process_tensor requires 'sw_hz' and 'sf_mhz' in kwargs")
|
|
181
|
+
|
|
182
|
+
N = data_array.shape[-1]
|
|
183
|
+
n_batch = int(np.prod(data_array.shape[:-1]))
|
|
184
|
+
ref_ppm = 4.7
|
|
185
|
+
|
|
186
|
+
# 1. Spectrum (backend-native FFT)
|
|
187
|
+
spec = fftshift(ifft(data_array))
|
|
188
|
+
|
|
189
|
+
# 2. ppm axis (numpy)
|
|
190
|
+
dt = 1.0 / float(sw_hz)
|
|
191
|
+
freq_hz = np.fft.fftshift(np.fft.fftfreq(N, d=dt))
|
|
192
|
+
ppm = ref_ppm - freq_hz / float(sf_mhz)
|
|
193
|
+
|
|
194
|
+
# 3. Compute additive water contribution per FID (numpy)
|
|
195
|
+
spec_np = to_numpy(spec).reshape(n_batch, N)
|
|
196
|
+
water_add_np = np.zeros((n_batch, N), dtype=np.complex128)
|
|
197
|
+
|
|
198
|
+
for i in range(n_batch):
|
|
199
|
+
spec_with = add_residual_water(
|
|
200
|
+
spec_np[i], ppm,
|
|
201
|
+
center_ppm=self.center_ppm,
|
|
202
|
+
peaks=self.peaks,
|
|
203
|
+
phase_deg=self.phase_deg,
|
|
204
|
+
amplitude_scale=self.amplitude_scale,
|
|
205
|
+
)
|
|
206
|
+
water_add_np[i] = spec_with - spec_np[i]
|
|
207
|
+
|
|
208
|
+
water_add_np = water_add_np.reshape(data_array.shape)
|
|
209
|
+
|
|
210
|
+
# 4. Add water in spectral domain (backend-native)
|
|
211
|
+
spec_aug = spec + match_backend(water_add_np, spec)
|
|
212
|
+
|
|
213
|
+
# 5. Back to FID (backend-native IFFT)
|
|
214
|
+
return fft(ifftshift(spec_aug)), water_array
|
|
215
|
+
|
|
216
|
+
def _add_water_to_fid(self, fid: np.ndarray, sw_hz: float, sf_mhz: float,
|
|
217
|
+
ref_ppm: float = 4.7) -> np.ndarray:
|
|
218
|
+
"""
|
|
219
|
+
Add water peaks to FID data using original augment_mrs.py implementation.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
fid: Input FID data
|
|
223
|
+
sw_hz: Spectral width in Hz
|
|
224
|
+
sf_mhz: Spectrometer frequency in MHz
|
|
225
|
+
ref_ppm: Reference ppm (default: 4.7)
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
FID with water peaks added
|
|
229
|
+
"""
|
|
230
|
+
# Work with last dimension (FID points)
|
|
231
|
+
original_shape = fid.shape
|
|
232
|
+
N = original_shape[-1]
|
|
233
|
+
|
|
234
|
+
# Reshape to 2D for processing
|
|
235
|
+
fid_2d = fid.reshape(-1, N)
|
|
236
|
+
result = np.zeros_like(fid_2d)
|
|
237
|
+
|
|
238
|
+
# Process each FID
|
|
239
|
+
for i in range(fid_2d.shape[0]):
|
|
240
|
+
fid_1d = fid_2d[i]
|
|
241
|
+
|
|
242
|
+
# 1) IFFT to frequency domain (FID -> spectrum) - MRS convention
|
|
243
|
+
spec = np.fft.fftshift(np.fft.ifft(fid_1d))
|
|
244
|
+
|
|
245
|
+
# 2) Create ppm axis
|
|
246
|
+
dt = 1.0 / float(sw_hz)
|
|
247
|
+
freq_hz = np.fft.fftshift(np.fft.fftfreq(N, d=dt))
|
|
248
|
+
ppm = ref_ppm - freq_hz / sf_mhz
|
|
249
|
+
|
|
250
|
+
# 3) Add water peaks in frequency domain
|
|
251
|
+
spec_with_water = add_residual_water(
|
|
252
|
+
spec, ppm,
|
|
253
|
+
center_ppm=self.center_ppm,
|
|
254
|
+
peaks=self.peaks,
|
|
255
|
+
phase_deg=self.phase_deg,
|
|
256
|
+
amplitude_scale=self.amplitude_scale
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# 4) FFT back to time domain (spectrum -> FID) - MRS convention
|
|
260
|
+
fid_with_water = np.fft.fft(np.fft.ifftshift(spec_with_water))
|
|
261
|
+
result[i] = fid_with_water
|
|
262
|
+
|
|
263
|
+
# Reshape back to original shape
|
|
264
|
+
return result.reshape(original_shape)
|