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,378 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# spurious_echoes.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: Adds delayed echo replicas (ghosting artifacts) to MRS data. #
|
|
11
|
+
# Supports simple replica model and hybrid model (Kyathanahally et al. 2021, #
|
|
12
|
+
# Bugler et al. 2025). #
|
|
13
|
+
# #
|
|
14
|
+
####################################################################################################
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from typing import Optional, List, Tuple, Union
|
|
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 to_numpy, match_backend
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpuriousEchoes(BaseModule):
|
|
24
|
+
"""
|
|
25
|
+
Add spurious echo artifacts to MRS data.
|
|
26
|
+
|
|
27
|
+
Simulates ghosting artifacts by adding delayed, attenuated copies
|
|
28
|
+
of the FID signal at specified time delays.
|
|
29
|
+
|
|
30
|
+
Supports two modes:
|
|
31
|
+
- 'replica': Simple multiplicative echo — multiplies FID by a decaying
|
|
32
|
+
envelope after a delay (classic replica model).
|
|
33
|
+
- 'hybrid': Physically shifts the FID in time, then shapes with a
|
|
34
|
+
localized envelope (Kyathanahally/Bugler model). Produces more
|
|
35
|
+
realistic ghosting artifacts.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
echoes : list of dicts or tuples
|
|
40
|
+
Each echo specifies:
|
|
41
|
+
- delay_s / tau: Echo delay time in seconds
|
|
42
|
+
- amp / alpha: Relative amplitude (vs max |FID|)
|
|
43
|
+
- phase_deg: Additional phase for this echo (degrees)
|
|
44
|
+
- decay_hz: Exponential decay in Hz (replica mode)
|
|
45
|
+
- freq_hz / df_hz: Frequency offset of echo (Hz)
|
|
46
|
+
|
|
47
|
+
For hybrid mode, additional per-echo keys:
|
|
48
|
+
- t_echo: Echo center time in seconds (defaults to tau)
|
|
49
|
+
- T2: Envelope time constant in seconds (defaults to 0.04)
|
|
50
|
+
- gaussian_env: Use Gaussian envelope (bool, default False)
|
|
51
|
+
|
|
52
|
+
mode : str
|
|
53
|
+
'replica' (default) or 'hybrid'
|
|
54
|
+
global_phase_deg : float
|
|
55
|
+
Global phase offset for all echoes (default: 0.0)
|
|
56
|
+
alpha_reference : str
|
|
57
|
+
How amplitude is referenced in hybrid mode:
|
|
58
|
+
'max' (default) = fraction of max(|FID|),
|
|
59
|
+
'tau' = fraction of |FID| at delay time
|
|
60
|
+
|
|
61
|
+
Examples
|
|
62
|
+
--------
|
|
63
|
+
>>> # Replica mode (simple)
|
|
64
|
+
>>> se = SpuriousEchoes(
|
|
65
|
+
... echoes=[{'delay_s': 0.1, 'amp': 0.3, 'phase_deg': 0,
|
|
66
|
+
... 'decay_hz': 5.0, 'freq_hz': 0.0}]
|
|
67
|
+
... )
|
|
68
|
+
>>> result_data, _ = se(nifti_plus, None)
|
|
69
|
+
|
|
70
|
+
>>> # Hybrid mode (matches legacy add_spurious_echo_artifact)
|
|
71
|
+
>>> se = SpuriousEchoes(
|
|
72
|
+
... mode='hybrid',
|
|
73
|
+
... echoes=[{'tau': 0.18, 'alpha': 0.32, 'phase_deg': 25,
|
|
74
|
+
... 't_echo': 0.26, 'T2': 0.03, 'df_hz': 35.0}]
|
|
75
|
+
... )
|
|
76
|
+
>>> result_data, _ = se(nifti_plus, None)
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
80
|
+
|
|
81
|
+
def __init__(self, echoes=None,
|
|
82
|
+
mode: str = 'replica',
|
|
83
|
+
global_phase_deg: float = 0.0,
|
|
84
|
+
alpha_reference: str = 'max'):
|
|
85
|
+
"""Initialize spurious echoes module."""
|
|
86
|
+
if echoes is None:
|
|
87
|
+
echoes = [{'delay_s': 0.1, 'amp': 0.2, 'phase_deg': 0.0,
|
|
88
|
+
'decay_hz': 5.0, 'freq_hz': 0.0}]
|
|
89
|
+
|
|
90
|
+
super().__init__(echoes=echoes, mode=mode,
|
|
91
|
+
global_phase_deg=global_phase_deg,
|
|
92
|
+
alpha_reference=alpha_reference)
|
|
93
|
+
|
|
94
|
+
self.mode = mode.lower()
|
|
95
|
+
if self.mode not in ('replica', 'hybrid'):
|
|
96
|
+
raise ValueError(f"mode must be 'replica' or 'hybrid', got '{mode}'")
|
|
97
|
+
|
|
98
|
+
self.global_phase_deg = global_phase_deg
|
|
99
|
+
self.alpha_reference = alpha_reference
|
|
100
|
+
|
|
101
|
+
# Normalize echoes to a list of dicts
|
|
102
|
+
self.echoes = []
|
|
103
|
+
for echo in echoes:
|
|
104
|
+
if isinstance(echo, dict):
|
|
105
|
+
self.echoes.append(dict(echo))
|
|
106
|
+
elif isinstance(echo, (tuple, list)):
|
|
107
|
+
# Legacy tuple: (delay_s, amp, phase_deg, decay_hz, freq_hz)
|
|
108
|
+
self.echoes.append({
|
|
109
|
+
'delay_s': echo[0],
|
|
110
|
+
'amp': echo[1],
|
|
111
|
+
'phase_deg': echo[2] if len(echo) > 2 else 0.0,
|
|
112
|
+
'decay_hz': echo[3] if len(echo) > 3 else 5.0,
|
|
113
|
+
'freq_hz': echo[4] if len(echo) > 4 else 0.0,
|
|
114
|
+
})
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(f"Echo must be a dict or tuple, got {type(echo)}")
|
|
117
|
+
|
|
118
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
119
|
+
"""
|
|
120
|
+
Add spurious echoes to list of NIFTI_MRS objects.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
data_list: List of NIFTI_MRS objects
|
|
124
|
+
water_list: Optional list of water reference NIFTI_MRS objects
|
|
125
|
+
**kwargs: Additional arguments
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
129
|
+
"""
|
|
130
|
+
processed_data = []
|
|
131
|
+
|
|
132
|
+
for nifti in data_list:
|
|
133
|
+
# Get FID data
|
|
134
|
+
fid = nifti[:]
|
|
135
|
+
|
|
136
|
+
# Get spectral width
|
|
137
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
138
|
+
|
|
139
|
+
# Add echoes
|
|
140
|
+
fid_with_echoes = self._add_echoes(fid, sw_hz)
|
|
141
|
+
|
|
142
|
+
# Update NIFTI_MRS data
|
|
143
|
+
nifti[:] = fid_with_echoes
|
|
144
|
+
processed_data.append(nifti)
|
|
145
|
+
|
|
146
|
+
return processed_data, water_list
|
|
147
|
+
|
|
148
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
149
|
+
"""
|
|
150
|
+
Add spurious echo artifacts to tensor/array data (**any backend**).
|
|
151
|
+
|
|
152
|
+
**Replica mode**: echo envelopes are data-independent (pure numpy)
|
|
153
|
+
and applied via ``data + data * match_backend(envelope, data)`` —
|
|
154
|
+
fully backend-native multiply/add.
|
|
155
|
+
|
|
156
|
+
**Hybrid mode**: echo amplitude is referenced to ``max|FID|`` which
|
|
157
|
+
requires a scalar pull to numpy; the additive ghost is then applied
|
|
158
|
+
via ``data + match_backend(ghost_additive, data)``.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
162
|
+
water_array: Optional water reference (unchanged)
|
|
163
|
+
backend: Backend enum (unused)
|
|
164
|
+
**kwargs: Must contain ``'sw_hz'`` (spectral width in Hz)
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Tuple of (processed_data, water_array)
|
|
168
|
+
"""
|
|
169
|
+
sw_hz = kwargs.get('sw_hz')
|
|
170
|
+
if sw_hz is None:
|
|
171
|
+
raise ValueError("SpuriousEchoes.process_tensor requires 'sw_hz' in kwargs")
|
|
172
|
+
|
|
173
|
+
original_shape = data_array.shape
|
|
174
|
+
N = original_shape[-1]
|
|
175
|
+
|
|
176
|
+
if self.mode == 'replica':
|
|
177
|
+
# Envelope is purely time-based — no data dependency at all
|
|
178
|
+
t = np.arange(N, dtype=np.float64) / float(sw_hz)
|
|
179
|
+
t_shape = [1] * (len(original_shape) - 1) + [N]
|
|
180
|
+
t = t.reshape(t_shape)
|
|
181
|
+
|
|
182
|
+
gphi = np.deg2rad(self.global_phase_deg)
|
|
183
|
+
echo_sum = np.zeros(t_shape, dtype=np.complex128)
|
|
184
|
+
|
|
185
|
+
for echo in self.echoes:
|
|
186
|
+
delay_s = echo.get('delay_s', echo.get('tau', 0.1))
|
|
187
|
+
amp = echo.get('amp', echo.get('alpha', echo.get('amplitude', 0.1)))
|
|
188
|
+
phase_deg = echo.get('phase_deg', 0.0)
|
|
189
|
+
decay_hz = echo.get('decay_hz', echo.get('decay', 5.0))
|
|
190
|
+
freq_hz = echo.get('freq_hz', echo.get('df_hz', 0.0))
|
|
191
|
+
|
|
192
|
+
if delay_s > 1.0:
|
|
193
|
+
delay_s = delay_s / float(sw_hz)
|
|
194
|
+
|
|
195
|
+
u = (t >= delay_s).astype(np.float64)
|
|
196
|
+
td = (t - delay_s) * u
|
|
197
|
+
env = (amp
|
|
198
|
+
* np.exp(1j * (gphi + np.deg2rad(phase_deg)))
|
|
199
|
+
* np.exp(-np.pi * float(decay_hz) * td)
|
|
200
|
+
* np.exp(1j * 2.0 * np.pi * float(freq_hz) * td))
|
|
201
|
+
echo_sum += env
|
|
202
|
+
|
|
203
|
+
# Apply: fid_out = fid + fid * sum_of_envelopes (backend-native)
|
|
204
|
+
return data_array + data_array * match_backend(echo_sum, data_array), water_array
|
|
205
|
+
|
|
206
|
+
else: # hybrid mode — amplitude references max(|fid|): pull per-FID to numpy
|
|
207
|
+
n_batch = int(np.prod(original_shape[:-1]))
|
|
208
|
+
data_np = to_numpy(data_array).reshape(n_batch, N)
|
|
209
|
+
ghost_np = np.zeros_like(data_np)
|
|
210
|
+
|
|
211
|
+
for i in range(n_batch):
|
|
212
|
+
fid_1d = data_np[i]
|
|
213
|
+
result = self._add_echoes_hybrid_1d(fid_1d, sw_hz)
|
|
214
|
+
ghost_np[i] = result - fid_1d # additive contribution only
|
|
215
|
+
|
|
216
|
+
ghost_np = ghost_np.reshape(original_shape)
|
|
217
|
+
return data_array + match_backend(ghost_np, data_array), water_array
|
|
218
|
+
|
|
219
|
+
def _add_echoes(self, fid: np.ndarray, sw_hz: float) -> np.ndarray:
|
|
220
|
+
"""
|
|
221
|
+
Add spurious echoes to FID data.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
fid: Input FID data
|
|
225
|
+
sw_hz: Spectral width in Hz
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
FID with echoes 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
|
+
if self.mode == 'hybrid':
|
|
242
|
+
result[i] = self._add_echoes_hybrid_1d(fid_1d, sw_hz)
|
|
243
|
+
else:
|
|
244
|
+
result[i] = self._add_echoes_replica_1d(fid_1d, sw_hz)
|
|
245
|
+
|
|
246
|
+
# Reshape back to original shape
|
|
247
|
+
return result.reshape(original_shape)
|
|
248
|
+
|
|
249
|
+
# ── Replica mode (original simple model) ──────────────────────────────
|
|
250
|
+
|
|
251
|
+
def _add_echoes_replica_1d(self, fid: np.ndarray, sw_hz: float) -> np.ndarray:
|
|
252
|
+
"""
|
|
253
|
+
Add echoes to 1D FID using replica model.
|
|
254
|
+
|
|
255
|
+
The echo is the original FID multiplied by a decaying, frequency-shifted
|
|
256
|
+
envelope that starts at the delay time.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
fid: 1D FID data
|
|
260
|
+
sw_hz: Spectral width in Hz
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
FID with echoes
|
|
264
|
+
"""
|
|
265
|
+
f = np.asarray(fid, dtype=np.complex128)
|
|
266
|
+
n = f.size
|
|
267
|
+
t = np.arange(n, dtype=float) / float(sw_hz)
|
|
268
|
+
|
|
269
|
+
out = f.copy()
|
|
270
|
+
gphi = np.deg2rad(self.global_phase_deg)
|
|
271
|
+
|
|
272
|
+
for echo in self.echoes:
|
|
273
|
+
delay_val = echo.get('delay_s', echo.get('tau', 0.1))
|
|
274
|
+
amp = echo.get('amp', echo.get('alpha', echo.get('amplitude', 0.1)))
|
|
275
|
+
phase_deg = echo.get('phase_deg', 0.0)
|
|
276
|
+
decay_hz = echo.get('decay_hz', echo.get('decay', 5.0))
|
|
277
|
+
freq_hz = echo.get('freq_hz', echo.get('df_hz', 0.0))
|
|
278
|
+
|
|
279
|
+
# delay_val might be in seconds or points - convert to seconds
|
|
280
|
+
if delay_val > 1.0:
|
|
281
|
+
delay_s = delay_val / float(sw_hz)
|
|
282
|
+
else:
|
|
283
|
+
delay_s = delay_val
|
|
284
|
+
|
|
285
|
+
# Step function: 1 after delay, 0 before
|
|
286
|
+
u = (t >= delay_s).astype(float)
|
|
287
|
+
td = (t - delay_s) * u
|
|
288
|
+
|
|
289
|
+
# Echo signal
|
|
290
|
+
echo_env = (
|
|
291
|
+
amp
|
|
292
|
+
* np.exp(1j * (gphi + np.deg2rad(phase_deg)))
|
|
293
|
+
* np.exp(-np.pi * float(decay_hz) * td)
|
|
294
|
+
* np.exp(1j * 2.0 * np.pi * float(freq_hz) * td)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# Add delayed replica
|
|
298
|
+
out += f * echo_env
|
|
299
|
+
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
# ── Hybrid mode (Kyathanahally / Bugler model) ───────────────────────
|
|
303
|
+
|
|
304
|
+
def _add_echoes_hybrid_1d(self, fid: np.ndarray, sw_hz: float) -> np.ndarray:
|
|
305
|
+
"""
|
|
306
|
+
Add echoes to 1D FID using hybrid model.
|
|
307
|
+
|
|
308
|
+
Physically shifts the FID in time, then applies a localized envelope
|
|
309
|
+
and frequency modulation. Matches the legacy add_spurious_echo_artifact
|
|
310
|
+
(Kyathanahally et al. 2021, Bugler et al. 2025).
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
fid: 1D FID data
|
|
314
|
+
sw_hz: Spectral width in Hz
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
FID with echoes
|
|
318
|
+
"""
|
|
319
|
+
f = np.asarray(fid, dtype=np.complex128)
|
|
320
|
+
n = f.size
|
|
321
|
+
dt = 1.0 / float(sw_hz)
|
|
322
|
+
t = np.arange(n, dtype=float) * dt
|
|
323
|
+
|
|
324
|
+
out = f.copy()
|
|
325
|
+
gphi = np.deg2rad(self.global_phase_deg)
|
|
326
|
+
|
|
327
|
+
for echo in self.echoes:
|
|
328
|
+
# Extract parameters (support both naming conventions)
|
|
329
|
+
alpha = echo.get('alpha', echo.get('amp', echo.get('amplitude', 0.05)))
|
|
330
|
+
phase_deg = echo.get('phase_deg', 0.0)
|
|
331
|
+
T2 = echo.get('T2', 0.04)
|
|
332
|
+
df_hz = echo.get('df_hz', echo.get('freq_hz', 0.0))
|
|
333
|
+
gaussian_env = echo.get('gaussian_env', False)
|
|
334
|
+
|
|
335
|
+
# Determine shift in samples — support delay_pts directly
|
|
336
|
+
if 'delay_pts' in echo:
|
|
337
|
+
shift_pts = int(echo['delay_pts'])
|
|
338
|
+
tau = shift_pts * dt
|
|
339
|
+
else:
|
|
340
|
+
tau = echo.get('tau', echo.get('delay_s', 0.18))
|
|
341
|
+
shift_pts = int(round(tau / dt))
|
|
342
|
+
|
|
343
|
+
t_echo = echo.get('t_echo', tau)
|
|
344
|
+
|
|
345
|
+
# Delayed (physically shifted) copy of the FID
|
|
346
|
+
delayed_fid = np.zeros_like(f)
|
|
347
|
+
if shift_pts < n:
|
|
348
|
+
delayed_fid[shift_pts:] = f[:n - shift_pts]
|
|
349
|
+
|
|
350
|
+
# Phase + frequency modulation
|
|
351
|
+
phase_rad = gphi + np.deg2rad(phase_deg)
|
|
352
|
+
mod = np.exp(1j * (2.0 * np.pi * df_hz * (t - tau) + phase_rad))
|
|
353
|
+
|
|
354
|
+
# Build ghost: handle no-envelope case (T2 >= 1e4) for exact
|
|
355
|
+
# match with simple shift-and-add legacy model
|
|
356
|
+
if T2 >= 1e4:
|
|
357
|
+
# No envelope — simple: ghost = alpha * delayed_fid * mod
|
|
358
|
+
ghost = alpha * delayed_fid * mod
|
|
359
|
+
else:
|
|
360
|
+
# Full hybrid: normalized FID × envelope × amplitude
|
|
361
|
+
if self.alpha_reference == 'tau':
|
|
362
|
+
tau_idx = min(shift_pts, n - 1)
|
|
363
|
+
Ag = alpha * np.abs(f[tau_idx])
|
|
364
|
+
else: # 'max'
|
|
365
|
+
Ag = alpha * (np.max(np.abs(f)) + 1e-30)
|
|
366
|
+
|
|
367
|
+
delayed_norm = delayed_fid / (np.max(np.abs(f)) + 1e-30)
|
|
368
|
+
|
|
369
|
+
if gaussian_env:
|
|
370
|
+
env = np.exp(-((t - t_echo) ** 2) / (2.0 * T2 ** 2))
|
|
371
|
+
else:
|
|
372
|
+
env = np.exp(-np.abs(t - t_echo) / T2)
|
|
373
|
+
|
|
374
|
+
ghost = Ag * delayed_norm * env * mod
|
|
375
|
+
|
|
376
|
+
out += ghost
|
|
377
|
+
|
|
378
|
+
return out
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# zero_fill.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: K. C. Igwe (kci2104@columbia.edu) #
|
|
6
|
+
# J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
7
|
+
# #
|
|
8
|
+
# Created: 2026-03-29 #
|
|
9
|
+
# #
|
|
10
|
+
# Purpose: Zero-fill (zero-pad) or crop FID to a fixed target number of spectral points. #
|
|
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 _is_torch, _is_jax, _is_tf
|
|
19
|
+
from fsl_mrs.core.nifti_mrs import gen_nifti_mrs
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ZeroFill(BaseModule):
|
|
23
|
+
"""
|
|
24
|
+
Zero-pad (or crop) the FID to a fixed target number of spectral points.
|
|
25
|
+
|
|
26
|
+
Zero-filling increases apparent spectral resolution by interpolating
|
|
27
|
+
between frequency bins. Cropping (``target_pts < N_PTS``) is equivalent
|
|
28
|
+
to ``Apodization(mode='truncate')``.
|
|
29
|
+
|
|
30
|
+
Because ``target_pts`` is a module-level scalar, the same length is
|
|
31
|
+
applied to **every item in the batch** — so the output batch remains
|
|
32
|
+
uniform regardless of backend.
|
|
33
|
+
|
|
34
|
+
On non-NIfTI tensor backends, if N_PTS changes the base class detects
|
|
35
|
+
the shape change, rebuilds each NIfTI_MRS object at the new length, and
|
|
36
|
+
emits a ``RuntimeWarning`` (the "medium-speed" path). For maximum
|
|
37
|
+
throughput keep N_PTS the same throughout the whole pipeline.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
target_pts : int
|
|
42
|
+
Target number of spectral points after zero-filling / cropping.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> zf = ZeroFill(target_pts=2048) # double a 1024-point FID
|
|
47
|
+
>>> out, _ = zf(nifti_plus, None)
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
SUPPORTED_BACKENDS = [] # All backends via process_tensor
|
|
51
|
+
|
|
52
|
+
def __init__(self, target_pts: int):
|
|
53
|
+
super().__init__(target_pts=target_pts)
|
|
54
|
+
if int(target_pts) < 1:
|
|
55
|
+
raise ValueError(f"target_pts must be >= 1, got {target_pts}")
|
|
56
|
+
self.target_pts = int(target_pts)
|
|
57
|
+
|
|
58
|
+
# ── NIfTI-list path ────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
61
|
+
"""Apply zero-fill to each NIFTI_MRS object in the list."""
|
|
62
|
+
processed_data = []
|
|
63
|
+
for nifti in data_list:
|
|
64
|
+
fid = nifti[:]
|
|
65
|
+
fid_out = self._apply_numpy(fid)
|
|
66
|
+
|
|
67
|
+
if fid_out.shape[-1] != fid.shape[-1]:
|
|
68
|
+
# N_PTS changed — rebuild NIfTI_MRS with new length
|
|
69
|
+
try:
|
|
70
|
+
affine = nifti.getAffine('voxel', 'world')
|
|
71
|
+
except Exception:
|
|
72
|
+
affine = None
|
|
73
|
+
nucleus = (nifti.nucleus[0]
|
|
74
|
+
if hasattr(nifti, 'nucleus') and nifti.nucleus else '1H')
|
|
75
|
+
dim_tags = (list(nifti.dim_tags)
|
|
76
|
+
if hasattr(nifti, 'dim_tags') else [None, None, None])
|
|
77
|
+
new_nifti = gen_nifti_mrs(
|
|
78
|
+
data=fid_out,
|
|
79
|
+
dwelltime=nifti.dwelltime,
|
|
80
|
+
spec_freq=nifti.spectrometer_frequency[0],
|
|
81
|
+
nucleus=nucleus,
|
|
82
|
+
dim_tags=dim_tags,
|
|
83
|
+
affine=affine,
|
|
84
|
+
)
|
|
85
|
+
if hasattr(nifti, 'hdr_ext'):
|
|
86
|
+
for key in nifti.hdr_ext:
|
|
87
|
+
if key not in ('SpectrometerFrequency', 'ResonantNucleus',
|
|
88
|
+
'dim_5', 'dim_6', 'dim_7'):
|
|
89
|
+
try:
|
|
90
|
+
new_nifti.add_hdr_field(key, nifti.hdr_ext[key])
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
processed_data.append(new_nifti)
|
|
94
|
+
else:
|
|
95
|
+
nifti[:] = fid_out
|
|
96
|
+
processed_data.append(nifti)
|
|
97
|
+
|
|
98
|
+
return processed_data, water_list
|
|
99
|
+
|
|
100
|
+
# ── Tensor path (all non-NIfTI backends) ──────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
103
|
+
"""Apply zero-fill natively in whatever backend format the data is in.
|
|
104
|
+
|
|
105
|
+
Cropping (target_pts < N_PTS) is a slice — free.
|
|
106
|
+
Padding (target_pts > N_PTS) creates a zeros tensor in the native
|
|
107
|
+
backend and concatenates along the last axis — no numpy round-trip,
|
|
108
|
+
gradients preserved.
|
|
109
|
+
"""
|
|
110
|
+
n_in = data_array.shape[-1]
|
|
111
|
+
n_out = self.target_pts
|
|
112
|
+
|
|
113
|
+
if n_out == n_in:
|
|
114
|
+
return data_array, water_array
|
|
115
|
+
|
|
116
|
+
if n_out < n_in:
|
|
117
|
+
# Crop — native slice, works in every backend
|
|
118
|
+
return data_array[..., :n_out], water_array
|
|
119
|
+
|
|
120
|
+
# ── Zero-pad ──────────────────────────────────────────────────────────
|
|
121
|
+
pad_n = n_out - n_in
|
|
122
|
+
pad_shape = list(data_array.shape)
|
|
123
|
+
pad_shape[-1] = pad_n
|
|
124
|
+
|
|
125
|
+
if _is_torch(data_array):
|
|
126
|
+
import torch
|
|
127
|
+
zeros = torch.zeros(pad_shape, dtype=data_array.dtype, device=data_array.device)
|
|
128
|
+
return torch.cat([data_array, zeros], dim=-1), water_array
|
|
129
|
+
|
|
130
|
+
if _is_jax(data_array):
|
|
131
|
+
import jax.numpy as jnp
|
|
132
|
+
zeros = jnp.zeros(pad_shape, dtype=data_array.dtype)
|
|
133
|
+
return jnp.concatenate([data_array, zeros], axis=-1), water_array
|
|
134
|
+
|
|
135
|
+
if _is_tf(data_array):
|
|
136
|
+
import tensorflow as tf
|
|
137
|
+
zeros = tf.zeros(pad_shape, dtype=data_array.dtype)
|
|
138
|
+
return tf.concat([data_array, zeros], axis=-1), water_array
|
|
139
|
+
|
|
140
|
+
# NumPy (and Keras when backed by NumPy / TF caught above)
|
|
141
|
+
dt = data_array.dtype if hasattr(data_array, 'dtype') else np.complex64
|
|
142
|
+
zeros = np.zeros(pad_shape, dtype=dt)
|
|
143
|
+
try:
|
|
144
|
+
return np.concatenate([data_array, zeros], axis=-1), water_array
|
|
145
|
+
except TypeError:
|
|
146
|
+
# Keras tensor — fall back to TF ops
|
|
147
|
+
import tensorflow as tf
|
|
148
|
+
zeros_tf = tf.zeros(pad_shape, dtype=data_array.dtype)
|
|
149
|
+
return tf.concat([data_array, zeros_tf], axis=-1), water_array
|
|
150
|
+
|
|
151
|
+
# ── Internal numpy helper (used by process_nifti_list) ───────────────────
|
|
152
|
+
|
|
153
|
+
def _apply_numpy(self, fid: np.ndarray) -> np.ndarray:
|
|
154
|
+
n_in = fid.shape[-1]
|
|
155
|
+
n_out = self.target_pts
|
|
156
|
+
if n_out == n_in:
|
|
157
|
+
return fid
|
|
158
|
+
if n_out < n_in:
|
|
159
|
+
return fid[..., :n_out]
|
|
160
|
+
pad_width = [(0, 0)] * (fid.ndim - 1) + [(0, n_out - n_in)]
|
|
161
|
+
return np.pad(fid, pad_width)
|
|
162
|
+
|
|
163
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core data structures for Augmentrum.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from augmentrum.core.nifti_mrs_plus import NIfTI_MRS_Plus, Backend
|
|
6
|
+
from augmentrum.core.augmentrum import Augmentrum
|
|
7
|
+
from augmentrum.core.base_module import BaseModule
|
|
8
|
+
from augmentrum.core.pipeline import AugmentationPipeline
|
|
9
|
+
|
|
10
|
+
__all__ = ['NIfTI_MRS_Plus', 'Backend', 'Augmentrum', 'BaseModule', 'AugmentationPipeline']
|