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,274 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# coil_average_sampler.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. P. Merkofer (j.p.merkofer@tue.nl) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 2025-10-07 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Implements sampling of coils and averages from raw MRS data. #
|
|
10
|
+
# #
|
|
11
|
+
####################################################################################################
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
#*************#
|
|
15
|
+
# imports #
|
|
16
|
+
#*************#
|
|
17
|
+
import torch
|
|
18
|
+
|
|
19
|
+
from fsl_mrs.core.nifti_mrs import split
|
|
20
|
+
|
|
21
|
+
# own
|
|
22
|
+
from augmentrum.processing.utils import update_processing_prov
|
|
23
|
+
from augmentrum.core.base_module import BaseModule
|
|
24
|
+
from augmentrum.core import Backend
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
#**************************************************************************************************#
|
|
28
|
+
# Class CoilAverageSampler #
|
|
29
|
+
#**************************************************************************************************#
|
|
30
|
+
# #
|
|
31
|
+
# Samples coils and averages from raw MRS data. Supports random/deterministic selection #
|
|
32
|
+
# and reweighting. #
|
|
33
|
+
# #
|
|
34
|
+
#**************************************************************************************************#
|
|
35
|
+
class CoilAverageSampler(BaseModule):
|
|
36
|
+
"""
|
|
37
|
+
Samples coils and averages from raw MRS data.
|
|
38
|
+
|
|
39
|
+
Uses FSL-MRS `split` on NIfTI-MRS objects, so this module operates
|
|
40
|
+
natively on the NIFTI_LIST backend. For any other backend the base
|
|
41
|
+
class automatically routes the call through process_nifti_list and
|
|
42
|
+
returns the result in the original backend format.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
SUPPORTED_BACKENDS = [Backend.NIFTI_LIST]
|
|
46
|
+
|
|
47
|
+
def __init__(self, mode='random', n_coils=(1, None), n_averages=(1, None), reweight=False):
|
|
48
|
+
"""
|
|
49
|
+
Initializes the sampler.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
mode (str): 'random' or 'deterministic' sampling mode.
|
|
53
|
+
n_coils (tuple or int): Min/max number of coils to sample, or exact value.
|
|
54
|
+
Examples: (1, 8) = range, 4 = exactly 4
|
|
55
|
+
n_averages (tuple or int): Min/max number of averages to sample, or exact value.
|
|
56
|
+
reweight (bool): Whether to reweight signals after sampling.
|
|
57
|
+
"""
|
|
58
|
+
super().__init__(mode=mode, n_coils=n_coils, n_averages=n_averages, reweight=reweight)
|
|
59
|
+
|
|
60
|
+
self.mode = mode
|
|
61
|
+
|
|
62
|
+
# Normalize to tuples: int → (value, value), tuple → tuple
|
|
63
|
+
self.n_coils = self._normalize_param(n_coils)
|
|
64
|
+
self.n_averages = self._normalize_param(n_averages)
|
|
65
|
+
self.reweight = reweight
|
|
66
|
+
|
|
67
|
+
def _normalize_param(self, param):
|
|
68
|
+
"""
|
|
69
|
+
Normalize parameter to tuple format.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
param: Either tuple (min, max) or int/float (exact value)
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Tuple (min, max)
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
4 → (4, 4) # Exactly 4
|
|
79
|
+
(1, 8) → (1, 8) # Range 1-8
|
|
80
|
+
None → (1, None) # Default
|
|
81
|
+
"""
|
|
82
|
+
if param is None:
|
|
83
|
+
return (1, None)
|
|
84
|
+
elif isinstance(param, (int, float)):
|
|
85
|
+
# Single value → exact value
|
|
86
|
+
return (int(param), int(param))
|
|
87
|
+
elif isinstance(param, tuple):
|
|
88
|
+
return param
|
|
89
|
+
else:
|
|
90
|
+
raise TypeError(f"Parameter must be int, float, or tuple, got {type(param)}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def process_nifti_list(self, data_list, water_list=None, coil_indices=None, average_indices=None, **kwargs):
|
|
94
|
+
"""
|
|
95
|
+
Samples coils and averages from each NIFTI_MRS in the list.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
data_list: List of metabolite MRS data (NIFTI_MRS objects).
|
|
99
|
+
water_list: List of water reference MRS data (NIFTI_MRS objects), optional.
|
|
100
|
+
coil_indices: List of coil indices to select (for deterministic mode).
|
|
101
|
+
average_indices: List of average indices to select (for deterministic mode).
|
|
102
|
+
**kwargs: Additional arguments.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Tuple of (processed_data_list, processed_water_list)
|
|
106
|
+
"""
|
|
107
|
+
processed_data = []
|
|
108
|
+
processed_water = []
|
|
109
|
+
|
|
110
|
+
for i, data_met in enumerate(data_list):
|
|
111
|
+
data_wat = water_list[i] if water_list is not None else None
|
|
112
|
+
|
|
113
|
+
# Sample coils and averages for this subject
|
|
114
|
+
data_met, data_wat = self._process_single(data_met, data_wat, coil_indices, average_indices)
|
|
115
|
+
|
|
116
|
+
processed_data.append(data_met)
|
|
117
|
+
if water_list is not None:
|
|
118
|
+
processed_water.append(data_wat if data_wat is not None else water_list[i])
|
|
119
|
+
|
|
120
|
+
return processed_data, (processed_water if water_list is not None else None)
|
|
121
|
+
|
|
122
|
+
def _process_single(self, data_met, data_wat=None, coil_indices=None, average_indices=None):
|
|
123
|
+
"""
|
|
124
|
+
Samples coils and averages from a single subject's data.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
data_met: Metabolite MRS data (NiftiMRS object).
|
|
128
|
+
data_wat: Water reference MRS data (NiftiMRS object), optional.
|
|
129
|
+
coil_indices: List of coil indices to select (for deterministic mode).
|
|
130
|
+
average_indices: List of average indices to select (for deterministic mode).
|
|
131
|
+
"""
|
|
132
|
+
data_met, data_wat = self.sample_coils(data_met, data_wat, coil_indices)
|
|
133
|
+
data_met, data_wat = self.sample_averages(data_met, data_wat, average_indices)
|
|
134
|
+
|
|
135
|
+
if self.reweight:
|
|
136
|
+
data_met, data_wat = self._reweight_signals(data_met, data_wat)
|
|
137
|
+
return data_met, data_wat
|
|
138
|
+
|
|
139
|
+
def sample_coils(self, data_met, data_wat=None, coil_indices=None):
|
|
140
|
+
"""
|
|
141
|
+
Samples specified coils from the data.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
data_met: Metabolite MRS data (NiftiMRS object).
|
|
145
|
+
data_wat: Water reference MRS data (NiftiMRS object), optional
|
|
146
|
+
coil_indices: List of coil indices to select (if provided, use deterministic mode).
|
|
147
|
+
"""
|
|
148
|
+
dim_tags = getattr(data_met, 'dim_tags', [])
|
|
149
|
+
has_coil = 'DIM_COIL' in dim_tags
|
|
150
|
+
|
|
151
|
+
# Determine mode at runtime: if coil_indices provided, use them (deterministic)
|
|
152
|
+
# Otherwise, sample randomly (if mode='random') or use all (if mode='deterministic' but no indices)
|
|
153
|
+
if coil_indices is not None:
|
|
154
|
+
# Deterministic mode: use provided indices
|
|
155
|
+
pass # Will apply indices below
|
|
156
|
+
elif self.mode == 'random' and has_coil:
|
|
157
|
+
# Random mode: sample random coils
|
|
158
|
+
coil_dim = data_met.dim_position('DIM_COIL')
|
|
159
|
+
n_total = data_met.shape[coil_dim] # actual coil count
|
|
160
|
+
min_c, max_c = self._get_limits(self.n_coils, n_total)
|
|
161
|
+
if min_c < max_c:
|
|
162
|
+
num_coils = torch.randint(min_c, max_c, (1,)).item()
|
|
163
|
+
coil_indices = torch.randperm(n_total)[:num_coils].tolist()
|
|
164
|
+
elif self.mode == 'deterministic' and has_coil:
|
|
165
|
+
# Deterministic mode but no indices provided: use all coils
|
|
166
|
+
coil_dim = data_met.dim_position('DIM_COIL')
|
|
167
|
+
coil_indices = list(range(data_met.shape[coil_dim]))
|
|
168
|
+
|
|
169
|
+
# apply sampling
|
|
170
|
+
if has_coil and coil_indices is not None:
|
|
171
|
+
if isinstance(coil_indices, list) or (isinstance(coil_indices, int) and coil_indices > 0):
|
|
172
|
+
_, data_met = split(data_met, 'DIM_COIL', coil_indices)
|
|
173
|
+
if data_wat is not None:
|
|
174
|
+
_, data_wat = split(data_wat, 'DIM_COIL', coil_indices)
|
|
175
|
+
|
|
176
|
+
# update processing provenance
|
|
177
|
+
processing_info = f'{__name__}.sample_coils, '
|
|
178
|
+
processing_info += f'coil_indices={coil_indices}.'
|
|
179
|
+
update_processing_prov(data_met, 'Random Coil Sampling', processing_info)
|
|
180
|
+
if data_wat is not None:
|
|
181
|
+
update_processing_prov(data_wat, 'Random Coil Sampling', processing_info)
|
|
182
|
+
|
|
183
|
+
return data_met, data_wat
|
|
184
|
+
|
|
185
|
+
def sample_averages(self, data_met, data_wat=None, average_indices=None):
|
|
186
|
+
"""
|
|
187
|
+
Samples specified averages from the data.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
data_met: Metabolite MRS data (NiftiMRS object).
|
|
191
|
+
data_wat: Water reference MRS data (NiftiMRS object), optional.
|
|
192
|
+
average_indices: List of average indices to select.
|
|
193
|
+
"""
|
|
194
|
+
dim_tags = getattr(data_met, 'dim_tags', [])
|
|
195
|
+
has_dyn = 'DIM_DYN' in dim_tags
|
|
196
|
+
|
|
197
|
+
# Determine mode at runtime: if average_indices provided, use them (deterministic)
|
|
198
|
+
# Otherwise, sample randomly (if mode='random') or use all (if mode='deterministic' but no indices)
|
|
199
|
+
if average_indices is not None:
|
|
200
|
+
# Deterministic mode: use provided indices
|
|
201
|
+
pass # Will apply indices below
|
|
202
|
+
elif self.mode == 'random' and has_dyn:
|
|
203
|
+
# Random mode: sample random averages
|
|
204
|
+
dyn_dim = data_met.dim_position('DIM_DYN')
|
|
205
|
+
n_total = data_met.shape[dyn_dim] # actual average count
|
|
206
|
+
min_a, max_a = self._get_limits(self.n_averages, n_total)
|
|
207
|
+
if min_a < max_a:
|
|
208
|
+
num_averages = torch.randint(min_a, max_a, (1,)).item()
|
|
209
|
+
average_indices = torch.randperm(n_total)[:num_averages].tolist()
|
|
210
|
+
elif self.mode == 'deterministic' and has_dyn:
|
|
211
|
+
# Deterministic mode but no indices provided: use all averages
|
|
212
|
+
dyn_dim = data_met.dim_position('DIM_DYN')
|
|
213
|
+
average_indices = list(range(data_met.shape[dyn_dim]))
|
|
214
|
+
|
|
215
|
+
# apply sampling
|
|
216
|
+
if has_dyn and average_indices is not None:
|
|
217
|
+
if isinstance(average_indices, list) or (isinstance(average_indices, int) and average_indices > 0):
|
|
218
|
+
_, data_met = split(data_met, 'DIM_DYN', average_indices)
|
|
219
|
+
|
|
220
|
+
# update processing provenance
|
|
221
|
+
processing_info = f'{__name__}.sample_averages, '
|
|
222
|
+
processing_info += f'coil_indices={average_indices}.'
|
|
223
|
+
update_processing_prov(data_met, 'Random Average Sampling', processing_info)
|
|
224
|
+
|
|
225
|
+
return data_met, data_wat
|
|
226
|
+
|
|
227
|
+
def _reweight_signals(self, data, water):
|
|
228
|
+
"""
|
|
229
|
+
Reweights coils and averages based on random weights.
|
|
230
|
+
TODO: implement reweighting.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
data: MRS data (NiftiMRS object).
|
|
234
|
+
water: Water reference MRS data (NiftiMRS object), optional.
|
|
235
|
+
"""
|
|
236
|
+
raise NotImplementedError("Reweighting not implemented yet.")
|
|
237
|
+
|
|
238
|
+
def _sample_snr_improvement(self, data, water):
|
|
239
|
+
"""
|
|
240
|
+
Samples coils and averages to improve SNR.
|
|
241
|
+
TODO: implement SNR improvement sampling.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
data: MRS data (NiftiMRS object).
|
|
245
|
+
water: Water reference MRS data (NiftiMRS object), optional.
|
|
246
|
+
"""
|
|
247
|
+
raise NotImplementedError("SNR improvement sampling not implemented yet.")
|
|
248
|
+
|
|
249
|
+
def _get_limits(self, n, n_total):
|
|
250
|
+
"""
|
|
251
|
+
Return ``(min_n, max_exclusive)`` for ``torch.randint(min_n, max_exclusive)``.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
n (tuple): ``(min, max)`` — both *inclusive*, either can be ``None``.
|
|
255
|
+
``None`` on min → 1. ``None`` on max → ``n_total`` (use all).
|
|
256
|
+
n_total (int): Total number of items available (coils / averages).
|
|
257
|
+
|
|
258
|
+
Examples::
|
|
259
|
+
|
|
260
|
+
_get_limits((1, None), 4) → (1, 5) # randint → 1, 2, 3 or 4 ✓
|
|
261
|
+
_get_limits((2, 3), 4) → (2, 4) # randint → 2 or 3 ✓
|
|
262
|
+
_get_limits((4, 4), 4) → (4, 5) # randint → always 4 ✓
|
|
263
|
+
_get_limits((6, None), 4) → (4, 5) # clamps to available ✓
|
|
264
|
+
"""
|
|
265
|
+
if n is None:
|
|
266
|
+
return 1, n_total + 1
|
|
267
|
+
|
|
268
|
+
min_val, max_val = n
|
|
269
|
+
|
|
270
|
+
min_n = 1 if min_val is None else max(1, min(int(min_val), n_total))
|
|
271
|
+
max_n = n_total if max_val is None else min(int(max_val), n_total)
|
|
272
|
+
|
|
273
|
+
# +1 because torch.randint upper bound is exclusive
|
|
274
|
+
return min_n, max_n + 1
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
####################################################################################################
|
|
2
|
+
# kspace_reconstructor.py #
|
|
3
|
+
####################################################################################################
|
|
4
|
+
# #
|
|
5
|
+
# Authors: J. T. LaMaster (john.t.lamaster@gmail.com) #
|
|
6
|
+
# #
|
|
7
|
+
# Created: 2025-10-22 #
|
|
8
|
+
# #
|
|
9
|
+
# Purpose: Implements reconstruct_with_masking which accepts subsampled k-space data and its #
|
|
10
|
+
# trajectory coordinates to perform density compesnation and regridding. #
|
|
11
|
+
# #
|
|
12
|
+
####################################################################################################
|
|
13
|
+
|
|
14
|
+
import torch
|
|
15
|
+
import torch.nn as nn
|
|
16
|
+
import torch.nn.functional as F
|
|
17
|
+
import torchkbnufft as tkbn
|
|
18
|
+
from typing import Union, List, Tuple
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__ = ['reconstruct_with_masking']
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# --- Constants for normalization ---
|
|
25
|
+
# TorchKbNufft expects coordinates in the range [-pi, pi]
|
|
26
|
+
PI = torch.pi
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def prepare_data_and_coords(
|
|
30
|
+
coords: torch.Tensor,
|
|
31
|
+
kdata: Optional[torch.Tensor] = None
|
|
32
|
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
33
|
+
"""
|
|
34
|
+
Reshapes coordinates and k-data to the format required by TorchKbNufft:
|
|
35
|
+
Coords: [B, D, N_total], Kdata: [B, C, N_total]
|
|
36
|
+
N_total = S * L (shots x length).
|
|
37
|
+
"""
|
|
38
|
+
# Assuming input coords shape: [B, S, D, L] (e.g., [4, 10, 3, 100])
|
|
39
|
+
B, S, D, L = coords.shape
|
|
40
|
+
N_total = S * L
|
|
41
|
+
|
|
42
|
+
# 1. Normalize Coordinates (if not already done)
|
|
43
|
+
# The NUFFT forward/adjoint operators typically assume coordinates in [-pi, pi].
|
|
44
|
+
# We assume your input coords are normalized to [-1, 1] relative to k-space extent.
|
|
45
|
+
coords_normalized = coords * PI
|
|
46
|
+
|
|
47
|
+
# 2. Flatten Trajectory: [B, S, D, L] -> [B, D, S, L] -> [B, D, N_total]
|
|
48
|
+
k_traj_flat = coords_normalized.permute(0, 2, 1, 3).reshape(B, D, N_total)
|
|
49
|
+
|
|
50
|
+
kdata_flat = None
|
|
51
|
+
if kdata is not None:
|
|
52
|
+
# Assuming input kdata shape: [B, S, C, L] (e.g., [4, 10, 1, 100])
|
|
53
|
+
B_k, S_k, C, L_k = kdata.shape
|
|
54
|
+
|
|
55
|
+
# Flatten K-data: [B, S, C, L] -> [B, C, S, L] -> [B, C, N_total]
|
|
56
|
+
kdata_flat = kdata.permute(0, 2, 1, 3).reshape(B, C, N_total)
|
|
57
|
+
|
|
58
|
+
return k_traj_flat, kdata_flat
|
|
59
|
+
|
|
60
|
+
def calculate_nufft_dcf_weights(
|
|
61
|
+
coords: torch.Tensor,
|
|
62
|
+
image_size: Union[List[int], Tuple[int]],
|
|
63
|
+
oversampling_factor: Union[List[float], Tuple[float]]
|
|
64
|
+
) -> torch.Tensor:
|
|
65
|
+
"""
|
|
66
|
+
Calculates Density Compensation Function (DCF) weights using the
|
|
67
|
+
iterative Pipe-style method from torchkbnufft.
|
|
68
|
+
"""
|
|
69
|
+
device = coords.device
|
|
70
|
+
B, S, D, L = coords.shape
|
|
71
|
+
|
|
72
|
+
# 1. Prepare Trajectory Coordinates
|
|
73
|
+
k_traj_flat, _ = prepare_data_and_coords(coords) # [B, D, N_total]
|
|
74
|
+
|
|
75
|
+
# 2. Determine Grid Size based on Image Size and Oversampling
|
|
76
|
+
# Ensure all sizes are determined on the CPU and converted to a list/tuple of native types
|
|
77
|
+
|
|
78
|
+
# Get sizes as native types (no longer need to keep them as tensors)
|
|
79
|
+
image_size_list = list(image_size)
|
|
80
|
+
oversampling_factor_tensor = torch.tensor(oversampling_factor, dtype=torch.float32)
|
|
81
|
+
|
|
82
|
+
# Grid size = floor(Image Size * Oversampling Factor)
|
|
83
|
+
grid_size_tensor = torch.floor(torch.tensor(image_size) * oversampling_factor_tensor).to(torch.long)
|
|
84
|
+
grid_size_list = grid_size_tensor.tolist() # <--- Pass native list/tuple
|
|
85
|
+
|
|
86
|
+
# 3. Calculate DCF (Iterative Pipe-style)
|
|
87
|
+
# Pass 'im_size' and 'grid_size' as separate arguments, not the KbNufft object.
|
|
88
|
+
with torch.no_grad():
|
|
89
|
+
dcf_flat = tkbn.calc_density_compensation_function(
|
|
90
|
+
k_traj_flat,
|
|
91
|
+
im_size=image_size_list, # <--- FIX 1: Pass im_size
|
|
92
|
+
grid_size=grid_size_list # <--- FIX 2: Pass grid_size
|
|
93
|
+
).detach() # [B, 1, N_total]
|
|
94
|
+
|
|
95
|
+
# 4. Reshape DCF for easy multiplication with k-data: [B, 1, S*L] -> [B, S, L]
|
|
96
|
+
dcf_weights = dcf_flat.view(B, S, L)
|
|
97
|
+
|
|
98
|
+
return dcf_weights
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def regrid_kdata_to_image(
|
|
102
|
+
weighted_kdata: torch.Tensor,
|
|
103
|
+
coords: torch.Tensor,
|
|
104
|
+
image_size: Union[List[int], Tuple[int]],
|
|
105
|
+
oversampling_factor: Union[List[float], Tuple[float]]
|
|
106
|
+
) -> torch.Tensor:
|
|
107
|
+
"""
|
|
108
|
+
Performs Adjoint NUFFT (regridding) to map weighted k-space data to the image domain.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
weighted_kdata (torch.Tensor): Density-compensated k-space data, shape [B, S, C, L].
|
|
112
|
+
coords (torch.Tensor): K-space trajectory coordinates, shape [B, S, D, L].
|
|
113
|
+
image_size (Union[List[int], Tuple[int]]): Target image dimensions (Nx, Ny, Nz).
|
|
114
|
+
oversampling_factor (Union[List[float], Tuple[float]]): Oversampling factors
|
|
115
|
+
for the regridding grid (rho_x, rho_y, rho_z).
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
torch.Tensor: Reconstructed complex image volume, shape [B, C, Nx, Ny, Nz].
|
|
119
|
+
"""
|
|
120
|
+
device = weighted_kdata.device
|
|
121
|
+
|
|
122
|
+
# 1. Prepare Coords and K-data for NUFFT
|
|
123
|
+
# k_traj_flat: [B, D, N_total], kdata_flat: [B, C, N_total]
|
|
124
|
+
k_traj_flat, kdata_flat = prepare_data_and_coords(coords, weighted_kdata)
|
|
125
|
+
|
|
126
|
+
# 2. Determine Grid Size
|
|
127
|
+
image_size = torch.tensor(image_size, dtype=torch.long, device=device)
|
|
128
|
+
oversampling_factor = torch.tensor(oversampling_factor, dtype=torch.float32, device=device)
|
|
129
|
+
|
|
130
|
+
# Grid size = floor(Image Size * Oversampling Factor)
|
|
131
|
+
grid_size = torch.floor(image_size * oversampling_factor).to(torch.long)
|
|
132
|
+
|
|
133
|
+
# 3. Instantiate Adjoint NUFFT Operator (Regridding)
|
|
134
|
+
adj_op = tkbn.KbNufftAdjoint(
|
|
135
|
+
im_size=image_size.tolist(), # [Nx, Ny, Nz]
|
|
136
|
+
grid_size=grid_size.tolist() # [Grid_Nx, Grid_Ny, Grid_Nz]
|
|
137
|
+
).to(device)
|
|
138
|
+
|
|
139
|
+
# 4. Perform Regridding
|
|
140
|
+
# The operation is: Adjoint(k_data) -> IFFT -> Image
|
|
141
|
+
image_domain_output = adj_op(kdata_flat, k_traj_flat)
|
|
142
|
+
|
|
143
|
+
# Output shape: [B, C, Nx, Ny, Nz]
|
|
144
|
+
return image_domain_output
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def reconstruct_with_masking(
|
|
148
|
+
coords: torch.Tensor,
|
|
149
|
+
kdata: torch.Tensor,
|
|
150
|
+
undersampling_mask: torch.Tensor,
|
|
151
|
+
image_size: Union[List[int], Tuple[int]],
|
|
152
|
+
oversampling_factor: Union[List[float], Tuple[float]]
|
|
153
|
+
) -> torch.Tensor:
|
|
154
|
+
"""
|
|
155
|
+
High-level wrapper to apply undersampling mask, calculate DCF, and perform regridding.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
coords (torch.Tensor): K-space trajectory coordinates, shape [B, S, D, L].
|
|
159
|
+
kdata (torch.Tensor): Raw k-space data, shape [B, S, C, L].
|
|
160
|
+
undersampling_mask (torch.Tensor): Boolean mask indicating which shots to KEEP.
|
|
161
|
+
Shape: [B, S].
|
|
162
|
+
image_size (Union[List[int], Tuple[int]]): Target image dimensions (Nx, Ny, Nz).
|
|
163
|
+
oversampling_factor (Union[List[float], Tuple[float]]): Oversampling factors
|
|
164
|
+
for the regridding grid (rho_x, rho_y, rho_z).
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
torch.Tensor: Reconstructed complex image volume, shape [B, C, Nx, Ny, Nz].
|
|
168
|
+
"""
|
|
169
|
+
# --- 1. Apply Undersampling Mask (Zero-Filling) ---
|
|
170
|
+
# The mask is [B, S]. We need to expand it to [B, S, C, L] to multiply with kdata.
|
|
171
|
+
B, S, C, L = kdata.shape
|
|
172
|
+
|
|
173
|
+
# Expand mask to match kdata shape: [B, S] -> [B, S, 1, 1] -> [B, S, C, L]
|
|
174
|
+
# Invert the boolean mask to create a float mask (True -> 0, False -> 1)
|
|
175
|
+
# Since the mask is for samples to KEEP (conventionally True),
|
|
176
|
+
# we zero the kdata samples where the mask is False (discard).
|
|
177
|
+
|
|
178
|
+
# Convert mask to float tensor (0 or 1) and expand
|
|
179
|
+
mask_float = undersampling_mask.to(dtype=kdata.real.dtype).unsqueeze(2).unsqueeze(3)
|
|
180
|
+
|
|
181
|
+
# Expand across Coil (C) and Length (L) dimensions
|
|
182
|
+
mask_bcast = mask_float.expand(B, S, C, L)
|
|
183
|
+
|
|
184
|
+
# Apply mask (zero out discarded k-space samples/shots)
|
|
185
|
+
masked_kdata = kdata * mask_bcast
|
|
186
|
+
|
|
187
|
+
# --- 2. Calculate DCF Weights ---
|
|
188
|
+
# DCF calculation should use ALL coordinates to reflect the desired density.
|
|
189
|
+
dcf_weights = calculate_nufft_dcf_weights(
|
|
190
|
+
coords=coords,
|
|
191
|
+
image_size=image_size,
|
|
192
|
+
oversampling_factor=oversampling_factor
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Reshape DCF for multiplication: [B, S, L] -> [B, S, 1, L]
|
|
196
|
+
dcf_bcast = dcf_weights.unsqueeze(2)
|
|
197
|
+
|
|
198
|
+
# --- 3. Apply DCF to Masked K-space Data ---
|
|
199
|
+
weighted_kdata = masked_kdata * dcf_bcast
|
|
200
|
+
|
|
201
|
+
# --- 4. Perform Regridding (Adjoint NUFFT) ---
|
|
202
|
+
reconstructed_image = regrid_kdata_to_image(
|
|
203
|
+
weighted_kdata=weighted_kdata,
|
|
204
|
+
coords=coords,
|
|
205
|
+
image_size=image_size,
|
|
206
|
+
oversampling_factor=oversampling_factor
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return reconstructed_image
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
if __name__ == "__ main __":
|
|
213
|
+
# Define parameters for a 3D example
|
|
214
|
+
B, S, D, L, C = 4, 10, 3, 100, 1 # Batch, Shots, Dimensions, Length, Channels
|
|
215
|
+
Target_Image_Size = (64, 64, 64)
|
|
216
|
+
Oversampling_Factor = (1.5, 1.5, 1.5)
|
|
217
|
+
|
|
218
|
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
219
|
+
|
|
220
|
+
# Dummy Data: Coords must be float, Kdata must be complex
|
|
221
|
+
coords_in = (torch.rand(B, S, D, L) * 2 - 1).to(dtype=torch.float32, device=device) # [-1, 1] range
|
|
222
|
+
kdata_in = torch.randn(B, S, C, L, dtype=torch.complex64, device=device)
|
|
223
|
+
|
|
224
|
+
# --- NEW: Create an Undersampling Mask ---
|
|
225
|
+
# Mask [B, S] (e.g., keeping only 50% of the shots randomly per batch sample)
|
|
226
|
+
undersampling_mask_in = torch.rand(B, S) < 0.5
|
|
227
|
+
undersampling_mask_in = undersampling_mask_in.to(device)
|
|
228
|
+
|
|
229
|
+
# --- 1. Full Reconstruction Pipeline with Masking ---
|
|
230
|
+
print("--- Starting Full Reconstruction Pipeline (Masked) ---")
|
|
231
|
+
reconstructed_image_masked = reconstruct_with_masking(
|
|
232
|
+
coords=coords_in,
|
|
233
|
+
kdata=kdata_in,
|
|
234
|
+
undersampling_mask=undersampling_mask_in,
|
|
235
|
+
image_size=Target_Image_Size,
|
|
236
|
+
oversampling_factor=Oversampling_Factor
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
print(f"Masked Reconstruction successful. Final Image Shape: {reconstructed_image_masked.shape}")
|
|
240
|
+
print(f"Shots kept per batch: {undersampling_mask_in.sum(dim=1).tolist()}")
|
|
241
|
+
print(f"Expected Final Image Shape: [{B}, {C}, {Target_Image_Size[0]}, {Target_Image_Size[1]}, {Target_Image_Size[2]}]")
|
|
242
|
+
|
|
243
|
+
# The reconstructed image is now a complex tensor [B, C, Nx, Ny, Nz]
|
|
244
|
+
|
|
245
|
+
# Define parameters for a 2D example
|
|
246
|
+
B, S, D, L, C = 4, 10, 2, 100, 1 # Batch, Shots, Dimensions, Length, Channels
|
|
247
|
+
Target_Image_Size = (64, 64)
|
|
248
|
+
Oversampling_Factor = (1.5, 1.5)
|
|
249
|
+
|
|
250
|
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
251
|
+
|
|
252
|
+
# Dummy Data: Coords must be float, Kdata must be complex
|
|
253
|
+
coords_in = (torch.rand(B, S, D, L) * 2 - 1).to(dtype=torch.float32, device=device) # [-1, 1] range
|
|
254
|
+
kdata_in = torch.randn(B, S, C, L, dtype=torch.complex64, device=device)
|
|
255
|
+
|
|
256
|
+
# --- NEW: Create an Undersampling Mask ---
|
|
257
|
+
# Mask [B, S] (e.g., keeping only 50% of the shots randomly per batch sample)
|
|
258
|
+
undersampling_mask_in = torch.rand(B, S) < 0.5
|
|
259
|
+
undersampling_mask_in = undersampling_mask_in.to(device)
|
|
260
|
+
|
|
261
|
+
# --- 1. Full Reconstruction Pipeline with Masking ---
|
|
262
|
+
print("--- Starting Full Reconstruction Pipeline (Masked) ---")
|
|
263
|
+
reconstructed_image_masked = reconstruct_with_masking(
|
|
264
|
+
coords=coords_in,
|
|
265
|
+
kdata=kdata_in,
|
|
266
|
+
undersampling_mask=undersampling_mask_in,
|
|
267
|
+
image_size=Target_Image_Size,
|
|
268
|
+
oversampling_factor=Oversampling_Factor
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
print(f"Masked Reconstruction successful. Final Image Shape: {reconstructed_image_masked.shape}")
|
|
272
|
+
print(f"Shots kept per batch: {undersampling_mask_in.sum(dim=1).tolist()}")
|
|
273
|
+
print("Expected Final Image Shape: [{}, {}, {}]".format(B, C, [x for x in Target_Image_Size]))
|
|
274
|
+
|
|
275
|
+
# The reconstructed image is now a complex tensor [B, C, Nx, Ny]
|