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
augmentrum/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Augmentrum - A Data Augmentation Package for MR Spectroscopy
|
|
3
|
+
|
|
4
|
+
A modular Python framework designed to help researchers with limited in-vivo MRS data
|
|
5
|
+
create diverse, physically consistent datasets through flexible augmentation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.0.1"
|
|
9
|
+
__author__ = "John T. LaMaster, Julian P. Merkofer, Kay C. Igwe"
|
|
10
|
+
__email__ = "j.p.merkofer@tue.nl"
|
|
11
|
+
|
|
12
|
+
# Main API
|
|
13
|
+
from augmentrum.core import Augmentrum, NIfTI_MRS_Plus, Backend, BaseModule, AugmentationPipeline
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"__version__",
|
|
17
|
+
"Augmentrum",
|
|
18
|
+
"NIfTI_MRS_Plus",
|
|
19
|
+
"Backend",
|
|
20
|
+
"BaseModule",
|
|
21
|
+
"AugmentationPipeline",
|
|
22
|
+
]
|
|
23
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Augmentation modules for MRS data.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from augmentrum.augmentation.gaussian_noise import GaussianNoise
|
|
6
|
+
from augmentrum.augmentation.line_broadening import LineBroadening
|
|
7
|
+
from augmentrum.augmentation.baseline_augmentation import BaselineAugmentation
|
|
8
|
+
from augmentrum.augmentation.residual_water import ResidualWater
|
|
9
|
+
from augmentrum.augmentation.spurious_echoes import SpuriousEchoes
|
|
10
|
+
from augmentrum.augmentation.artificial_peaks import ArtificialPeaks
|
|
11
|
+
from augmentrum.augmentation.eddy_current import EddyCurrent
|
|
12
|
+
from augmentrum.augmentation.apodization import Apodization
|
|
13
|
+
from augmentrum.augmentation.phase_frequency import PhaseShift, FrequencyShift
|
|
14
|
+
from augmentrum.augmentation.amplitude_scaling import AmplitudeScaling
|
|
15
|
+
from augmentrum.augmentation.spatial_augmentations import SpatialAugmentations
|
|
16
|
+
from augmentrum.augmentation.zero_fill import ZeroFill
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
'GaussianNoise',
|
|
20
|
+
'LineBroadening',
|
|
21
|
+
'BaselineAugmentation',
|
|
22
|
+
'ResidualWater',
|
|
23
|
+
'SpuriousEchoes',
|
|
24
|
+
'ArtificialPeaks',
|
|
25
|
+
'EddyCurrent',
|
|
26
|
+
'Apodization',
|
|
27
|
+
'PhaseShift',
|
|
28
|
+
'FrequencyShift',
|
|
29
|
+
'AmplitudeScaling',
|
|
30
|
+
'SpatialAugmentations',
|
|
31
|
+
'ZeroFill',
|
|
32
|
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# amplitude_scaling.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: K. C. Igwe (kcigwe@bu.edu) #
|
|
6
|
+
# J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
7
|
+
# #
|
|
8
|
+
# Created: 2026-02-08 #
|
|
9
|
+
# #
|
|
10
|
+
# Purpose: Amplitude scaling for MRS data augmentation. #
|
|
11
|
+
# Note: True metabolite-specific scaling requires basis functions (simulated data only). #
|
|
12
|
+
# #
|
|
13
|
+
####################################################################################################
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
#*************#
|
|
17
|
+
# imports #
|
|
18
|
+
#*************#
|
|
19
|
+
from typing import List, Optional, Union, Tuple
|
|
20
|
+
import numpy as np
|
|
21
|
+
from fsl_mrs.core import NIFTI_MRS
|
|
22
|
+
|
|
23
|
+
from augmentrum.core.base_module import BaseModule
|
|
24
|
+
from augmentrum.core.nifti_mrs_plus import Backend
|
|
25
|
+
from augmentrum.utils.tensor_ops import match_backend
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
####################################################################################################
|
|
29
|
+
# AmplitudeScaling Class #
|
|
30
|
+
####################################################################################################
|
|
31
|
+
class AmplitudeScaling(BaseModule):
|
|
32
|
+
"""
|
|
33
|
+
Apply amplitude scaling to MRS data.
|
|
34
|
+
|
|
35
|
+
**Note:** This performs global amplitude scaling. True metabolite-specific scaling
|
|
36
|
+
requires access to individual basis functions and is only applicable to simulated data.
|
|
37
|
+
|
|
38
|
+
For realistic metabolite-specific scaling:
|
|
39
|
+
- Fit in-vivo data to a basis set
|
|
40
|
+
- Scale individual metabolite components
|
|
41
|
+
- Reconstruct the FID
|
|
42
|
+
|
|
43
|
+
This module provides global scaling for demonstration/preprocessing purposes.
|
|
44
|
+
|
|
45
|
+
Parameters:
|
|
46
|
+
scale_factor: Scaling factor to apply. Can be:
|
|
47
|
+
- Single float: Fixed scaling
|
|
48
|
+
- Tuple (min, max): Random uniform sampling from range
|
|
49
|
+
Default: 1.0 (no scaling)
|
|
50
|
+
distribution: Distribution for random sampling ('uniform', 'normal')
|
|
51
|
+
Only used if scale_factor is a tuple
|
|
52
|
+
seed: Random seed for reproducibility
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
>>> # Fixed scaling
|
|
56
|
+
>>> scaler = AmplitudeScaling(scale_factor=0.8)
|
|
57
|
+
>>>
|
|
58
|
+
>>> # Random scaling in range
|
|
59
|
+
>>> scaler = AmplitudeScaling(scale_factor=(0.7, 1.3))
|
|
60
|
+
>>>
|
|
61
|
+
>>> # Normal distribution around 1.0
|
|
62
|
+
>>> scaler = AmplitudeScaling(scale_factor=(0.9, 1.1), distribution='normal')
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
SUPPORTED_BACKENDS = [Backend.NIFTI_LIST, Backend.NUMPY, Backend.PYTORCH,
|
|
66
|
+
Backend.TENSORFLOW, Backend.JAX, Backend.KERAS]
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
scale_factor: Union[float, Tuple[float, float]] = 1.0,
|
|
71
|
+
distribution: str = 'uniform',
|
|
72
|
+
seed: Optional[int] = None,
|
|
73
|
+
**kwargs
|
|
74
|
+
):
|
|
75
|
+
"""Initialize AmplitudeScaling module."""
|
|
76
|
+
super().__init__(**kwargs)
|
|
77
|
+
|
|
78
|
+
self.scale_factor = scale_factor
|
|
79
|
+
self.distribution = distribution
|
|
80
|
+
self.seed = seed
|
|
81
|
+
self.rng = np.random.default_rng(seed)
|
|
82
|
+
|
|
83
|
+
# Validate distribution
|
|
84
|
+
if distribution not in ['uniform', 'normal']:
|
|
85
|
+
raise ValueError(f"distribution must be 'uniform' or 'normal', got {distribution}")
|
|
86
|
+
|
|
87
|
+
def _get_scale_factor(self) -> float:
|
|
88
|
+
"""Get scale factor (sample if range is provided)."""
|
|
89
|
+
if isinstance(self.scale_factor, (tuple, list)):
|
|
90
|
+
min_scale, max_scale = self.scale_factor
|
|
91
|
+
|
|
92
|
+
if self.distribution == 'uniform':
|
|
93
|
+
return self.rng.uniform(min_scale, max_scale)
|
|
94
|
+
elif self.distribution == 'normal':
|
|
95
|
+
# Use range as mean ± std
|
|
96
|
+
mean = (min_scale + max_scale) / 2.0
|
|
97
|
+
std = (max_scale - min_scale) / 4.0 # ~95% within range
|
|
98
|
+
return self.rng.normal(mean, std)
|
|
99
|
+
else:
|
|
100
|
+
return float(self.scale_factor)
|
|
101
|
+
|
|
102
|
+
def process_nifti_list(
|
|
103
|
+
self,
|
|
104
|
+
data_list: List[NIFTI_MRS],
|
|
105
|
+
water_list: Optional[List[NIFTI_MRS]] = None,
|
|
106
|
+
**kwargs
|
|
107
|
+
) -> Tuple[List[NIFTI_MRS], Optional[List[NIFTI_MRS]]]:
|
|
108
|
+
"""
|
|
109
|
+
Process list of NIFTI_MRS objects.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
data_list: List of NIFTI_MRS objects
|
|
113
|
+
water_list: Optional list of water reference NIFTI_MRS objects
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Tuple of (scaled_data_list, water_list_unchanged)
|
|
117
|
+
"""
|
|
118
|
+
scaled_data = []
|
|
119
|
+
|
|
120
|
+
for nifti in data_list:
|
|
121
|
+
# Get scale factor (sample if random)
|
|
122
|
+
scale = self._get_scale_factor()
|
|
123
|
+
|
|
124
|
+
# Get data
|
|
125
|
+
fid = nifti[:].copy()
|
|
126
|
+
|
|
127
|
+
# Apply scaling
|
|
128
|
+
fid_scaled = fid * scale
|
|
129
|
+
|
|
130
|
+
# Create new NIFTI_MRS with scaled data
|
|
131
|
+
nifti_scaled = nifti.copy()
|
|
132
|
+
nifti_scaled[:] = fid_scaled
|
|
133
|
+
|
|
134
|
+
scaled_data.append(nifti_scaled)
|
|
135
|
+
|
|
136
|
+
# Water references unchanged
|
|
137
|
+
return scaled_data, water_list
|
|
138
|
+
|
|
139
|
+
def process_tensor(
|
|
140
|
+
self,
|
|
141
|
+
data,
|
|
142
|
+
water=None,
|
|
143
|
+
**kwargs
|
|
144
|
+
) -> Tuple:
|
|
145
|
+
"""
|
|
146
|
+
Process tensor data.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
data: Input tensor (batch, ...)
|
|
150
|
+
water: Optional water reference tensor
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Tuple of (scaled_data, water_unchanged)
|
|
154
|
+
"""
|
|
155
|
+
# Get scale factor for each sample in batch
|
|
156
|
+
batch_size = data.shape[0]
|
|
157
|
+
scales = np.array([self._get_scale_factor() for _ in range(batch_size)])
|
|
158
|
+
|
|
159
|
+
# Reshape scales for broadcasting
|
|
160
|
+
scale_shape = [batch_size] + [1] * (data.ndim - 1)
|
|
161
|
+
scales = scales.reshape(scale_shape)
|
|
162
|
+
|
|
163
|
+
# Apply scaling
|
|
164
|
+
data_scaled = data * match_backend(scales, data)
|
|
165
|
+
|
|
166
|
+
return data_scaled, water
|
|
167
|
+
|
|
168
|
+
def __repr__(self):
|
|
169
|
+
return (f"AmplitudeScaling(scale_factor={self.scale_factor}, "
|
|
170
|
+
f"distribution='{self.distribution}')")
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# apodization.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 FID truncation and exponential weighting (apodization) for MRS data. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from typing import Optional, List
|
|
16
|
+
from augmentrum.core.base_module import BaseModule
|
|
17
|
+
from augmentrum.core.nifti_mrs_plus import Backend, NIfTI_MRS_Plus
|
|
18
|
+
from fsl_mrs.core.nifti_mrs import gen_nifti_mrs
|
|
19
|
+
from augmentrum.utils.tensor_ops import match_backend
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Apodization(BaseModule):
|
|
23
|
+
"""
|
|
24
|
+
Apply apodization (truncation or exponential weighting) to MRS data.
|
|
25
|
+
|
|
26
|
+
Supports two modes:
|
|
27
|
+
- 'truncate': Cut FID after specified number of points
|
|
28
|
+
- 'exponential': Apply exponential decay (Lorentzian broadening)
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
mode : str
|
|
33
|
+
Apodization mode: 'truncate' or 'exponential' (default: 'exponential')
|
|
34
|
+
n_pts : int, optional
|
|
35
|
+
Number of points to keep (for truncation mode)
|
|
36
|
+
frac_pts : float, optional
|
|
37
|
+
Fraction of points to keep (for truncation mode), range [0, 1]
|
|
38
|
+
lb_hz : float, optional
|
|
39
|
+
Lorentzian broadening in Hz (for exponential mode)
|
|
40
|
+
auto_lb : bool
|
|
41
|
+
Auto-calculate lb_hz to preserve signal (default: False)
|
|
42
|
+
target_damp : float
|
|
43
|
+
Target damping factor for auto_lb (default: 0.01)
|
|
44
|
+
target_pts : int, optional
|
|
45
|
+
Target point where signal becomes mostly noise (for auto_lb)
|
|
46
|
+
|
|
47
|
+
Examples
|
|
48
|
+
--------
|
|
49
|
+
>>> # Truncation mode
|
|
50
|
+
>>> apod = Apodization(mode='truncate', n_pts=1024)
|
|
51
|
+
>>>
|
|
52
|
+
>>> # Exponential mode with manual lb
|
|
53
|
+
>>> apod = Apodization(mode='exponential', lb_hz=5.0)
|
|
54
|
+
>>>
|
|
55
|
+
>>> # Exponential mode with auto lb
|
|
56
|
+
>>> apod = Apodization(mode='exponential', auto_lb=True, target_pts=1024)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
SUPPORTED_BACKENDS = [] # Supports all backends
|
|
60
|
+
|
|
61
|
+
def __init__(self, mode: str = 'exponential',
|
|
62
|
+
n_pts: Optional[int] = None,
|
|
63
|
+
frac_pts: Optional[float] = None,
|
|
64
|
+
lb_hz: Optional[float] = None,
|
|
65
|
+
auto_lb: bool = False,
|
|
66
|
+
target_damp: float = 0.01,
|
|
67
|
+
target_pts: Optional[int] = None):
|
|
68
|
+
"""Initialize apodization module."""
|
|
69
|
+
super().__init__(mode=mode, n_pts=n_pts, frac_pts=frac_pts,
|
|
70
|
+
lb_hz=lb_hz, auto_lb=auto_lb,
|
|
71
|
+
target_damp=target_damp, target_pts=target_pts)
|
|
72
|
+
|
|
73
|
+
self.mode = mode.lower()
|
|
74
|
+
self.n_pts = n_pts
|
|
75
|
+
self.frac_pts = frac_pts
|
|
76
|
+
self.lb_hz = lb_hz
|
|
77
|
+
self.auto_lb = auto_lb
|
|
78
|
+
self.target_damp = target_damp
|
|
79
|
+
self.target_pts = target_pts
|
|
80
|
+
|
|
81
|
+
if self.mode not in ['truncate', 'exponential']:
|
|
82
|
+
raise ValueError(f"mode must be 'truncate' or 'exponential', got '{mode}'")
|
|
83
|
+
|
|
84
|
+
def process_nifti_list(self, data_list: List, water_list: Optional[List] = None, **kwargs):
|
|
85
|
+
"""
|
|
86
|
+
Apply apodization to list of NIFTI_MRS objects.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
data_list: List of NIFTI_MRS objects
|
|
90
|
+
water_list: Optional list of water reference NIFTI_MRS objects
|
|
91
|
+
**kwargs: Additional arguments
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
95
|
+
"""
|
|
96
|
+
processed_data = []
|
|
97
|
+
|
|
98
|
+
for nifti in data_list:
|
|
99
|
+
# Get FID data
|
|
100
|
+
fid = nifti[:]
|
|
101
|
+
|
|
102
|
+
# Get spectral width (for exponential mode)
|
|
103
|
+
sw_hz = 1.0 / nifti.dwelltime
|
|
104
|
+
|
|
105
|
+
# Apply apodization
|
|
106
|
+
if self.mode == 'truncate':
|
|
107
|
+
fid_apod = self._apply_truncation(fid)
|
|
108
|
+
else: # exponential
|
|
109
|
+
fid_apod = self._apply_exponential(fid, sw_hz)
|
|
110
|
+
|
|
111
|
+
# Update NIFTI_MRS data
|
|
112
|
+
if fid_apod.shape[-1] != fid.shape[-1]:
|
|
113
|
+
# Shape changed (truncation) — must build a new NIFTI_MRS object
|
|
114
|
+
spec_freq = nifti.spectrometer_frequency[0]
|
|
115
|
+
nucleus = nifti.nucleus[0] if nifti.nucleus else '1H'
|
|
116
|
+
dim_tags = list(nifti.dim_tags) if hasattr(nifti, 'dim_tags') else [None, None, None]
|
|
117
|
+
affine = nifti.getAffine('voxel', 'world') if hasattr(nifti, 'getAffine') else None
|
|
118
|
+
|
|
119
|
+
new_nifti = gen_nifti_mrs(
|
|
120
|
+
data=fid_apod,
|
|
121
|
+
dwelltime=nifti.dwelltime,
|
|
122
|
+
spec_freq=spec_freq,
|
|
123
|
+
nucleus=nucleus,
|
|
124
|
+
dim_tags=dim_tags,
|
|
125
|
+
affine=affine,
|
|
126
|
+
)
|
|
127
|
+
# Copy across any extra header fields
|
|
128
|
+
if hasattr(nifti, 'hdr_ext'):
|
|
129
|
+
for key in nifti.hdr_ext:
|
|
130
|
+
if key not in ('SpectrometerFrequency', 'ResonantNucleus',
|
|
131
|
+
'dim_5', 'dim_6', 'dim_7'):
|
|
132
|
+
try:
|
|
133
|
+
new_nifti.add_hdr_field(key, nifti.hdr_ext[key])
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
nifti = new_nifti
|
|
137
|
+
else:
|
|
138
|
+
nifti[:] = fid_apod
|
|
139
|
+
processed_data.append(nifti)
|
|
140
|
+
|
|
141
|
+
return processed_data, water_list
|
|
142
|
+
|
|
143
|
+
def process_tensor(self, data_array, water_array=None, backend=None, **kwargs):
|
|
144
|
+
"""
|
|
145
|
+
Apply apodization to tensor/array data (**any backend**).
|
|
146
|
+
|
|
147
|
+
**Truncation mode**: slices the last axis with ``data[..., :n_keep]``
|
|
148
|
+
— works natively in NumPy, PyTorch, JAX, and TensorFlow without any
|
|
149
|
+
conversion.
|
|
150
|
+
|
|
151
|
+
**Exponential mode**: the decay envelope is a numpy array (no data
|
|
152
|
+
dependency) multiplied via ``data * match_backend(envelope, data)``
|
|
153
|
+
— fully backend-native multiply, gradients preserved.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
data_array: Input tensor of shape ``(batch, ..., n_points)``
|
|
157
|
+
water_array: Optional water reference (unchanged)
|
|
158
|
+
backend: Backend enum (unused)
|
|
159
|
+
**kwargs: ``'sw_hz'`` required for exponential mode
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Tuple of (processed_data, water_array)
|
|
163
|
+
"""
|
|
164
|
+
if self.mode == 'truncate':
|
|
165
|
+
N = data_array.shape[-1]
|
|
166
|
+
if self.n_pts is not None:
|
|
167
|
+
n_keep = int(np.clip(self.n_pts, 1, N))
|
|
168
|
+
elif self.frac_pts is not None:
|
|
169
|
+
if not (0 < self.frac_pts <= 1):
|
|
170
|
+
raise ValueError("frac_pts must be in range (0, 1]")
|
|
171
|
+
n_keep = int(np.ceil(self.frac_pts * N))
|
|
172
|
+
else:
|
|
173
|
+
raise ValueError("Must provide n_pts or frac_pts for truncation mode")
|
|
174
|
+
# Native slice — valid in numpy, torch, jax, tf
|
|
175
|
+
return data_array[..., :n_keep], water_array
|
|
176
|
+
|
|
177
|
+
else: # exponential
|
|
178
|
+
sw_hz = kwargs.get('sw_hz')
|
|
179
|
+
if sw_hz is None:
|
|
180
|
+
raise ValueError("Apodization.process_tensor requires 'sw_hz' for exponential mode")
|
|
181
|
+
|
|
182
|
+
if self.auto_lb:
|
|
183
|
+
if self.target_pts is None:
|
|
184
|
+
raise ValueError("Must provide target_pts when auto_lb=True")
|
|
185
|
+
lb = self._calc_lb(None, sw_hz, self.target_pts, self.target_damp)
|
|
186
|
+
elif self.lb_hz is not None:
|
|
187
|
+
lb = self.lb_hz
|
|
188
|
+
else:
|
|
189
|
+
raise ValueError("Must provide lb_hz or set auto_lb=True for exponential mode")
|
|
190
|
+
|
|
191
|
+
N = data_array.shape[-1]
|
|
192
|
+
t = np.arange(N, dtype=np.float64) / float(sw_hz)
|
|
193
|
+
t_shape = [1] * (len(data_array.shape) - 1) + [N]
|
|
194
|
+
t = t.reshape(t_shape)
|
|
195
|
+
|
|
196
|
+
envelope = np.exp(-np.pi * float(lb) * t)
|
|
197
|
+
# Backend-native multiply (envelope is data-independent numpy array)
|
|
198
|
+
return data_array * match_backend(envelope, data_array), water_array
|
|
199
|
+
|
|
200
|
+
def _apply_truncation(self, fid: np.ndarray) -> np.ndarray:
|
|
201
|
+
"""
|
|
202
|
+
Apply truncation apodization.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
fid: Input FID data
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Truncated FID
|
|
209
|
+
"""
|
|
210
|
+
N = fid.shape[-1]
|
|
211
|
+
|
|
212
|
+
# Determine number of points to keep
|
|
213
|
+
if self.n_pts is not None:
|
|
214
|
+
data_keep = int(np.clip(self.n_pts, 1, N))
|
|
215
|
+
elif self.frac_pts is not None:
|
|
216
|
+
if not (0 < self.frac_pts <= 1):
|
|
217
|
+
raise ValueError("frac_pts must be in range (0, 1]")
|
|
218
|
+
data_keep = int(np.ceil(self.frac_pts * N))
|
|
219
|
+
else:
|
|
220
|
+
raise ValueError("Must provide n_pts or frac_pts for truncation mode")
|
|
221
|
+
|
|
222
|
+
# Truncate along last dimension
|
|
223
|
+
return fid[..., :data_keep]
|
|
224
|
+
|
|
225
|
+
def _apply_exponential(self, fid: np.ndarray, sw_hz: float) -> np.ndarray:
|
|
226
|
+
"""
|
|
227
|
+
Apply exponential apodization.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
fid: Input FID data
|
|
231
|
+
sw_hz: Spectral width in Hz
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Apodized FID
|
|
235
|
+
"""
|
|
236
|
+
# Determine lb_hz
|
|
237
|
+
if self.auto_lb:
|
|
238
|
+
if self.target_pts is None:
|
|
239
|
+
raise ValueError("Must provide target_pts when auto_lb=True")
|
|
240
|
+
lb = self._calc_lb(fid, sw_hz, self.target_pts, self.target_damp)
|
|
241
|
+
elif self.lb_hz is not None:
|
|
242
|
+
lb = self.lb_hz
|
|
243
|
+
else:
|
|
244
|
+
raise ValueError("Must provide lb_hz or set auto_lb=True for exponential mode")
|
|
245
|
+
|
|
246
|
+
if lb <= 0:
|
|
247
|
+
return fid
|
|
248
|
+
|
|
249
|
+
# Get time points for last dimension
|
|
250
|
+
N = fid.shape[-1]
|
|
251
|
+
t = np.arange(N, dtype=float) / float(sw_hz)
|
|
252
|
+
|
|
253
|
+
# Reshape t to broadcast correctly
|
|
254
|
+
t_shape = tuple([1] * (fid.ndim - 1) + [N])
|
|
255
|
+
t = t.reshape(t_shape)
|
|
256
|
+
|
|
257
|
+
# Apply exponential decay
|
|
258
|
+
envelope = np.exp(-np.pi * float(lb) * t)
|
|
259
|
+
return fid * envelope
|
|
260
|
+
|
|
261
|
+
@staticmethod
|
|
262
|
+
def _calc_lb(fid, sw_hz: float, desired_npts: int, target_damp: float = 0.01) -> float:
|
|
263
|
+
"""
|
|
264
|
+
Calculate Lorentzian broadening to achieve target damping.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
fid: FID data
|
|
268
|
+
sw_hz: Spectral width in Hz
|
|
269
|
+
desired_npts: Point where signal is mostly noise
|
|
270
|
+
target_damp: Target damping factor (default: 0.01 = 1% of signal)
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Lorentzian broadening in Hz
|
|
274
|
+
"""
|
|
275
|
+
return (-sw_hz / (np.pi * desired_npts)) * np.log(target_damp)
|