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,2562 @@
|
|
|
1
|
+
"""
|
|
2
|
+
kspace_sampling.py
|
|
3
|
+
==================
|
|
4
|
+
A backend-agnostic Python module for generating k-space sampling trajectories
|
|
5
|
+
and undersampling masks for MRI/MRS simulation pipelines.
|
|
6
|
+
|
|
7
|
+
Design Choices
|
|
8
|
+
--------------
|
|
9
|
+
Non-Cartesian trajectories default to center-out readout ordering, which places
|
|
10
|
+
the most signal-rich k-space center at the beginning of each shot and improves
|
|
11
|
+
compatibility with variable-density density-compensation functions. Golden-angle
|
|
12
|
+
and phyllotaxis-based orderings are used wherever angular distribution matters,
|
|
13
|
+
because any prefix of N shots retains near-uniform angular coverage — a critical
|
|
14
|
+
property for retrospective undersampling studies and for incrementally acquiring
|
|
15
|
+
datasets. Density estimates are recomputed analytically for every subset so that
|
|
16
|
+
downstream DCF pipelines receive a geometry-consistent starting point. No
|
|
17
|
+
gridding, reconstruction, or DCF computation is performed here; this module
|
|
18
|
+
exclusively returns coordinate arrays and boolean masks.
|
|
19
|
+
|
|
20
|
+
Interface Summary (plain text)
|
|
21
|
+
------------------------------
|
|
22
|
+
read_header_geometry(header: dict) -> dict
|
|
23
|
+
generate_trajectory(name: str, header: dict, params: dict, backend) -> (list[array], dict)
|
|
24
|
+
shots_from_continuous(coords: array, segmentation: dict, backend) -> list[array]
|
|
25
|
+
undersample_shots(coords_per_shot: list, method: str, AF: float, params: dict, backend)
|
|
26
|
+
-> (array[bool], list[array[bool]] | None)
|
|
27
|
+
get_kspace_shots_and_mask(header, trajectory, undersampling, AF, traj_params, us_params, backend)
|
|
28
|
+
-> (list[array], array[bool], dict)
|
|
29
|
+
|
|
30
|
+
Units: all coordinates in cycles per metre (1/m), k-space centre at 0.
|
|
31
|
+
Conversion: k [1/m] = k_index / FOV_m where FOV_m = FOV_mm / 1000.
|
|
32
|
+
|
|
33
|
+
Backend must provide (see BackendInterface docstring below):
|
|
34
|
+
array, zeros, ones, linspace, stack, concat, sin, cos, sqrt, pi,
|
|
35
|
+
asarray, shape, to_cpu, floor, ceil, arange, randperm, meshgrid,
|
|
36
|
+
cast_bool, cast_int, cast_float, full.
|
|
37
|
+
|
|
38
|
+
Dependencies: numpy (always available); torch (optional, only if TorchBackend used).
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import math
|
|
44
|
+
import warnings
|
|
45
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
46
|
+
|
|
47
|
+
import numpy as np
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Type aliases
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
Array = Any # numpy ndarray or torch.Tensor
|
|
53
|
+
Backend = Any # NumpyBackend or TorchBackend instance
|
|
54
|
+
HeaderDict = Dict[str, Any]
|
|
55
|
+
MetaDict = Dict[str, Any]
|
|
56
|
+
ParamsDict = Dict[str, Any]
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Constants
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
GOLDEN_ANGLE_RAD: float = math.pi * (3.0 - math.sqrt(5.0)) # ≈ 2.39996 rad
|
|
62
|
+
GOLDEN_RATIO: float = (1.0 + math.sqrt(5.0)) / 2.0 # ≈ 1.61803
|
|
63
|
+
|
|
64
|
+
# Trajectory → compatible undersampling strategies
|
|
65
|
+
_TRAJ_US_COMPAT: Dict[str, List[str]] = {
|
|
66
|
+
"cartesian_2d": ["keep_acs", "poisson_disc_cartesian", "random_vd",
|
|
67
|
+
"drop_every", "prefix", "combined"],
|
|
68
|
+
"cartesian_3d": ["keep_acs", "poisson_disc_cartesian", "random_vd",
|
|
69
|
+
"drop_every", "prefix", "combined"],
|
|
70
|
+
"radial_2d": ["prefix", "golden_prefix", "drop_every",
|
|
71
|
+
"random_vd", "keep_acs", "combined"],
|
|
72
|
+
"golden_radial_2d": ["prefix", "golden_prefix", "drop_every",
|
|
73
|
+
"random_vd", "keep_acs", "combined"],
|
|
74
|
+
"spiral_2d": ["prefix", "drop_every", "variable_density_spiral",
|
|
75
|
+
"combined"],
|
|
76
|
+
"rosette_2d": ["prefix", "drop_every", "combined"],
|
|
77
|
+
"rosette_2d_petals": ["prefix", "drop_every", "combined"],
|
|
78
|
+
"concentric_rings_2d": ["prefix", "drop_every", "combined"],
|
|
79
|
+
"stack_of_stars": ["prefix", "drop_every", "random_vd", "keep_acs",
|
|
80
|
+
"combined"],
|
|
81
|
+
"stack_of_spirals": ["prefix", "drop_every", "combined"],
|
|
82
|
+
"stack_of_cones": ["prefix", "drop_every", "combined"],
|
|
83
|
+
"stack_of_rosettes": ["prefix", "drop_every", "combined"],
|
|
84
|
+
"stack_of_ECCENTRIC":["prefix", "drop_every", "combined"],
|
|
85
|
+
"3d_phyllotaxis": ["prefix", "golden_prefix", "drop_every",
|
|
86
|
+
"random_vd", "shell_based", "combined"],
|
|
87
|
+
"cones_3d": ["prefix", "drop_every", "shell_based", "combined"],
|
|
88
|
+
"cones_3d_rosette": ["prefix", "drop_every", "shell_based", "combined"],
|
|
89
|
+
"floret_3d": ["prefix", "drop_every", "shell_based", "combined"],
|
|
90
|
+
"3d_egg_rosette": ["prefix", "golden_prefix", "drop_every",
|
|
91
|
+
"shell_based", "combined"],
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Backend Abstraction
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class BackendInterface:
|
|
100
|
+
"""
|
|
101
|
+
Required interface for any backend.
|
|
102
|
+
|
|
103
|
+
Methods
|
|
104
|
+
-------
|
|
105
|
+
array(data, dtype=None)
|
|
106
|
+
Create a backend array from a Python list or numpy array.
|
|
107
|
+
zeros(shape, dtype=None)
|
|
108
|
+
Array of zeros.
|
|
109
|
+
ones(shape, dtype=None)
|
|
110
|
+
Array of ones.
|
|
111
|
+
full(shape, fill_value, dtype=None)
|
|
112
|
+
Array filled with fill_value.
|
|
113
|
+
linspace(start, stop, num, endpoint)
|
|
114
|
+
Evenly-spaced values (like numpy.linspace).
|
|
115
|
+
arange(start, stop, step=1)
|
|
116
|
+
Evenly-spaced integers/floats (like numpy.arange).
|
|
117
|
+
stack(arrays, axis=0)
|
|
118
|
+
Stack sequence of arrays along a new axis.
|
|
119
|
+
concat(arrays, axis=0)
|
|
120
|
+
Concatenate arrays along an existing axis.
|
|
121
|
+
sin(x) / cos(x) / sqrt(x)
|
|
122
|
+
Element-wise trig / sqrt.
|
|
123
|
+
pi()
|
|
124
|
+
Return float pi.
|
|
125
|
+
asarray(x)
|
|
126
|
+
Convert to backend array (no-op if already correct type).
|
|
127
|
+
shape(x) -> tuple
|
|
128
|
+
Return shape as a plain Python tuple.
|
|
129
|
+
to_cpu(x) -> numpy.ndarray
|
|
130
|
+
Return numpy view/copy (for I/O and assertions).
|
|
131
|
+
floor(x) / ceil(x)
|
|
132
|
+
Element-wise floor / ceil.
|
|
133
|
+
cast_bool(x) -> array
|
|
134
|
+
Cast to boolean.
|
|
135
|
+
cast_int(x) -> array
|
|
136
|
+
Cast to int64.
|
|
137
|
+
cast_float(x) -> array
|
|
138
|
+
Cast to float32 or float64.
|
|
139
|
+
randperm(n) -> 1-D int array
|
|
140
|
+
Random permutation of range(n) — for random undersampling.
|
|
141
|
+
meshgrid(*arrays, indexing='ij')
|
|
142
|
+
N-D coordinate grids.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class NumpyBackend:
|
|
147
|
+
"""Concrete backend using NumPy."""
|
|
148
|
+
|
|
149
|
+
def array(self, data, dtype=None):
|
|
150
|
+
return np.array(data, dtype=dtype)
|
|
151
|
+
|
|
152
|
+
def zeros(self, shape, dtype=None):
|
|
153
|
+
return np.zeros(shape, dtype=dtype or np.float32)
|
|
154
|
+
|
|
155
|
+
def ones(self, shape, dtype=None):
|
|
156
|
+
return np.ones(shape, dtype=dtype or np.float32)
|
|
157
|
+
|
|
158
|
+
def full(self, shape, fill_value, dtype=None):
|
|
159
|
+
return np.full(shape, fill_value, dtype=dtype or np.float32)
|
|
160
|
+
|
|
161
|
+
def linspace(self, start, stop, num, endpoint=True):
|
|
162
|
+
return np.linspace(start, stop, int(num), dtype=np.float32, endpoint=endpoint)
|
|
163
|
+
|
|
164
|
+
def arange(self, start, stop=None, step=1):
|
|
165
|
+
if stop is None:
|
|
166
|
+
return np.arange(start, dtype=np.float32)
|
|
167
|
+
return np.arange(start, stop, step, dtype=np.float32)
|
|
168
|
+
|
|
169
|
+
def stack(self, arrays, axis=0):
|
|
170
|
+
return np.stack(arrays, axis=axis)
|
|
171
|
+
|
|
172
|
+
def concat(self, arrays, axis=0):
|
|
173
|
+
return np.concatenate(arrays, axis=axis)
|
|
174
|
+
|
|
175
|
+
def sin(self, x):
|
|
176
|
+
return np.sin(x)
|
|
177
|
+
|
|
178
|
+
def cos(self, x):
|
|
179
|
+
return np.cos(x)
|
|
180
|
+
|
|
181
|
+
def sqrt(self, x):
|
|
182
|
+
return np.sqrt(x)
|
|
183
|
+
|
|
184
|
+
def pi(self):
|
|
185
|
+
return math.pi
|
|
186
|
+
|
|
187
|
+
def asarray(self, x):
|
|
188
|
+
return np.asarray(x, dtype=np.float32)
|
|
189
|
+
|
|
190
|
+
def shape(self, x):
|
|
191
|
+
return tuple(x.shape)
|
|
192
|
+
|
|
193
|
+
def to_cpu(self, x):
|
|
194
|
+
if isinstance(x, np.ndarray):
|
|
195
|
+
return x
|
|
196
|
+
return np.array(x)
|
|
197
|
+
|
|
198
|
+
def floor(self, x):
|
|
199
|
+
return np.floor(x)
|
|
200
|
+
|
|
201
|
+
def ceil(self, x):
|
|
202
|
+
return np.ceil(x)
|
|
203
|
+
|
|
204
|
+
def cast_bool(self, x):
|
|
205
|
+
return x.astype(bool)
|
|
206
|
+
|
|
207
|
+
def cast_int(self, x):
|
|
208
|
+
return x.astype(np.int64)
|
|
209
|
+
|
|
210
|
+
def cast_float(self, x):
|
|
211
|
+
return x.astype(np.float32)
|
|
212
|
+
|
|
213
|
+
def randperm(self, n):
|
|
214
|
+
return np.random.permutation(n).astype(np.int64)
|
|
215
|
+
|
|
216
|
+
def meshgrid(self, *arrays, indexing="ij"):
|
|
217
|
+
return np.meshgrid(*arrays, indexing=indexing)
|
|
218
|
+
|
|
219
|
+
def __repr__(self):
|
|
220
|
+
return "NumpyBackend()"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TorchBackend:
|
|
224
|
+
"""
|
|
225
|
+
Concrete backend using PyTorch.
|
|
226
|
+
|
|
227
|
+
Requires: import torch
|
|
228
|
+
device keyword can be passed at construction, e.g. TorchBackend(device='cuda').
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(self, device: str = "cpu", dtype=None):
|
|
232
|
+
import torch # noqa: F401 – deferred import so numpy-only usage works
|
|
233
|
+
self._torch = torch
|
|
234
|
+
self.device = torch.device(device)
|
|
235
|
+
self._dtype = dtype or torch.float32
|
|
236
|
+
|
|
237
|
+
def array(self, data, dtype=None):
|
|
238
|
+
dt = dtype or self._dtype
|
|
239
|
+
return self._torch.tensor(data, dtype=dt, device=self.device)
|
|
240
|
+
|
|
241
|
+
def zeros(self, shape, dtype=None):
|
|
242
|
+
return self._torch.zeros(shape, dtype=dtype or self._dtype, device=self.device)
|
|
243
|
+
|
|
244
|
+
def ones(self, shape, dtype=None):
|
|
245
|
+
return self._torch.ones(shape, dtype=dtype or self._dtype, device=self.device)
|
|
246
|
+
|
|
247
|
+
def full(self, shape, fill_value, dtype=None):
|
|
248
|
+
return self._torch.full(shape, fill_value, dtype=dtype or self._dtype,
|
|
249
|
+
device=self.device)
|
|
250
|
+
|
|
251
|
+
def linspace(self, start, stop, num):
|
|
252
|
+
return self._torch.linspace(start, stop, int(num), dtype=self._dtype,
|
|
253
|
+
device=self.device)
|
|
254
|
+
|
|
255
|
+
def arange(self, start, stop=None, step=1):
|
|
256
|
+
if stop is None:
|
|
257
|
+
return self._torch.arange(start, dtype=self._dtype, device=self.device)
|
|
258
|
+
return self._torch.arange(start, stop, step, dtype=self._dtype,
|
|
259
|
+
device=self.device)
|
|
260
|
+
|
|
261
|
+
def stack(self, arrays, axis=0):
|
|
262
|
+
return self._torch.stack(arrays, dim=axis)
|
|
263
|
+
|
|
264
|
+
def concat(self, arrays, axis=0):
|
|
265
|
+
return self._torch.cat(arrays, dim=axis)
|
|
266
|
+
|
|
267
|
+
def sin(self, x):
|
|
268
|
+
return self._torch.sin(x)
|
|
269
|
+
|
|
270
|
+
def cos(self, x):
|
|
271
|
+
return self._torch.cos(x)
|
|
272
|
+
|
|
273
|
+
def sqrt(self, x):
|
|
274
|
+
return self._torch.sqrt(x)
|
|
275
|
+
|
|
276
|
+
def pi(self):
|
|
277
|
+
return math.pi
|
|
278
|
+
|
|
279
|
+
def asarray(self, x):
|
|
280
|
+
if isinstance(x, self._torch.Tensor):
|
|
281
|
+
return x.to(dtype=self._dtype, device=self.device)
|
|
282
|
+
return self._torch.tensor(x, dtype=self._dtype, device=self.device)
|
|
283
|
+
|
|
284
|
+
def shape(self, x):
|
|
285
|
+
return tuple(x.shape)
|
|
286
|
+
|
|
287
|
+
def to_cpu(self, x):
|
|
288
|
+
if isinstance(x, self._torch.Tensor):
|
|
289
|
+
return x.detach().cpu().numpy()
|
|
290
|
+
return np.asarray(x)
|
|
291
|
+
|
|
292
|
+
def floor(self, x):
|
|
293
|
+
return self._torch.floor(x)
|
|
294
|
+
|
|
295
|
+
def ceil(self, x):
|
|
296
|
+
return self._torch.ceil(x)
|
|
297
|
+
|
|
298
|
+
def cast_bool(self, x):
|
|
299
|
+
return x.bool()
|
|
300
|
+
|
|
301
|
+
def cast_int(self, x):
|
|
302
|
+
return x.long()
|
|
303
|
+
|
|
304
|
+
def cast_float(self, x):
|
|
305
|
+
return x.to(self._dtype)
|
|
306
|
+
|
|
307
|
+
def randperm(self, n):
|
|
308
|
+
return self._torch.randperm(n, device=self.device)
|
|
309
|
+
|
|
310
|
+
def meshgrid(self, *arrays, indexing="ij"):
|
|
311
|
+
return self._torch.meshgrid(*arrays, indexing=indexing)
|
|
312
|
+
|
|
313
|
+
def __repr__(self):
|
|
314
|
+
return f"TorchBackend(device={self.device}, dtype={self._dtype})"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _validate_backend(backend: Backend) -> None:
|
|
318
|
+
"""Raise if backend is missing any required method."""
|
|
319
|
+
required = [
|
|
320
|
+
"array", "zeros", "ones", "full", "linspace", "arange",
|
|
321
|
+
"stack", "concat", "sin", "cos", "sqrt", "pi",
|
|
322
|
+
"asarray", "shape", "to_cpu", "floor", "ceil",
|
|
323
|
+
"cast_bool", "cast_int", "cast_float", "randperm", "meshgrid",
|
|
324
|
+
]
|
|
325
|
+
missing = [m for m in required if not hasattr(backend, m)]
|
|
326
|
+
if missing:
|
|
327
|
+
raise TypeError(
|
|
328
|
+
f"Backend {backend!r} is missing required methods: {missing}. "
|
|
329
|
+
"Implement a class with all methods listed in BackendInterface."
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
# Header Geometry
|
|
335
|
+
# ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
# Standard NIfTI-MRS header field names (for documentation & parsing)
|
|
338
|
+
_NIFTI_MRS_FIELDS = {
|
|
339
|
+
"pixdim": "voxel sizes (mm) stored at pixdim[1:4]",
|
|
340
|
+
"dim": "matrix dimensions stored at dim[1:4]",
|
|
341
|
+
"qform_code": "qform code (non-zero → use qform)",
|
|
342
|
+
"sform_code": "sform code (non-zero → use sform)",
|
|
343
|
+
"SpectrometerFrequency":"transmitter centre frequency [Hz] list",
|
|
344
|
+
"DwellTime": "ADC dwell time [s] per spectral point",
|
|
345
|
+
"ResonantNucleus": "nucleus label e.g. '1H'",
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def read_header_geometry(header: HeaderDict) -> dict:
|
|
350
|
+
"""
|
|
351
|
+
Parse a NIfTI-MRS header dictionary and return imaging geometry fields.
|
|
352
|
+
|
|
353
|
+
Parameters
|
|
354
|
+
----------
|
|
355
|
+
header : dict
|
|
356
|
+
Parsed NIfTI-MRS header. Recognised standard fields (see _NIFTI_MRS_FIELDS):
|
|
357
|
+
- ``dim`` : array-like, dim[1..3] → matrix [nx, ny, nz]
|
|
358
|
+
- ``pixdim`` : array-like, pixdim[1..3] → voxel size [vx, vy, vz] in mm
|
|
359
|
+
- ``SpectrometerFrequency`` : list[float] → centre frequency in Hz
|
|
360
|
+
- ``DwellTime`` : float → ADC dwell time in seconds
|
|
361
|
+
Fallback keys also accepted: ``matrix``, ``voxel_size_mm``,
|
|
362
|
+
``fov_mm``, ``dwell_time``.
|
|
363
|
+
|
|
364
|
+
Returns
|
|
365
|
+
-------
|
|
366
|
+
dict with keys:
|
|
367
|
+
fov_mm : tuple (fx, fy, fz) — field of view in mm
|
|
368
|
+
matrix : tuple (nx, ny, nz) — matrix size
|
|
369
|
+
voxel_mm : tuple (vx, vy, vz) — voxel size in mm
|
|
370
|
+
dwell_time : float or None
|
|
371
|
+
spectrometer_frequency : float or None
|
|
372
|
+
ndim : 2 or 3 (spatial)
|
|
373
|
+
inferred : list[str] — list of fields that were inferred rather than explicit
|
|
374
|
+
"""
|
|
375
|
+
inferred: List[str] = []
|
|
376
|
+
|
|
377
|
+
# ---- Matrix ----
|
|
378
|
+
if "dim" in header:
|
|
379
|
+
dim = header["dim"]
|
|
380
|
+
nx, ny, nz = int(dim[1]), int(dim[2]), int(dim[3])
|
|
381
|
+
elif "matrix" in header:
|
|
382
|
+
m = header["matrix"]
|
|
383
|
+
nx, ny, nz = int(m[0]), int(m[1]), int(m[2]) if len(m) > 2 else 1
|
|
384
|
+
inferred.append("matrix from 'matrix' fallback key")
|
|
385
|
+
else:
|
|
386
|
+
raise KeyError(
|
|
387
|
+
"Header must contain 'dim' (NIfTI-MRS standard, dim[1:4] = [nx,ny,nz]) "
|
|
388
|
+
"or fallback key 'matrix'. Neither was found."
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# ---- Voxel size ----
|
|
392
|
+
if "pixdim" in header:
|
|
393
|
+
pd = header["pixdim"]
|
|
394
|
+
vx, vy, vz = float(pd[1]), float(pd[2]), float(pd[3])
|
|
395
|
+
elif "voxel_size_mm" in header:
|
|
396
|
+
vs = header["voxel_size_mm"]
|
|
397
|
+
vx, vy, vz = (float(vs[0]), float(vs[1]),
|
|
398
|
+
float(vs[2]) if len(vs) > 2 else float(vs[0]))
|
|
399
|
+
inferred.append("voxel_mm from 'voxel_size_mm' fallback key")
|
|
400
|
+
elif "fov_mm" in header:
|
|
401
|
+
# derive voxel from FOV / matrix
|
|
402
|
+
fov = header["fov_mm"]
|
|
403
|
+
vx = float(fov[0]) / nx
|
|
404
|
+
vy = float(fov[1]) / ny
|
|
405
|
+
vz = float(fov[2]) / nz if len(fov) > 2 else 1.0
|
|
406
|
+
inferred.append("voxel_mm derived from fov_mm / matrix")
|
|
407
|
+
else:
|
|
408
|
+
raise KeyError(
|
|
409
|
+
"Header must contain 'pixdim' (NIfTI-MRS standard, pixdim[1:3] = voxel mm) "
|
|
410
|
+
"or fallback keys 'voxel_size_mm' or 'fov_mm'. None found."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
fov_mm = (nx * vx, ny * vy, nz * vz)
|
|
414
|
+
|
|
415
|
+
# ---- Dwell time ----
|
|
416
|
+
dwell_time: Optional[float] = None
|
|
417
|
+
if "DwellTime" in header:
|
|
418
|
+
dwell_time = float(header["DwellTime"])
|
|
419
|
+
elif "dwell_time" in header:
|
|
420
|
+
dwell_time = float(header["dwell_time"])
|
|
421
|
+
inferred.append("dwell_time from 'dwell_time' fallback key")
|
|
422
|
+
|
|
423
|
+
# ---- Spectrometer frequency ----
|
|
424
|
+
spec_freq: Optional[float] = None
|
|
425
|
+
if "SpectrometerFrequency" in header:
|
|
426
|
+
sf = header["SpectrometerFrequency"]
|
|
427
|
+
spec_freq = float(sf[0]) if hasattr(sf, "__len__") else float(sf)
|
|
428
|
+
|
|
429
|
+
ndim = 2 if nz == 1 else 3
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
"fov_mm": fov_mm,
|
|
433
|
+
"matrix": (nx, ny, nz),
|
|
434
|
+
"voxel_mm": (vx, vy, vz),
|
|
435
|
+
"dwell_time": dwell_time,
|
|
436
|
+
"spectrometer_frequency": spec_freq,
|
|
437
|
+
"ndim": ndim,
|
|
438
|
+
"inferred": inferred,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
# Trajectory Helpers
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
|
|
446
|
+
def _kmax_from_geometry(geom: dict) -> Tuple[float, float, float]:
|
|
447
|
+
"""
|
|
448
|
+
Compute kmax per axis in cycles/m.
|
|
449
|
+
|
|
450
|
+
Formula: kmax_axis = (N_axis / 2) / (FOV_axis_mm / 1000)
|
|
451
|
+
equivalently kmax = N / (2 * FOV_m)
|
|
452
|
+
For Nyquist sampling Δk = 1 / FOV_m, so kmax = (N/2) * Δk.
|
|
453
|
+
"""
|
|
454
|
+
nx, ny, nz = geom["matrix"]
|
|
455
|
+
fx_m, fy_m, fz_m = (v / 1000.0 for v in geom["fov_mm"])
|
|
456
|
+
kmax_x = (nx / 2.0) / fx_m
|
|
457
|
+
kmax_y = (ny / 2.0) / fy_m
|
|
458
|
+
kmax_z = (nz / 2.0) / fz_m if nz > 1 else 0.0
|
|
459
|
+
return kmax_x, kmax_y, kmax_z
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _density_estimate_placeholder(trajectory_name: str, n_shots: int,
|
|
463
|
+
samples_per_shot: int, geom: dict) -> dict:
|
|
464
|
+
"""
|
|
465
|
+
Return a placeholder density estimate dictionary.
|
|
466
|
+
|
|
467
|
+
A proper DCF would require Voronoi cells or iterative pipe methods.
|
|
468
|
+
This placeholder records the parameters so downstream code can
|
|
469
|
+
recompute or override it.
|
|
470
|
+
|
|
471
|
+
Returns
|
|
472
|
+
-------
|
|
473
|
+
dict with keys:
|
|
474
|
+
type : str — 'analytic_placeholder'
|
|
475
|
+
description : str
|
|
476
|
+
n_shots : int
|
|
477
|
+
samples_per_shot : int
|
|
478
|
+
kmax : tuple[float]
|
|
479
|
+
note : str
|
|
480
|
+
"""
|
|
481
|
+
kmax = _kmax_from_geometry(geom)
|
|
482
|
+
return {
|
|
483
|
+
"type": "analytic_placeholder",
|
|
484
|
+
"description": (
|
|
485
|
+
f"Density placeholder for '{trajectory_name}'. "
|
|
486
|
+
"Compute DCF externally (e.g. iterative pipe, Voronoi, or analytic)."
|
|
487
|
+
),
|
|
488
|
+
"trajectory": trajectory_name,
|
|
489
|
+
"n_shots": n_shots,
|
|
490
|
+
"samples_per_shot": samples_per_shot,
|
|
491
|
+
"kmax": kmax,
|
|
492
|
+
"note": (
|
|
493
|
+
"For Cartesian: density = 1 everywhere by definition. "
|
|
494
|
+
"For radial: density ∝ |k| (centre-heavy). "
|
|
495
|
+
"For spirals: use gradient-delay-corrected NUFFT pipe density."
|
|
496
|
+
),
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
# Trajectory Generators
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
def _traj_cartesian_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
505
|
+
"""
|
|
506
|
+
2D Cartesian trajectory.
|
|
507
|
+
|
|
508
|
+
Each shot = one phase-encode line (ky) with full kx readout.
|
|
509
|
+
|
|
510
|
+
Parameters (params)
|
|
511
|
+
-------------------
|
|
512
|
+
ordering : str
|
|
513
|
+
'linear' (default) — ky from -N/2 to N/2-1
|
|
514
|
+
'centric' — ky ordered from centre outwards
|
|
515
|
+
n_shots : int, optional
|
|
516
|
+
Number of phase encodes to generate (default: ny).
|
|
517
|
+
|
|
518
|
+
Output shot count: n_shots (= ny unless overridden).
|
|
519
|
+
Sample count per shot: nx.
|
|
520
|
+
"""
|
|
521
|
+
nx, ny, _ = geom["matrix"]
|
|
522
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
523
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
524
|
+
|
|
525
|
+
ordering = params.get("ordering", "linear")
|
|
526
|
+
n_shots = int(params.get("n_shots", ny))
|
|
527
|
+
|
|
528
|
+
kx_line = backend.linspace(-nx / 2, nx / 2 - 1, nx, True) / fov_x_m # (nx,) in 1/m
|
|
529
|
+
|
|
530
|
+
if ordering == "centric":
|
|
531
|
+
half = ny // 2
|
|
532
|
+
ky_idx = []
|
|
533
|
+
for i in range(max(half, ny - half)):
|
|
534
|
+
if i < half:
|
|
535
|
+
ky_idx.extend([half - 1 - i, half + i] if half + i < ny else [half - 1 - i])
|
|
536
|
+
elif half + i < ny:
|
|
537
|
+
ky_idx.append(half + i)
|
|
538
|
+
ky_indices = np.array(ky_idx[:n_shots], dtype=np.float32)
|
|
539
|
+
else:
|
|
540
|
+
ky_indices = np.arange(n_shots, dtype=np.float32)
|
|
541
|
+
|
|
542
|
+
# Map indices [0..ny-1] → k-space positions
|
|
543
|
+
# ky = (index - ny/2) / fov_y_m
|
|
544
|
+
ky_positions = (ky_indices - ny / 2.0) / fov_y_m
|
|
545
|
+
|
|
546
|
+
shots = []
|
|
547
|
+
for ky_val in ky_positions:
|
|
548
|
+
ky_line = backend.full((nx,), float(ky_val))
|
|
549
|
+
shot = backend.stack([kx_line, ky_line], axis=1) # (nx, 2)
|
|
550
|
+
shots.append(shot)
|
|
551
|
+
|
|
552
|
+
kmax_x, kmax_y, _ = _kmax_from_geometry(geom)
|
|
553
|
+
meta = {
|
|
554
|
+
"trajectory_type": "cartesian_2d",
|
|
555
|
+
"ordering": ordering,
|
|
556
|
+
"kmax": (kmax_x, kmax_y),
|
|
557
|
+
"fov_mm": geom["fov_mm"][:2],
|
|
558
|
+
"matrix": (nx, ny),
|
|
559
|
+
"n_shots": n_shots,
|
|
560
|
+
"samples_per_shot": nx,
|
|
561
|
+
"density_estimate": _density_estimate_placeholder("cartesian_2d", n_shots, nx, geom),
|
|
562
|
+
"params_used": params,
|
|
563
|
+
}
|
|
564
|
+
return shots, meta
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _traj_cartesian_3d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
568
|
+
"""
|
|
569
|
+
3D Cartesian trajectory.
|
|
570
|
+
|
|
571
|
+
Each shot = one (ky, kz) phase-encode pair with full kx readout.
|
|
572
|
+
|
|
573
|
+
Parameters (params)
|
|
574
|
+
-------------------
|
|
575
|
+
ordering : str
|
|
576
|
+
'linear' (default), 'centric_ky', 'centric_kz', 'centric_both'
|
|
577
|
+
n_shots : int, optional
|
|
578
|
+
Total phase encodes (default: ny*nz).
|
|
579
|
+
|
|
580
|
+
Output shot count: ny * nz (or n_shots).
|
|
581
|
+
Sample count per shot: nx.
|
|
582
|
+
"""
|
|
583
|
+
if geom["ndim"] < 3:
|
|
584
|
+
raise ValueError(
|
|
585
|
+
"cartesian_3d requires a 3D geometry (nz > 1). "
|
|
586
|
+
"Header has nz=1. Use cartesian_2d for 2D acquisitions."
|
|
587
|
+
)
|
|
588
|
+
nx, ny, nz = geom["matrix"]
|
|
589
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
590
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
591
|
+
fov_z_m = geom["fov_mm"][2] / 1000.0
|
|
592
|
+
|
|
593
|
+
ordering = params.get("ordering", "linear")
|
|
594
|
+
kx_line = backend.linspace(-nx / 2, nx / 2 - 1, nx) / fov_x_m
|
|
595
|
+
|
|
596
|
+
ky_arr = (np.arange(ny, dtype=np.float32) - ny / 2.0) / fov_y_m
|
|
597
|
+
kz_arr = (np.arange(nz, dtype=np.float32) - nz / 2.0) / fov_z_m
|
|
598
|
+
|
|
599
|
+
if "centric" in ordering:
|
|
600
|
+
def _centric_order(n):
|
|
601
|
+
half = n // 2
|
|
602
|
+
idx = []
|
|
603
|
+
for i in range(max(half, n - half)):
|
|
604
|
+
if i < half:
|
|
605
|
+
idx.extend([half - 1 - i, half + i] if half + i < n else [half - 1 - i])
|
|
606
|
+
elif half + i < n:
|
|
607
|
+
idx.append(half + i)
|
|
608
|
+
return idx
|
|
609
|
+
|
|
610
|
+
if ordering in ("centric_ky", "centric_both"):
|
|
611
|
+
ky_arr = ky_arr[_centric_order(ny)]
|
|
612
|
+
if ordering in ("centric_kz", "centric_both"):
|
|
613
|
+
kz_arr = kz_arr[_centric_order(nz)]
|
|
614
|
+
|
|
615
|
+
n_max = int(params.get("n_shots", ny * nz))
|
|
616
|
+
shots = []
|
|
617
|
+
count = 0
|
|
618
|
+
for kz_val in kz_arr:
|
|
619
|
+
if count >= n_max:
|
|
620
|
+
break
|
|
621
|
+
for ky_val in ky_arr:
|
|
622
|
+
if count >= n_max:
|
|
623
|
+
break
|
|
624
|
+
ky_line = backend.full((nx,), float(ky_val))
|
|
625
|
+
kz_line = backend.full((nx,), float(kz_val))
|
|
626
|
+
shot = backend.stack([kx_line, ky_line, kz_line], axis=1) # (nx, 3)
|
|
627
|
+
shots.append(shot)
|
|
628
|
+
count += 1
|
|
629
|
+
|
|
630
|
+
kmax_x, kmax_y, kmax_z = _kmax_from_geometry(geom)
|
|
631
|
+
n_shots = len(shots)
|
|
632
|
+
meta = {
|
|
633
|
+
"trajectory_type": "cartesian_3d",
|
|
634
|
+
"ordering": ordering,
|
|
635
|
+
"kmax": (kmax_x, kmax_y, kmax_z),
|
|
636
|
+
"fov_mm": geom["fov_mm"],
|
|
637
|
+
"matrix": (nx, ny, nz),
|
|
638
|
+
"n_shots": n_shots,
|
|
639
|
+
"samples_per_shot": nx,
|
|
640
|
+
"density_estimate": _density_estimate_placeholder("cartesian_3d", n_shots, nx, geom),
|
|
641
|
+
"params_used": params,
|
|
642
|
+
}
|
|
643
|
+
return shots, meta
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _traj_radial_2d(geom: dict, params: dict, backend: Backend,
|
|
647
|
+
use_golden: bool = False) -> Tuple[List[Array], MetaDict]:
|
|
648
|
+
"""
|
|
649
|
+
2D radial trajectory (uniform or golden-angle angular increment).
|
|
650
|
+
|
|
651
|
+
Center-out by default: samples go from k=0 outward to kmax.
|
|
652
|
+
|
|
653
|
+
Parameters (params)
|
|
654
|
+
-------------------
|
|
655
|
+
n_shots : int
|
|
656
|
+
Number of spokes (default: π/2 * max(nx,ny) ≈ Nyquist angular rate).
|
|
657
|
+
samples_per_shot : int
|
|
658
|
+
Samples per spoke (default: max(nx, ny)).
|
|
659
|
+
center_out : bool
|
|
660
|
+
If True (default), spoke goes from centre outward.
|
|
661
|
+
If False, spoke is symmetric (–kmax → +kmax, centre at midpoint).
|
|
662
|
+
golden_angle_rad : float
|
|
663
|
+
Golden angle override (default: GOLDEN_ANGLE_RAD).
|
|
664
|
+
|
|
665
|
+
Output shot count: n_shots.
|
|
666
|
+
Sample count per shot: samples_per_shot (constant).
|
|
667
|
+
"""
|
|
668
|
+
nx, ny, _ = geom["matrix"]
|
|
669
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
670
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
671
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
672
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
673
|
+
|
|
674
|
+
n_shots = int(params.get("n_shots", max(1, int(math.pi / 2 * max(nx, ny)))))
|
|
675
|
+
sps = int(params.get("samples_per_shot", max(nx, ny)))
|
|
676
|
+
center_out = bool(params.get("center_out", True))
|
|
677
|
+
ga = float(params.get("golden_angle_rad", GOLDEN_ANGLE_RAD))
|
|
678
|
+
|
|
679
|
+
name = "golden_radial_2d" if use_golden else "radial_2d"
|
|
680
|
+
shots = []
|
|
681
|
+
|
|
682
|
+
for i in range(n_shots):
|
|
683
|
+
if use_golden:
|
|
684
|
+
angle = i * ga
|
|
685
|
+
else:
|
|
686
|
+
angle = i * math.pi / n_shots
|
|
687
|
+
|
|
688
|
+
if center_out:
|
|
689
|
+
r = backend.linspace(0.0, 1.0, sps)
|
|
690
|
+
else:
|
|
691
|
+
r = backend.linspace(-1.0, 1.0, sps)
|
|
692
|
+
|
|
693
|
+
kx = r * backend.cos(backend.array([angle]))[0] * kmax_x
|
|
694
|
+
ky = r * backend.sin(backend.array([angle]))[0] * kmax_y
|
|
695
|
+
shot = backend.stack([kx, ky], axis=1)
|
|
696
|
+
shots.append(shot)
|
|
697
|
+
|
|
698
|
+
kmax = (kmax_x, kmax_y)
|
|
699
|
+
meta = {
|
|
700
|
+
"trajectory_type": name,
|
|
701
|
+
"golden_angle_rad": ga if use_golden else None,
|
|
702
|
+
"kmax": kmax,
|
|
703
|
+
"fov_mm": geom["fov_mm"][:2],
|
|
704
|
+
"matrix": (nx, ny),
|
|
705
|
+
"n_shots": n_shots,
|
|
706
|
+
"samples_per_shot": sps,
|
|
707
|
+
"center_out": center_out,
|
|
708
|
+
"density_estimate": _density_estimate_placeholder(name, n_shots, sps, geom),
|
|
709
|
+
"params_used": params,
|
|
710
|
+
}
|
|
711
|
+
return shots, meta
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _traj_spiral_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
715
|
+
"""
|
|
716
|
+
2D multi-shot Archimedean or variable-density spiral.
|
|
717
|
+
|
|
718
|
+
Each shot = one spiral arm (interleaved rotation by 2π/n_shots).
|
|
719
|
+
Center-out (starts at k=0, ends at kmax).
|
|
720
|
+
|
|
721
|
+
Parameters (params)
|
|
722
|
+
-------------------
|
|
723
|
+
n_shots : int
|
|
724
|
+
Number of interleaved arms (default: 1).
|
|
725
|
+
samples_per_shot : int
|
|
726
|
+
Samples per arm (default: nx * ny // n_shots).
|
|
727
|
+
spiral_turns : float
|
|
728
|
+
Number of full turns per arm (default: n_shots * max(nx,ny)/4).
|
|
729
|
+
vd_alpha : float
|
|
730
|
+
Variable-density exponent ≥ 1 (1 = Archimedean, >1 = denser centre).
|
|
731
|
+
r(t) ∝ t^(1/vd_alpha).
|
|
732
|
+
rotate_first : bool
|
|
733
|
+
If True (default), shot i is rotated by i * 2π / n_shots.
|
|
734
|
+
"""
|
|
735
|
+
nx, ny, _ = geom["matrix"]
|
|
736
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
737
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
738
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
739
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
740
|
+
|
|
741
|
+
n_shots = int(params.get("n_shots", 1))
|
|
742
|
+
sps = int(params.get("samples_per_shot", max(nx * ny // max(n_shots, 1), 64)))
|
|
743
|
+
turns = float(params.get("spiral_turns", n_shots * max(nx, ny) / 4.0))
|
|
744
|
+
vd_alpha = float(params.get("vd_alpha", 1.0))
|
|
745
|
+
rotate_first = bool(params.get("rotate_first", True))
|
|
746
|
+
|
|
747
|
+
t = backend.linspace(0.0, 1.0, sps) # normalised parameter
|
|
748
|
+
# r(t) ∝ t^(1/vd_alpha), ensures r ∈ [0, 1]
|
|
749
|
+
r = t ** (1.0 / vd_alpha)
|
|
750
|
+
theta_base = r * (2.0 * math.pi * turns)
|
|
751
|
+
|
|
752
|
+
shots = []
|
|
753
|
+
for i in range(n_shots):
|
|
754
|
+
rot = (2.0 * math.pi * i / n_shots) if rotate_first else 0.0
|
|
755
|
+
theta = theta_base + rot
|
|
756
|
+
kx = r * backend.cos(theta) * kmax_x
|
|
757
|
+
ky = r * backend.sin(theta) * kmax_y
|
|
758
|
+
shot = backend.stack([kx, ky], axis=1)
|
|
759
|
+
shots.append(shot)
|
|
760
|
+
|
|
761
|
+
meta = {
|
|
762
|
+
"trajectory_type": "spiral_2d",
|
|
763
|
+
"spiral_turns": turns,
|
|
764
|
+
"vd_alpha": vd_alpha,
|
|
765
|
+
"kmax": (kmax_x, kmax_y),
|
|
766
|
+
"fov_mm": geom["fov_mm"][:2],
|
|
767
|
+
"matrix": (nx, ny),
|
|
768
|
+
"n_shots": n_shots,
|
|
769
|
+
"samples_per_shot": sps,
|
|
770
|
+
"density_estimate": _density_estimate_placeholder("spiral_2d", n_shots, sps, geom),
|
|
771
|
+
"params_used": params,
|
|
772
|
+
}
|
|
773
|
+
return shots, meta
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _traj_rosette_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
777
|
+
"""
|
|
778
|
+
2D Rosette (looped petal) trajectory.
|
|
779
|
+
|
|
780
|
+
Parametric: kx = kmax * sin(ω1*t) * cos(ω2*t)
|
|
781
|
+
ky = kmax * sin(ω1*t) * sin(ω2*t)
|
|
782
|
+
|
|
783
|
+
Parameters (params)
|
|
784
|
+
-------------------
|
|
785
|
+
n_shots : int
|
|
786
|
+
Number of shots (each is one full rosette pass, default: 1).
|
|
787
|
+
samples_per_shot : int
|
|
788
|
+
Samples per shot (default: 512).
|
|
789
|
+
omega1, omega2 : float
|
|
790
|
+
Petal frequencies (default: 5, 4) — ratio determines petal count.
|
|
791
|
+
rotation_per_shot : float
|
|
792
|
+
Angular increment (rad) between shots (default: golden angle).
|
|
793
|
+
"""
|
|
794
|
+
nx, ny, _ = geom["matrix"]
|
|
795
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
796
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
797
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
798
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
799
|
+
|
|
800
|
+
n_shots = int(params.get("n_shots", 1))
|
|
801
|
+
sps = int(params.get("samples_per_shot", 512))
|
|
802
|
+
omega1 = float(params.get("omega1", 5.0))
|
|
803
|
+
omega2 = float(params.get("omega2", 4.0))
|
|
804
|
+
rot_step = float(params.get("rotation_per_shot", GOLDEN_ANGLE_RAD))
|
|
805
|
+
|
|
806
|
+
t = backend.linspace(0.0, 2.0 * math.pi, sps)
|
|
807
|
+
shots = []
|
|
808
|
+
for i in range(n_shots):
|
|
809
|
+
rot = i * rot_step
|
|
810
|
+
kx = backend.sin(t * omega1) * backend.cos(t * omega2 + rot) * kmax_x
|
|
811
|
+
ky = backend.sin(t * omega1) * backend.sin(t * omega2 + rot) * kmax_y
|
|
812
|
+
shot = backend.stack([kx, ky], axis=1)
|
|
813
|
+
shots.append(shot)
|
|
814
|
+
|
|
815
|
+
meta = {
|
|
816
|
+
"trajectory_type": "rosette_2d",
|
|
817
|
+
"omega1": omega1,
|
|
818
|
+
"omega2": omega2,
|
|
819
|
+
"kmax": (kmax_x, kmax_y),
|
|
820
|
+
"fov_mm": geom["fov_mm"][:2],
|
|
821
|
+
"matrix": (nx, ny),
|
|
822
|
+
"n_shots": n_shots,
|
|
823
|
+
"samples_per_shot": sps,
|
|
824
|
+
"density_estimate": _density_estimate_placeholder("rosette_2d", n_shots, sps, geom),
|
|
825
|
+
"params_used": params,
|
|
826
|
+
}
|
|
827
|
+
return shots, meta
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _traj_rosette_2d_petals(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
831
|
+
"""
|
|
832
|
+
2D Rosette where each individual petal is returned as a separate shot.
|
|
833
|
+
|
|
834
|
+
Each petal traces a closed lemniscate-like path from the k-space origin
|
|
835
|
+
to the tip and back, giving a center-out-center readout per petal:
|
|
836
|
+
|
|
837
|
+
kx(t) = kmax_x · sin(t) · cos(t + φₖ)
|
|
838
|
+
ky(t) = kmax_y · sin(t) · sin(t + φₖ)
|
|
839
|
+
t ∈ [0, π], φₖ = -π/2 + 2π·k/n_petals + start_angle_rad
|
|
840
|
+
|
|
841
|
+
At t=0 and t=π: sin(t)=0, so both endpoints are at the origin.
|
|
842
|
+
The petal tip lies at t=π/2, pointing in direction φₖ + π/2.
|
|
843
|
+
With the default start_angle = -π/2, petal 0 points along +kx.
|
|
844
|
+
|
|
845
|
+
Parameters (params)
|
|
846
|
+
-------------------
|
|
847
|
+
n_petals : int
|
|
848
|
+
Number of petals / shots (default: 7).
|
|
849
|
+
samples_per_shot : int
|
|
850
|
+
Samples per petal (default: 512). Includes origin at both ends.
|
|
851
|
+
start_angle_rad : float
|
|
852
|
+
Extra azimuthal offset applied to all petals (default: 0).
|
|
853
|
+
The built-in -π/2 offset already aligns petal 0 with +kx.
|
|
854
|
+
|
|
855
|
+
Output shot count: n_petals.
|
|
856
|
+
Sample count per shot: samples_per_shot (constant).
|
|
857
|
+
"""
|
|
858
|
+
nx, ny = geom["matrix"][0], geom["matrix"][1]
|
|
859
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
860
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
861
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
862
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
863
|
+
|
|
864
|
+
n_petals = int(params.get("n_petals", 7))
|
|
865
|
+
sps = int(params.get("samples_per_shot", 512))
|
|
866
|
+
start_angle = float(params.get("start_angle_rad", 0.0))
|
|
867
|
+
|
|
868
|
+
t = backend.linspace(0.0, math.pi, sps) # one petal: origin → tip → origin
|
|
869
|
+
shots = []
|
|
870
|
+
for k in range(n_petals):
|
|
871
|
+
# -π/2 aligns petal 0 with +kx; 2π*k/n_petals rotates CCW
|
|
872
|
+
phi = -math.pi / 2.0 + 2.0 * math.pi * k / n_petals + start_angle
|
|
873
|
+
kx = backend.sin(t) * backend.cos(t + phi) * kmax_x
|
|
874
|
+
ky = backend.sin(t) * backend.sin(t + phi) * kmax_y
|
|
875
|
+
shot = backend.stack([kx, ky], axis=1)
|
|
876
|
+
shots.append(shot)
|
|
877
|
+
|
|
878
|
+
kmax_xv, kmax_yv, _ = _kmax_from_geometry(geom)
|
|
879
|
+
meta = {
|
|
880
|
+
"trajectory_type": "rosette_2d_petals",
|
|
881
|
+
"n_petals": n_petals,
|
|
882
|
+
"kmax": (kmax_xv, kmax_yv),
|
|
883
|
+
"fov_mm": geom["fov_mm"][:2],
|
|
884
|
+
"matrix": (nx, ny),
|
|
885
|
+
"n_shots": n_petals,
|
|
886
|
+
"samples_per_shot": sps,
|
|
887
|
+
"center_out_center": True,
|
|
888
|
+
"density_estimate": _density_estimate_placeholder(
|
|
889
|
+
"rosette_2d_petals", n_petals, sps, geom),
|
|
890
|
+
"params_used": params,
|
|
891
|
+
}
|
|
892
|
+
return shots, meta
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _traj_stack_of_x(geom: dict, params: dict, backend: Backend,
|
|
896
|
+
inplane_name: str) -> Tuple[List[Array], MetaDict]:
|
|
897
|
+
"""
|
|
898
|
+
Generic stack-of-2D hybrid: generate in-plane trajectory per kz slice.
|
|
899
|
+
|
|
900
|
+
In-plane options: 'radial', 'golden_radial', 'spiral', 'cones', 'ECCENTRIC'.
|
|
901
|
+
Shots are grouped: [kz0_shot0, kz0_shot1, ..., kz1_shot0, ...].
|
|
902
|
+
|
|
903
|
+
Parameters (params)
|
|
904
|
+
-------------------
|
|
905
|
+
kz_ordering : str
|
|
906
|
+
'linear' or 'centric' (default: 'linear').
|
|
907
|
+
kz_range : tuple (kz_min_idx, kz_max_idx) optional
|
|
908
|
+
Slice index range to include (default: all nz slices).
|
|
909
|
+
plus all params for the in-plane trajectory generator.
|
|
910
|
+
|
|
911
|
+
Output shot count: n_kz_slices * n_inplane_shots.
|
|
912
|
+
"""
|
|
913
|
+
if geom["ndim"] < 3:
|
|
914
|
+
raise ValueError(
|
|
915
|
+
f"stack_of_{inplane_name} requires a 3D geometry (nz > 1). "
|
|
916
|
+
f"Header has nz=1."
|
|
917
|
+
)
|
|
918
|
+
_, _, nz = geom["matrix"]
|
|
919
|
+
fov_z_m = geom["fov_mm"][2] / 1000.0
|
|
920
|
+
kmax_z = (nz / 2.0) / fov_z_m
|
|
921
|
+
|
|
922
|
+
kz_ordering = params.get("kz_ordering", "linear")
|
|
923
|
+
kz_range = params.get("kz_range", (0, nz))
|
|
924
|
+
kz_min, kz_max = int(kz_range[0]), int(kz_range[1])
|
|
925
|
+
n_kz = kz_max - kz_min
|
|
926
|
+
|
|
927
|
+
kz_indices = np.arange(kz_min, kz_max, dtype=np.float32)
|
|
928
|
+
kz_positions = (kz_indices - nz / 2.0) / fov_z_m
|
|
929
|
+
|
|
930
|
+
if kz_ordering == "centric":
|
|
931
|
+
half = n_kz // 2
|
|
932
|
+
order = []
|
|
933
|
+
for i in range(max(half, n_kz - half)):
|
|
934
|
+
if i < half:
|
|
935
|
+
order.extend([half - 1 - i, half + i] if half + i < n_kz else [half - 1 - i])
|
|
936
|
+
elif half + i < n_kz:
|
|
937
|
+
order.append(half + i)
|
|
938
|
+
kz_positions = kz_positions[order[:n_kz]]
|
|
939
|
+
|
|
940
|
+
# Generate in-plane geometry (treat as 2D)
|
|
941
|
+
geom2d = {**geom, "ndim": 2, "matrix": (geom["matrix"][0], geom["matrix"][1], 1),
|
|
942
|
+
"fov_mm": (geom["fov_mm"][0], geom["fov_mm"][1], 1.0)}
|
|
943
|
+
|
|
944
|
+
ip_map = {
|
|
945
|
+
"radial": lambda: _traj_radial_2d(geom2d, params, backend, use_golden=False),
|
|
946
|
+
"golden_radial": lambda: _traj_radial_2d(geom2d, params, backend, use_golden=True),
|
|
947
|
+
"spiral": lambda: _traj_spiral_2d(geom2d, params, backend),
|
|
948
|
+
"cones": lambda: _traj_spiral_2d(geom2d, params, backend),
|
|
949
|
+
"ECCENTRIC": lambda: _traj_eccentric(geom2d, params, backend),
|
|
950
|
+
"rosette": lambda: _traj_rosette_2d(geom2d, params, backend),
|
|
951
|
+
}
|
|
952
|
+
if inplane_name not in ip_map:
|
|
953
|
+
raise ValueError(f"Unknown in-plane trajectory '{inplane_name}'. "
|
|
954
|
+
f"Valid: {list(ip_map.keys())}")
|
|
955
|
+
inplane_shots, inplane_meta = ip_map[inplane_name]()
|
|
956
|
+
|
|
957
|
+
all_shots = []
|
|
958
|
+
for kz_val in kz_positions:
|
|
959
|
+
kz_col = float(kz_val)
|
|
960
|
+
for shot2d in inplane_shots:
|
|
961
|
+
nrows = backend.shape(shot2d)[0]
|
|
962
|
+
kz_col_arr = backend.full((nrows,), kz_col)
|
|
963
|
+
shot3d = backend.concat([shot2d, backend.stack([kz_col_arr], axis=1)], axis=1)
|
|
964
|
+
all_shots.append(shot3d)
|
|
965
|
+
|
|
966
|
+
n_shots_total = len(all_shots)
|
|
967
|
+
sps = backend.shape(all_shots[0])[0] if all_shots else 0
|
|
968
|
+
traj_name = f"stack_of_{inplane_name}"
|
|
969
|
+
kmax_x, kmax_y, _ = _kmax_from_geometry(geom)
|
|
970
|
+
meta = {
|
|
971
|
+
"trajectory_type": traj_name,
|
|
972
|
+
"inplane_trajectory": inplane_name,
|
|
973
|
+
"inplane_meta": inplane_meta,
|
|
974
|
+
"kz_ordering": kz_ordering,
|
|
975
|
+
"n_kz_slices": n_kz,
|
|
976
|
+
"n_inplane_shots": len(inplane_shots),
|
|
977
|
+
"kmax": (kmax_x, kmax_y, kmax_z),
|
|
978
|
+
"fov_mm": geom["fov_mm"],
|
|
979
|
+
"matrix": geom["matrix"],
|
|
980
|
+
"n_shots": n_shots_total,
|
|
981
|
+
"samples_per_shot": sps,
|
|
982
|
+
"density_estimate": _density_estimate_placeholder(traj_name, n_shots_total, sps, geom),
|
|
983
|
+
"params_used": params,
|
|
984
|
+
}
|
|
985
|
+
return all_shots, meta
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _traj_eccentric(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
989
|
+
"""
|
|
990
|
+
ECCENTRIC MRSI trajectory (Echo-planar Concentric Rings Trajectory).
|
|
991
|
+
|
|
992
|
+
Concentric rings in k-space sampled with alternating directions,
|
|
993
|
+
commonly used in MRSI. Each shot is one ring.
|
|
994
|
+
|
|
995
|
+
Parameters (params)
|
|
996
|
+
-------------------
|
|
997
|
+
n_rings : int
|
|
998
|
+
Number of concentric rings (default: ny//2).
|
|
999
|
+
samples_per_ring : int
|
|
1000
|
+
Samples per ring (default: 2*π*ring_radius → proportional to circumference).
|
|
1001
|
+
min_ring_radius : float
|
|
1002
|
+
Minimum ring radius in normalised units (default: 1e-3).
|
|
1003
|
+
"""
|
|
1004
|
+
nx, ny, _ = geom["matrix"]
|
|
1005
|
+
fov_x_m = geom["fov_mm"][0] / 1000.0
|
|
1006
|
+
fov_y_m = geom["fov_mm"][1] / 1000.0
|
|
1007
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
1008
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
1009
|
+
|
|
1010
|
+
n_rings = int(params.get("n_rings", ny // 2))
|
|
1011
|
+
min_r = float(params.get("min_ring_radius", 1e-3))
|
|
1012
|
+
radii = np.linspace(min_r, 1.0, n_rings, True)
|
|
1013
|
+
|
|
1014
|
+
shots = []
|
|
1015
|
+
for idx, r_norm in enumerate(radii):
|
|
1016
|
+
# Samples proportional to circumference
|
|
1017
|
+
n_samp = int(params.get("samples_per_ring",
|
|
1018
|
+
max(8, int(2 * math.pi * r_norm * max(nx, ny) / 2))))
|
|
1019
|
+
theta = np.linspace(0, 2 * math.pi, n_samp, endpoint=True)
|
|
1020
|
+
if idx % 2 == 1: # alternating CW/CCW for echo-planar style
|
|
1021
|
+
theta = theta[::-1]
|
|
1022
|
+
theta_b = backend.asarray(theta)
|
|
1023
|
+
kx = backend.cos(theta_b) * r_norm * kmax_x
|
|
1024
|
+
ky = backend.sin(theta_b) * r_norm * kmax_y
|
|
1025
|
+
shot = backend.stack([kx, ky], axis=1)
|
|
1026
|
+
shots.append(shot)
|
|
1027
|
+
|
|
1028
|
+
meta = {
|
|
1029
|
+
"trajectory_type": "ECCENTRIC",
|
|
1030
|
+
"n_rings": n_rings,
|
|
1031
|
+
"kmax": (kmax_x, kmax_y),
|
|
1032
|
+
"fov_mm": geom["fov_mm"][:2],
|
|
1033
|
+
"matrix": (nx, ny),
|
|
1034
|
+
"n_shots": n_rings,
|
|
1035
|
+
"samples_per_shot": "variable (∝ ring circumference)",
|
|
1036
|
+
"density_estimate": _density_estimate_placeholder("ECCENTRIC", n_rings,
|
|
1037
|
+
ny // 2, geom),
|
|
1038
|
+
"params_used": params,
|
|
1039
|
+
}
|
|
1040
|
+
return shots, meta
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _traj_concentric_rings_2d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
1044
|
+
"""
|
|
1045
|
+
2D Concentric Rings trajectory.
|
|
1046
|
+
|
|
1047
|
+
A set of concentric circles in the kx–ky plane. Each ring is one shot.
|
|
1048
|
+
Rings are uniformly spaced from a minimum radius to kmax. Alternating
|
|
1049
|
+
rings are traversed in opposite directions (CW / CCW) to mimic an
|
|
1050
|
+
echo-planar acquisition pattern.
|
|
1051
|
+
|
|
1052
|
+
Equivalent to the ECCENTRIC / concentric-ring MRSI acquisition but
|
|
1053
|
+
exposed directly for 2D use without a kz stack.
|
|
1054
|
+
|
|
1055
|
+
Parameters (params)
|
|
1056
|
+
-------------------
|
|
1057
|
+
n_rings : int
|
|
1058
|
+
Number of concentric rings (default: ny // 2).
|
|
1059
|
+
samples_per_ring : int
|
|
1060
|
+
Samples per ring (default: proportional to circumference).
|
|
1061
|
+
Overrides the auto-computed circumference-proportional value.
|
|
1062
|
+
min_ring_radius : float
|
|
1063
|
+
Minimum ring radius as fraction of kmax (default: 1 / n_rings).
|
|
1064
|
+
|
|
1065
|
+
Output shot count: n_rings.
|
|
1066
|
+
Sample count per shot: variable unless samples_per_ring is set.
|
|
1067
|
+
"""
|
|
1068
|
+
shots, meta_eccentric = _traj_eccentric(geom, params, backend)
|
|
1069
|
+
# Re-label trajectory type for clarity
|
|
1070
|
+
meta_eccentric["trajectory_type"] = "concentric_rings_2d"
|
|
1071
|
+
meta_eccentric["n_shots"] = len(shots)
|
|
1072
|
+
meta_eccentric["density_estimate"]["trajectory"] = "concentric_rings_2d"
|
|
1073
|
+
return shots, meta_eccentric
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _traj_3d_phyllotaxis(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
1077
|
+
"""
|
|
1078
|
+
Fermat phyllotaxis 3D radial trajectory.
|
|
1079
|
+
|
|
1080
|
+
Angular positions on unit sphere use sunflower / Fibonacci lattice,
|
|
1081
|
+
so any prefix of N shots provides near-uniform angular coverage.
|
|
1082
|
+
Center-out readout: each spoke starts at k=0 and extends to kmax.
|
|
1083
|
+
|
|
1084
|
+
Parameters (params)
|
|
1085
|
+
-------------------
|
|
1086
|
+
n_shots : int
|
|
1087
|
+
Number of spokes (default: π/4 * max(nx,ny,nz)^2).
|
|
1088
|
+
samples_per_shot : int
|
|
1089
|
+
Samples per spoke (default: max(nx,ny,nz)).
|
|
1090
|
+
golden_angle_2d : bool
|
|
1091
|
+
Use 2D golden angle for azimuth increment (default: True).
|
|
1092
|
+
|
|
1093
|
+
Output shot count: n_shots.
|
|
1094
|
+
Sample count per shot: samples_per_shot (constant).
|
|
1095
|
+
"""
|
|
1096
|
+
if geom["ndim"] < 3:
|
|
1097
|
+
raise ValueError(
|
|
1098
|
+
"3d_phyllotaxis requires a 3D geometry (nz > 1). "
|
|
1099
|
+
"Header has nz=1. Use radial_2d or golden_radial_2d for 2D."
|
|
1100
|
+
)
|
|
1101
|
+
nx, ny, nz = geom["matrix"]
|
|
1102
|
+
fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
|
|
1103
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
1104
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
1105
|
+
kmax_z = (nz / 2.0) / fov_z_m
|
|
1106
|
+
|
|
1107
|
+
max_n = max(nx, ny, nz)
|
|
1108
|
+
n_shots = int(params.get("n_shots", max(1, int(math.pi / 4 * max_n ** 2))))
|
|
1109
|
+
sps = int(params.get("samples_per_shot", max_n))
|
|
1110
|
+
|
|
1111
|
+
# Fermat phyllotaxis: spoke i at
|
|
1112
|
+
# cos(elevation) = 1 - (2*i+1)/n_shots
|
|
1113
|
+
# azimuth = i * golden_angle (3D variant: 2.399963...)
|
|
1114
|
+
shots = []
|
|
1115
|
+
r = backend.linspace(0.0, 1.0, sps) # center-out
|
|
1116
|
+
for i in range(n_shots):
|
|
1117
|
+
cos_el = 1.0 - (2 * i + 1.0) / n_shots
|
|
1118
|
+
cos_el = float(np.clip(cos_el, -1.0, 1.0))
|
|
1119
|
+
sin_el = math.sqrt(max(0.0, 1.0 - cos_el ** 2))
|
|
1120
|
+
azimuth = i * GOLDEN_ANGLE_RAD # 2D golden angle for azimuth
|
|
1121
|
+
|
|
1122
|
+
dx = sin_el * math.cos(azimuth)
|
|
1123
|
+
dy = sin_el * math.sin(azimuth)
|
|
1124
|
+
dz = cos_el
|
|
1125
|
+
|
|
1126
|
+
kx = r * dx * kmax_x
|
|
1127
|
+
ky = r * dy * kmax_y
|
|
1128
|
+
kz = r * dz * kmax_z
|
|
1129
|
+
shot = backend.stack([kx, ky, kz], axis=1)
|
|
1130
|
+
shots.append(shot)
|
|
1131
|
+
|
|
1132
|
+
meta = {
|
|
1133
|
+
"trajectory_type": "3d_phyllotaxis",
|
|
1134
|
+
"golden_angle_rad": GOLDEN_ANGLE_RAD,
|
|
1135
|
+
"kmax": (kmax_x, kmax_y, kmax_z),
|
|
1136
|
+
"fov_mm": geom["fov_mm"],
|
|
1137
|
+
"matrix": (nx, ny, nz),
|
|
1138
|
+
"n_shots": n_shots,
|
|
1139
|
+
"samples_per_shot": sps,
|
|
1140
|
+
"center_out": True,
|
|
1141
|
+
"density_estimate": _density_estimate_placeholder("3d_phyllotaxis", n_shots, sps, geom),
|
|
1142
|
+
"params_used": params,
|
|
1143
|
+
}
|
|
1144
|
+
return shots, meta
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _traj_cones_3d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
1148
|
+
"""
|
|
1149
|
+
3D cones trajectory: spiral arms arranged on cones at discrete polar angles.
|
|
1150
|
+
|
|
1151
|
+
Each shot is a spiral arm on a cone surface. Cones are indexed by their
|
|
1152
|
+
polar angle θ measured from the +kz axis.
|
|
1153
|
+
|
|
1154
|
+
By default the polar range is [0, π], covering both the upper (+kz) and
|
|
1155
|
+
lower (−kz) hemispheres symmetrically. Set ``polar_max=π/2`` to restrict
|
|
1156
|
+
to the upper hemisphere only (legacy behaviour).
|
|
1157
|
+
|
|
1158
|
+
Parameters (params)
|
|
1159
|
+
-------------------
|
|
1160
|
+
n_cones : int
|
|
1161
|
+
Number of cone polar angles (default: 7).
|
|
1162
|
+
arms_per_cone : int
|
|
1163
|
+
Azimuthal arms per cone (default: 8).
|
|
1164
|
+
samples_per_shot : int
|
|
1165
|
+
Samples per arm (default: max(nx, ny, nz)).
|
|
1166
|
+
spiral_turns_per_arm : float
|
|
1167
|
+
Spiral turns per arm (default: 1.0).
|
|
1168
|
+
polar_min : float
|
|
1169
|
+
Minimum polar angle in radians (default: 0 = +kz axis).
|
|
1170
|
+
polar_max : float
|
|
1171
|
+
Maximum polar angle in radians (default: π = −kz axis).
|
|
1172
|
+
Set to π/2 to generate upper-hemisphere only.
|
|
1173
|
+
|
|
1174
|
+
Output shot count: n_cones * arms_per_cone.
|
|
1175
|
+
|
|
1176
|
+
Notes
|
|
1177
|
+
-----
|
|
1178
|
+
Polar angle θ is the standard physics convention (angle from +kz):
|
|
1179
|
+
θ = 0 → spoke along +kz
|
|
1180
|
+
θ = π/2 → spoke in the kxy plane (kz = 0)
|
|
1181
|
+
θ = π → spoke along −kz
|
|
1182
|
+
kz = r · cos(θ) · kmax_z, so negative-kz cones arise naturally for θ > π/2.
|
|
1183
|
+
"""
|
|
1184
|
+
if geom["ndim"] < 3:
|
|
1185
|
+
raise ValueError(
|
|
1186
|
+
"cones_3d requires a 3D geometry (nz > 1). Header has nz=1."
|
|
1187
|
+
)
|
|
1188
|
+
nx, ny, nz = geom["matrix"]
|
|
1189
|
+
fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
|
|
1190
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
1191
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
1192
|
+
kmax_z = (nz / 2.0) / fov_z_m
|
|
1193
|
+
|
|
1194
|
+
n_cones = int(params.get("n_cones", 14))
|
|
1195
|
+
arms_per_cone = int(params.get("arms_per_cone", 8))
|
|
1196
|
+
sps = int(params.get("samples_per_shot", max(nx, ny, nz)))
|
|
1197
|
+
turns = float(params.get("spiral_turns_per_arm", 1.0))
|
|
1198
|
+
polar_min = float(params.get("polar_min", 0.15*math.pi))#0.0))
|
|
1199
|
+
polar_max = float(params.get("polar_max", 0.85*math.pi))
|
|
1200
|
+
|
|
1201
|
+
cone_angles = np.linspace(polar_min, polar_max, n_cones, True)
|
|
1202
|
+
t = backend.linspace(0.0, 1.0, sps)
|
|
1203
|
+
|
|
1204
|
+
shots = []
|
|
1205
|
+
for ci, cone_ang in enumerate(cone_angles):
|
|
1206
|
+
sin_ca = math.sin(float(cone_ang))
|
|
1207
|
+
cos_ca = math.cos(float(cone_ang)) # negative for θ > π/2 → −kz cones
|
|
1208
|
+
for ai in range(arms_per_cone):
|
|
1209
|
+
az0 = ai * 2.0 * math.pi / arms_per_cone + ci * GOLDEN_ANGLE_RAD
|
|
1210
|
+
theta_spiral = t * (2.0 * math.pi * turns) + az0
|
|
1211
|
+
r = t # centre-out
|
|
1212
|
+
|
|
1213
|
+
kx = r * backend.cos(theta_spiral) * sin_ca * kmax_x
|
|
1214
|
+
ky = r * backend.sin(theta_spiral) * sin_ca * kmax_y
|
|
1215
|
+
kz = r * cos_ca * kmax_z # naturally negative when θ > π/2
|
|
1216
|
+
shot = backend.stack([kx, ky, kz], axis=1)
|
|
1217
|
+
shots.append(shot)
|
|
1218
|
+
|
|
1219
|
+
n_shots = len(shots)
|
|
1220
|
+
meta = {
|
|
1221
|
+
"trajectory_type": "cones_3d",
|
|
1222
|
+
"n_cones": n_cones,
|
|
1223
|
+
"arms_per_cone": arms_per_cone,
|
|
1224
|
+
"spiral_turns": turns,
|
|
1225
|
+
"polar_min_deg": math.degrees(polar_min),
|
|
1226
|
+
"polar_max_deg": math.degrees(polar_max),
|
|
1227
|
+
"kmax": (kmax_x, kmax_y, kmax_z),
|
|
1228
|
+
"fov_mm": geom["fov_mm"],
|
|
1229
|
+
"matrix": (nx, ny, nz),
|
|
1230
|
+
"n_shots": n_shots,
|
|
1231
|
+
"samples_per_shot": sps,
|
|
1232
|
+
"density_estimate": _density_estimate_placeholder("cones_3d", n_shots, sps, geom),
|
|
1233
|
+
"params_used": params,
|
|
1234
|
+
}
|
|
1235
|
+
return shots, meta
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def _traj_cones_3d_rosette(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
1239
|
+
"""
|
|
1240
|
+
3D Cones Rosette trajectory.
|
|
1241
|
+
|
|
1242
|
+
Replaces the spiral readout arms of ``cones_3d`` with rosette petals.
|
|
1243
|
+
Like a 2D petal, each 3D petal is a *closed* trajectory that starts and
|
|
1244
|
+
ends at the k-space origin (center-out-center), tracing a lemniscate-like
|
|
1245
|
+
loop on the surface of the cone:
|
|
1246
|
+
|
|
1247
|
+
t ∈ [0, π] (both endpoints at k = 0)
|
|
1248
|
+
φⱼ = j · 2π / petals_per_cone + cone_idx · golden_angle_rad
|
|
1249
|
+
|
|
1250
|
+
kx(t) = sin(t) · sin(θ_cone) · cos(t + φⱼ) · kmax_x
|
|
1251
|
+
ky(t) = sin(t) · sin(θ_cone) · sin(t + φⱼ) · kmax_y
|
|
1252
|
+
kz(t) = sin(t) · cos(θ_cone) · kmax_z
|
|
1253
|
+
|
|
1254
|
+
Because kx, ky, kz all share the sin(t) envelope, the trajectory lies
|
|
1255
|
+
on the cone surface for all t and returns to the origin at t=0 and t=π.
|
|
1256
|
+
The azimuthal sweep (t + φⱼ) creates the petal lobe in the transverse
|
|
1257
|
+
plane. The golden-angle inter-cone offset spreads petals uniformly in
|
|
1258
|
+
azimuth across cone levels.
|
|
1259
|
+
|
|
1260
|
+
By default the polar range is [0, π], covering both the upper (+kz) and
|
|
1261
|
+
lower (−kz) hemispheres. cos(θ) is negative for θ > π/2, which makes kz
|
|
1262
|
+
naturally negative — no special mirroring step is required.
|
|
1263
|
+
|
|
1264
|
+
Parameters (params)
|
|
1265
|
+
-------------------
|
|
1266
|
+
n_cones : int
|
|
1267
|
+
Number of cone polar angles, spaced across [polar_min, polar_max]
|
|
1268
|
+
(default: 7). Includes cones in both hemispheres when polar_max > π/2.
|
|
1269
|
+
petals_per_cone : int
|
|
1270
|
+
Number of rosette petals per cone (default: 6).
|
|
1271
|
+
samples_per_shot : int
|
|
1272
|
+
Samples per petal (default: 512). Includes origin at both ends.
|
|
1273
|
+
polar_min : float
|
|
1274
|
+
Minimum polar angle in radians (default: 0 = +kz axis).
|
|
1275
|
+
polar_max : float
|
|
1276
|
+
Maximum polar angle in radians (default: π = −kz axis).
|
|
1277
|
+
Set to π/2 to generate upper-hemisphere only.
|
|
1278
|
+
|
|
1279
|
+
Output shot count: n_cones * petals_per_cone.
|
|
1280
|
+
Sample count per shot: samples_per_shot (constant).
|
|
1281
|
+
|
|
1282
|
+
Notes
|
|
1283
|
+
-----
|
|
1284
|
+
Same polar-angle convention as cones_3d:
|
|
1285
|
+
θ = 0 → cone along +kz (kz > 0)
|
|
1286
|
+
θ = π/2 → equatorial cone (kz = 0)
|
|
1287
|
+
θ = π → cone along −kz (kz < 0)
|
|
1288
|
+
"""
|
|
1289
|
+
if geom["ndim"] < 3:
|
|
1290
|
+
raise ValueError(
|
|
1291
|
+
"cones_3d_rosette requires a 3D geometry (nz > 1). "
|
|
1292
|
+
"Header has nz=1."
|
|
1293
|
+
)
|
|
1294
|
+
nx, ny, nz = geom["matrix"]
|
|
1295
|
+
fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
|
|
1296
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
1297
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
1298
|
+
kmax_z = (nz / 2.0) / fov_z_m
|
|
1299
|
+
|
|
1300
|
+
n_cones = int(params.get("n_cones", 14))
|
|
1301
|
+
ppc = int(params.get("petals_per_cone", 9))
|
|
1302
|
+
sps = int(params.get("samples_per_shot", 512))
|
|
1303
|
+
polar_min = float(params.get("polar_min", 0.15*math.pi))#0.0))
|
|
1304
|
+
polar_max = float(params.get("polar_max", 0.85*math.pi))
|
|
1305
|
+
|
|
1306
|
+
cone_angles = np.linspace(polar_min, polar_max, n_cones, True)
|
|
1307
|
+
t = backend.linspace(0.0, math.pi, sps) # petal: 0 → π
|
|
1308
|
+
sin_t = backend.sin(t) # envelope — 0 at both ends
|
|
1309
|
+
|
|
1310
|
+
shots = []
|
|
1311
|
+
for ci, theta_cone in enumerate(cone_angles):
|
|
1312
|
+
sin_ca = math.sin(float(theta_cone))
|
|
1313
|
+
cos_ca = math.cos(float(theta_cone)) # negative for θ > π/2 → −kz petals
|
|
1314
|
+
for pi_ in range(ppc):
|
|
1315
|
+
phi = pi_ * 2.0 * math.pi / ppc + ci * GOLDEN_ANGLE_RAD
|
|
1316
|
+
kx = sin_t * sin_ca * backend.cos(t + phi) * kmax_x
|
|
1317
|
+
ky = sin_t * sin_ca * backend.sin(t + phi) * kmax_y
|
|
1318
|
+
kz = sin_t * cos_ca * kmax_z # sign follows cos(θ) naturally
|
|
1319
|
+
shot = backend.stack([kx, ky, kz], axis=1)
|
|
1320
|
+
shots.append(shot)
|
|
1321
|
+
|
|
1322
|
+
n_shots = len(shots)
|
|
1323
|
+
kmax_xv, kmax_yv, kmax_zv = _kmax_from_geometry(geom)
|
|
1324
|
+
meta = {
|
|
1325
|
+
"trajectory_type": "cones_3d_rosette",
|
|
1326
|
+
"n_cones": n_cones,
|
|
1327
|
+
"petals_per_cone": ppc,
|
|
1328
|
+
"polar_min_deg": math.degrees(polar_min),
|
|
1329
|
+
"polar_max_deg": math.degrees(polar_max),
|
|
1330
|
+
"kmax": (kmax_xv, kmax_yv, kmax_zv),
|
|
1331
|
+
"fov_mm": geom["fov_mm"],
|
|
1332
|
+
"matrix": (nx, ny, nz),
|
|
1333
|
+
"n_shots": n_shots,
|
|
1334
|
+
"samples_per_shot": sps,
|
|
1335
|
+
"center_out_center": True,
|
|
1336
|
+
"density_estimate": _density_estimate_placeholder(
|
|
1337
|
+
"cones_3d_rosette", n_shots, sps, geom),
|
|
1338
|
+
"params_used": params,
|
|
1339
|
+
}
|
|
1340
|
+
return shots, meta
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _traj_floret_3d(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
1344
|
+
"""
|
|
1345
|
+
FLORET (Fermat-Loop-Over-Radial Echo Trajectory) 3D trajectory.
|
|
1346
|
+
|
|
1347
|
+
Distributed spiral shells (hubs) arranged uniformly on the sphere via
|
|
1348
|
+
phyllotaxis ordering. Each hub is a set of interleaved spiral arms.
|
|
1349
|
+
|
|
1350
|
+
Parameters (params)
|
|
1351
|
+
-------------------
|
|
1352
|
+
n_hubs : int
|
|
1353
|
+
Number of shell hubs (default: 5).
|
|
1354
|
+
arms_per_hub : int
|
|
1355
|
+
Spiral arms per hub (default: 15).
|
|
1356
|
+
samples_per_shot : int
|
|
1357
|
+
Samples per arm (default: max(nx, ny, nz)).
|
|
1358
|
+
spiral_turns : float
|
|
1359
|
+
Turns per arm (default: arms_per_hub / 2).
|
|
1360
|
+
hub_cone_angle_deg : float
|
|
1361
|
+
Half-angle of each hub cone in degrees (default: 35).
|
|
1362
|
+
|
|
1363
|
+
Output shot count: n_hubs * arms_per_hub.
|
|
1364
|
+
"""
|
|
1365
|
+
if geom["ndim"] < 3:
|
|
1366
|
+
raise ValueError("floret_3d requires a 3D geometry (nz > 1).")
|
|
1367
|
+
nx, ny, nz = geom["matrix"]
|
|
1368
|
+
fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
|
|
1369
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
1370
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
1371
|
+
kmax_z = (nz / 2.0) / fov_z_m
|
|
1372
|
+
|
|
1373
|
+
n_hubs = int(params.get("n_hubs", 5))
|
|
1374
|
+
aph = int(params.get("arms_per_hub", 15))
|
|
1375
|
+
sps = int(params.get("samples_per_shot", max(nx, ny, nz)))
|
|
1376
|
+
turns = float(params.get("spiral_turns", aph / 2.0))
|
|
1377
|
+
hub_ang_deg = float(params.get("hub_cone_angle_deg", 35.0))
|
|
1378
|
+
hub_ang_rad = math.radians(hub_ang_deg)
|
|
1379
|
+
|
|
1380
|
+
t = backend.linspace(0.0, 1.0, sps)
|
|
1381
|
+
|
|
1382
|
+
# Hub axis directions via phyllotaxis
|
|
1383
|
+
shots = []
|
|
1384
|
+
for h in range(n_hubs):
|
|
1385
|
+
cos_el_hub = 1.0 - (2 * h + 1.0) / n_hubs
|
|
1386
|
+
cos_el_hub = float(np.clip(cos_el_hub, -1.0, 1.0))
|
|
1387
|
+
sin_el_hub = math.sqrt(max(0.0, 1.0 - cos_el_hub ** 2))
|
|
1388
|
+
az_hub = h * GOLDEN_ANGLE_RAD
|
|
1389
|
+
|
|
1390
|
+
# Hub rotation matrix columns (local x, y axes on hub cone)
|
|
1391
|
+
hub_axis = np.array([sin_el_hub * math.cos(az_hub),
|
|
1392
|
+
sin_el_hub * math.sin(az_hub),
|
|
1393
|
+
cos_el_hub])
|
|
1394
|
+
# local x = cross(z_global, hub_axis) or fallback
|
|
1395
|
+
z_global = np.array([0.0, 0.0, 1.0])
|
|
1396
|
+
local_x = np.cross(z_global, hub_axis)
|
|
1397
|
+
norm = np.linalg.norm(local_x)
|
|
1398
|
+
if norm < 1e-8:
|
|
1399
|
+
local_x = np.array([1.0, 0.0, 0.0])
|
|
1400
|
+
else:
|
|
1401
|
+
local_x = local_x / norm
|
|
1402
|
+
local_y = np.cross(hub_axis, local_x)
|
|
1403
|
+
|
|
1404
|
+
for a in range(aph):
|
|
1405
|
+
az0 = a * 2.0 * math.pi / aph
|
|
1406
|
+
theta = t * (2.0 * math.pi * turns) + az0
|
|
1407
|
+
r = t
|
|
1408
|
+
# Arm direction sweeps within the hub cone
|
|
1409
|
+
arm_vec_x = r * backend.cos(theta) * math.sin(hub_ang_rad)
|
|
1410
|
+
arm_vec_y = r * backend.sin(theta) * math.sin(hub_ang_rad)
|
|
1411
|
+
arm_vec_z = r * math.cos(hub_ang_rad)
|
|
1412
|
+
|
|
1413
|
+
# Rotate from hub local frame to global
|
|
1414
|
+
kx = (arm_vec_x * local_x[0] + arm_vec_y * local_y[0]
|
|
1415
|
+
+ arm_vec_z * hub_axis[0]) * kmax_x
|
|
1416
|
+
ky = (arm_vec_x * local_x[1] + arm_vec_y * local_y[1]
|
|
1417
|
+
+ arm_vec_z * hub_axis[1]) * kmax_y
|
|
1418
|
+
kz = (arm_vec_x * local_x[2] + arm_vec_y * local_y[2]
|
|
1419
|
+
+ arm_vec_z * hub_axis[2]) * kmax_z
|
|
1420
|
+
shot = backend.stack([kx, ky, kz], axis=1)
|
|
1421
|
+
shots.append(shot)
|
|
1422
|
+
|
|
1423
|
+
n_shots = len(shots)
|
|
1424
|
+
meta = {
|
|
1425
|
+
"trajectory_type": "floret_3d",
|
|
1426
|
+
"n_hubs": n_hubs,
|
|
1427
|
+
"arms_per_hub": aph,
|
|
1428
|
+
"hub_cone_angle_deg": hub_ang_deg,
|
|
1429
|
+
"spiral_turns": turns,
|
|
1430
|
+
"kmax": (kmax_x, kmax_y, kmax_z),
|
|
1431
|
+
"fov_mm": geom["fov_mm"],
|
|
1432
|
+
"matrix": (nx, ny, nz),
|
|
1433
|
+
"n_shots": n_shots,
|
|
1434
|
+
"samples_per_shot": sps,
|
|
1435
|
+
"density_estimate": _density_estimate_placeholder("floret_3d", n_shots, sps, geom),
|
|
1436
|
+
"params_used": params,
|
|
1437
|
+
}
|
|
1438
|
+
return shots, meta
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _traj_3d_egg_rosette(geom: dict, params: dict, backend: Backend) -> Tuple[List[Array], MetaDict]:
|
|
1442
|
+
"""
|
|
1443
|
+
3D Egg-Shaped Rosette trajectory (Strasser, Medical University of Vienna).
|
|
1444
|
+
|
|
1445
|
+
Reference: Strasser B et al., "Ultra-high resolution whole-brain MR spectroscopic
|
|
1446
|
+
imaging using a 3D egg-shaped rosette k-space trajectory", MRM 2017 / ISMRM abstracts.
|
|
1447
|
+
|
|
1448
|
+
Each shot traces a closed egg-shaped loop on the surface of the k-space sphere:
|
|
1449
|
+
|
|
1450
|
+
kx(t) = kmax_x · sin(ω₁·t) · cos(ω₂·t + φₙ)
|
|
1451
|
+
ky(t) = kmax_y · sin(ω₁·t) · sin(ω₂·t + φₙ)
|
|
1452
|
+
kz(t) = kmax_z · cos(ω₁·t)
|
|
1453
|
+
|
|
1454
|
+
where t ∈ [0, 2π] traces one complete egg-shaped loop:
|
|
1455
|
+
- ω₁ = 1 (polar oscillation; kz sweeps north → south → north once per shot)
|
|
1456
|
+
- ω₂ = azimuthal wrap frequency (number of azimuthal lobes per shot)
|
|
1457
|
+
- φₙ = n · golden_angle_rad — per-shot azimuthal phase offset ensuring that
|
|
1458
|
+
any prefix of N shots gives near-uniform sphere coverage.
|
|
1459
|
+
|
|
1460
|
+
The "egg" geometry arises from the interplay of sin(ω₁t) (which vanishes at both
|
|
1461
|
+
poles) and cos(ω₁t) (which drives kz); the result is a lemniscate-like curve on
|
|
1462
|
+
the sphere whose half-width at the equator equals kmax and whose lobes are
|
|
1463
|
+
distributed uniformly in azimuth via the golden-angle increment between shots.
|
|
1464
|
+
|
|
1465
|
+
Parameters (params)
|
|
1466
|
+
-------------------
|
|
1467
|
+
n_shots : int
|
|
1468
|
+
Number of egg-rosette shots (default: 20).
|
|
1469
|
+
Any prefix gives uniform angular coverage thanks to golden-angle ordering.
|
|
1470
|
+
samples_per_shot : int
|
|
1471
|
+
Number of samples per shot (default: 4 * max(nx, ny, nz)).
|
|
1472
|
+
omega1 : float
|
|
1473
|
+
Polar frequency (default 1 → one polar oscillation per shot).
|
|
1474
|
+
Increase to add multiple north–south loops per trajectory.
|
|
1475
|
+
omega2 : int
|
|
1476
|
+
Azimuthal frequency — number of lobes per egg (default 5).
|
|
1477
|
+
Best chosen as an odd integer coprime with omega1 for closed trajectories.
|
|
1478
|
+
rotation_per_shot : float
|
|
1479
|
+
Azimuthal phase increment between shots in radians
|
|
1480
|
+
(default: GOLDEN_ANGLE_RAD ≈ 2.3999 rad).
|
|
1481
|
+
|
|
1482
|
+
Output shot count: n_shots.
|
|
1483
|
+
Sample count per shot: samples_per_shot (constant).
|
|
1484
|
+
"""
|
|
1485
|
+
if geom["ndim"] < 3:
|
|
1486
|
+
raise ValueError(
|
|
1487
|
+
"3d_egg_rosette requires a 3D geometry (nz > 1). "
|
|
1488
|
+
"Header has nz=1."
|
|
1489
|
+
)
|
|
1490
|
+
nx, ny, nz = geom["matrix"]
|
|
1491
|
+
fov_x_m, fov_y_m, fov_z_m = (v / 1000.0 for v in geom["fov_mm"])
|
|
1492
|
+
kmax_x = (nx / 2.0) / fov_x_m
|
|
1493
|
+
kmax_y = (ny / 2.0) / fov_y_m
|
|
1494
|
+
kmax_z = (nz / 2.0) / fov_z_m
|
|
1495
|
+
|
|
1496
|
+
n_shots = int(params.get("n_shots", 20))
|
|
1497
|
+
sps = int(params.get("samples_per_shot", 4 * max(nx, ny, nz)))
|
|
1498
|
+
omega1 = float(params.get("omega1", 1.0))
|
|
1499
|
+
omega2 = float(params.get("omega2", 5.0))
|
|
1500
|
+
rot_per_shot = float(params.get("rotation_per_shot", GOLDEN_ANGLE_RAD))
|
|
1501
|
+
|
|
1502
|
+
# t ∈ [0, 2π] → one complete polar oscillation per shot
|
|
1503
|
+
t = backend.linspace(0.0, 2.0 * math.pi, sps)
|
|
1504
|
+
|
|
1505
|
+
shots = []
|
|
1506
|
+
for n in range(n_shots):
|
|
1507
|
+
phi_n = n * rot_per_shot # golden-angle azimuth offset
|
|
1508
|
+
kx = backend.sin(t * omega1) * backend.cos(t * omega2 + phi_n) * kmax_x
|
|
1509
|
+
ky = backend.sin(t * omega1) * backend.sin(t * omega2 + phi_n) * kmax_y
|
|
1510
|
+
kz = backend.cos(t * omega1) * kmax_z # kz independent of phi_n
|
|
1511
|
+
shot = backend.stack([kx, ky, kz], axis=1) # (sps, 3)
|
|
1512
|
+
shots.append(shot)
|
|
1513
|
+
|
|
1514
|
+
meta = {
|
|
1515
|
+
"trajectory_type": "3d_egg_rosette",
|
|
1516
|
+
"omega1": omega1,
|
|
1517
|
+
"omega2": omega2,
|
|
1518
|
+
"rotation_per_shot": rot_per_shot,
|
|
1519
|
+
"kmax": (kmax_x, kmax_y, kmax_z),
|
|
1520
|
+
"fov_mm": geom["fov_mm"],
|
|
1521
|
+
"matrix": (nx, ny, nz),
|
|
1522
|
+
"n_shots": n_shots,
|
|
1523
|
+
"samples_per_shot": sps,
|
|
1524
|
+
"reference": "Strasser et al., MedUni Vienna",
|
|
1525
|
+
"density_estimate": _density_estimate_placeholder(
|
|
1526
|
+
"3d_egg_rosette", n_shots, sps, geom),
|
|
1527
|
+
"params_used": params,
|
|
1528
|
+
}
|
|
1529
|
+
return shots, meta
|
|
1530
|
+
|
|
1531
|
+
_TRAJECTORY_GENERATORS = {
|
|
1532
|
+
"cartesian_2d": lambda g, p, b: _traj_cartesian_2d(g, p, b),
|
|
1533
|
+
"cartesian_3d": lambda g, p, b: _traj_cartesian_3d(g, p, b),
|
|
1534
|
+
"radial_2d": lambda g, p, b: _traj_radial_2d(g, p, b, use_golden=False),
|
|
1535
|
+
"golden_radial_2d": lambda g, p, b: _traj_radial_2d(g, p, b, use_golden=True),
|
|
1536
|
+
"spiral_2d": lambda g, p, b: _traj_spiral_2d(g, p, b),
|
|
1537
|
+
"rosette_2d": lambda g, p, b: _traj_rosette_2d(g, p, b),
|
|
1538
|
+
"rosette_2d_petals": lambda g, p, b: _traj_rosette_2d_petals(g, p, b),
|
|
1539
|
+
"concentric_rings_2d": lambda g, p, b: _traj_concentric_rings_2d(g, p, b),
|
|
1540
|
+
"stack_of_stars": lambda g, p, b: _traj_stack_of_x(g, p, b, "golden_radial"),
|
|
1541
|
+
"stack_of_spirals": lambda g, p, b: _traj_stack_of_x(g, p, b, "spiral"),
|
|
1542
|
+
"stack_of_cones": lambda g, p, b: _traj_stack_of_x(g, p, b, "cones"),
|
|
1543
|
+
"stack_of_rosettes": lambda g, p, b: _traj_stack_of_x(g, p, b, "rosette"),
|
|
1544
|
+
"stack_of_ECCENTRIC": lambda g, p, b: _traj_stack_of_x(g, p, b, "ECCENTRIC"),
|
|
1545
|
+
"3d_phyllotaxis": lambda g, p, b: _traj_3d_phyllotaxis(g, p, b),
|
|
1546
|
+
"cones_3d": lambda g, p, b: _traj_cones_3d(g, p, b),
|
|
1547
|
+
"cones_3d_rosette": lambda g, p, b: _traj_cones_3d_rosette(g, p, b),
|
|
1548
|
+
"floret_3d": lambda g, p, b: _traj_floret_3d(g, p, b),
|
|
1549
|
+
"3d_egg_rosette": lambda g, p, b: _traj_3d_egg_rosette(g, p, b),
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
|
|
1553
|
+
# ---------------------------------------------------------------------------
|
|
1554
|
+
# Public API: generate_trajectory
|
|
1555
|
+
# ---------------------------------------------------------------------------
|
|
1556
|
+
|
|
1557
|
+
def generate_trajectory(
|
|
1558
|
+
name: str,
|
|
1559
|
+
header: HeaderDict,
|
|
1560
|
+
params: ParamsDict,
|
|
1561
|
+
backend: Backend,
|
|
1562
|
+
) -> Tuple[List[Array], MetaDict]:
|
|
1563
|
+
"""
|
|
1564
|
+
Generate a k-space trajectory and return shot-organised coordinates.
|
|
1565
|
+
|
|
1566
|
+
Parameters
|
|
1567
|
+
----------
|
|
1568
|
+
name : str
|
|
1569
|
+
Trajectory name. One of:
|
|
1570
|
+
'cartesian_2d', 'cartesian_3d', 'radial_2d', 'golden_radial_2d',
|
|
1571
|
+
'spiral_2d', 'rosette_2d', 'stack_of_stars', 'stack_of_spirals',
|
|
1572
|
+
'stack_of_cones', 'stack_of_ECCENTRIC',
|
|
1573
|
+
'3d_phyllotaxis', 'cones_3d', 'floret_3d'.
|
|
1574
|
+
header : dict
|
|
1575
|
+
Parsed NIfTI-MRS header (passed to read_header_geometry internally).
|
|
1576
|
+
params : dict
|
|
1577
|
+
Trajectory-specific parameters (see individual trajectory docstrings).
|
|
1578
|
+
backend : Backend
|
|
1579
|
+
NumpyBackend() or TorchBackend() instance.
|
|
1580
|
+
|
|
1581
|
+
Returns
|
|
1582
|
+
-------
|
|
1583
|
+
coords_per_shot : list of arrays, each shape (Nsamples, Ndims)
|
|
1584
|
+
Ndims = 2 for 2D trajectories, 3 for 3D. All values in cycles/m.
|
|
1585
|
+
meta : dict
|
|
1586
|
+
trajectory_type, kmax (per-axis tuple), fov_mm, matrix, n_shots,
|
|
1587
|
+
samples_per_shot, density_estimate placeholder, params_used.
|
|
1588
|
+
|
|
1589
|
+
Raises
|
|
1590
|
+
------
|
|
1591
|
+
KeyError
|
|
1592
|
+
If required header fields are missing.
|
|
1593
|
+
ValueError
|
|
1594
|
+
If trajectory name is unknown or geometry is incompatible.
|
|
1595
|
+
TypeError
|
|
1596
|
+
If backend is missing required methods.
|
|
1597
|
+
"""
|
|
1598
|
+
_validate_backend(backend)
|
|
1599
|
+
if name not in _TRAJECTORY_GENERATORS:
|
|
1600
|
+
raise ValueError(
|
|
1601
|
+
f"Unknown trajectory '{name}'. Valid trajectories: "
|
|
1602
|
+
f"{sorted(_TRAJECTORY_GENERATORS.keys())}."
|
|
1603
|
+
)
|
|
1604
|
+
geom = read_header_geometry(header)
|
|
1605
|
+
if geom["inferred"]:
|
|
1606
|
+
warnings.warn(
|
|
1607
|
+
f"Some geometry fields were inferred: {geom['inferred']}. "
|
|
1608
|
+
"Verify header completeness for accurate k-space scaling.",
|
|
1609
|
+
UserWarning,
|
|
1610
|
+
stacklevel=2,
|
|
1611
|
+
)
|
|
1612
|
+
return _TRAJECTORY_GENERATORS[name](geom, params, backend)
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
# ---------------------------------------------------------------------------
|
|
1616
|
+
# Public API: shots_from_continuous
|
|
1617
|
+
# ---------------------------------------------------------------------------
|
|
1618
|
+
|
|
1619
|
+
def shots_from_continuous(
|
|
1620
|
+
coords: Array,
|
|
1621
|
+
segmentation: Dict[str, Any],
|
|
1622
|
+
backend: Backend,
|
|
1623
|
+
) -> List[Array]:
|
|
1624
|
+
"""
|
|
1625
|
+
Split a continuous k-space readout array into shots.
|
|
1626
|
+
|
|
1627
|
+
Parameters
|
|
1628
|
+
----------
|
|
1629
|
+
coords : array, shape (N_total_samples, Ndims)
|
|
1630
|
+
Continuous coordinate array in cycles/m.
|
|
1631
|
+
segmentation : dict
|
|
1632
|
+
Must contain one of:
|
|
1633
|
+
'n_shots' : int — equal-length split into n_shots chunks.
|
|
1634
|
+
'shot_lengths' : list[int] — variable lengths per shot.
|
|
1635
|
+
'shot_boundaries' : list[int] — start indices for each shot
|
|
1636
|
+
(last boundary implicitly at N_total_samples).
|
|
1637
|
+
backend : Backend
|
|
1638
|
+
|
|
1639
|
+
Returns
|
|
1640
|
+
-------
|
|
1641
|
+
list of arrays, each shape (Nsamples_i, Ndims)
|
|
1642
|
+
|
|
1643
|
+
Raises
|
|
1644
|
+
------
|
|
1645
|
+
ValueError
|
|
1646
|
+
If segmentation dict lacks required keys or lengths are inconsistent.
|
|
1647
|
+
"""
|
|
1648
|
+
_validate_backend(backend)
|
|
1649
|
+
total = backend.shape(coords)[0]
|
|
1650
|
+
cpu_coords = backend.to_cpu(coords)
|
|
1651
|
+
|
|
1652
|
+
if "n_shots" in segmentation:
|
|
1653
|
+
n = int(segmentation["n_shots"])
|
|
1654
|
+
if total % n != 0:
|
|
1655
|
+
raise ValueError(
|
|
1656
|
+
f"'n_shots'={n} does not evenly divide total samples={total}. "
|
|
1657
|
+
"Use 'shot_lengths' or 'shot_boundaries' for unequal shots."
|
|
1658
|
+
)
|
|
1659
|
+
sps = total // n
|
|
1660
|
+
shots = [backend.asarray(cpu_coords[i * sps: (i + 1) * sps]) for i in range(n)]
|
|
1661
|
+
|
|
1662
|
+
elif "shot_lengths" in segmentation:
|
|
1663
|
+
lengths = list(segmentation["shot_lengths"])
|
|
1664
|
+
if sum(lengths) != total:
|
|
1665
|
+
raise ValueError(
|
|
1666
|
+
f"Sum of shot_lengths ({sum(lengths)}) != total samples ({total})."
|
|
1667
|
+
)
|
|
1668
|
+
shots = []
|
|
1669
|
+
start = 0
|
|
1670
|
+
for length in lengths:
|
|
1671
|
+
shots.append(backend.asarray(cpu_coords[start: start + length]))
|
|
1672
|
+
start += length
|
|
1673
|
+
|
|
1674
|
+
elif "shot_boundaries" in segmentation:
|
|
1675
|
+
bounds = list(segmentation["shot_boundaries"]) + [total]
|
|
1676
|
+
shots = []
|
|
1677
|
+
for s, e in zip(bounds[:-1], bounds[1:]):
|
|
1678
|
+
shots.append(backend.asarray(cpu_coords[s:e]))
|
|
1679
|
+
|
|
1680
|
+
else:
|
|
1681
|
+
raise ValueError(
|
|
1682
|
+
"segmentation must contain 'n_shots', 'shot_lengths', or 'shot_boundaries'. "
|
|
1683
|
+
f"Got keys: {list(segmentation.keys())}."
|
|
1684
|
+
)
|
|
1685
|
+
return shots
|
|
1686
|
+
|
|
1687
|
+
|
|
1688
|
+
# ---------------------------------------------------------------------------
|
|
1689
|
+
# Undersampling Strategies (internal helpers)
|
|
1690
|
+
# ---------------------------------------------------------------------------
|
|
1691
|
+
|
|
1692
|
+
def _us_prefix(n_shots: int, af: float, params: dict) -> np.ndarray:
|
|
1693
|
+
"""Keep first ceil(n_shots / AF) shots."""
|
|
1694
|
+
n_keep = math.ceil(n_shots / af)
|
|
1695
|
+
mask = np.zeros(n_shots, dtype=bool)
|
|
1696
|
+
mask[:n_keep] = True
|
|
1697
|
+
return mask
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _us_golden_prefix(n_shots: int, af: float, params: dict) -> np.ndarray:
|
|
1701
|
+
"""
|
|
1702
|
+
Equivalent to prefix for golden-angle sorted trajectories.
|
|
1703
|
+
Alias of prefix; golden-angle ordering guarantees spatial uniformity.
|
|
1704
|
+
"""
|
|
1705
|
+
return _us_prefix(n_shots, af, params)
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def _us_drop_every(n_shots: int, af: float, params: dict) -> np.ndarray:
|
|
1709
|
+
"""Drop every M-th shot. M = round(AF) by default or params['drop_every_m']."""
|
|
1710
|
+
m = int(params.get("drop_every_m", max(1, round(af))))
|
|
1711
|
+
mask = np.ones(n_shots, dtype=bool)
|
|
1712
|
+
mask[::m] = False
|
|
1713
|
+
return mask
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
def _us_random_vd(n_shots: int, af: float, params: dict,
|
|
1717
|
+
coords_per_shot: List[Array], backend: Backend) -> np.ndarray:
|
|
1718
|
+
"""
|
|
1719
|
+
Variable-density random undersampling.
|
|
1720
|
+
Centre shots (lower norm) are kept with higher probability.
|
|
1721
|
+
|
|
1722
|
+
params
|
|
1723
|
+
------
|
|
1724
|
+
vd_beta : float
|
|
1725
|
+
Density decay exponent. 0 = uniform random, >0 = centre-weighted (default 2).
|
|
1726
|
+
acs_fraction : float
|
|
1727
|
+
Fraction of shots nearest to k-centre always kept (default 0.05).
|
|
1728
|
+
"""
|
|
1729
|
+
n_keep = math.ceil(n_shots / af)
|
|
1730
|
+
vd_beta = float(params.get("vd_beta", 2.0))
|
|
1731
|
+
acs_frac = float(params.get("acs_fraction", 0.05))
|
|
1732
|
+
|
|
1733
|
+
# Compute shot centre norms (use numpy via to_cpu)
|
|
1734
|
+
norms = []
|
|
1735
|
+
for shot in coords_per_shot:
|
|
1736
|
+
cpu = backend.to_cpu(shot)
|
|
1737
|
+
norms.append(float(np.linalg.norm(cpu.mean(axis=0))))
|
|
1738
|
+
norms = np.array(norms, dtype=np.float32)
|
|
1739
|
+
|
|
1740
|
+
# Normalise to [0,1]
|
|
1741
|
+
if norms.max() > 0:
|
|
1742
|
+
norms_n = norms / norms.max()
|
|
1743
|
+
else:
|
|
1744
|
+
norms_n = norms
|
|
1745
|
+
|
|
1746
|
+
# Probability: centre gets high prob
|
|
1747
|
+
prob = np.exp(-vd_beta * norms_n)
|
|
1748
|
+
prob /= prob.sum()
|
|
1749
|
+
|
|
1750
|
+
acs_n = max(0, int(acs_frac * n_shots))
|
|
1751
|
+
sorted_idx = np.argsort(norms)
|
|
1752
|
+
acs_idx = sorted_idx[:acs_n]
|
|
1753
|
+
|
|
1754
|
+
outer_idx = np.setdiff1d(np.arange(n_shots), acs_idx)
|
|
1755
|
+
n_outer_keep = max(0, n_keep - acs_n)
|
|
1756
|
+
outer_prob = prob[outer_idx]
|
|
1757
|
+
outer_prob /= outer_prob.sum()
|
|
1758
|
+
chosen = np.random.choice(outer_idx, size=min(n_outer_keep, len(outer_idx)),
|
|
1759
|
+
replace=False, p=outer_prob)
|
|
1760
|
+
|
|
1761
|
+
mask = np.zeros(n_shots, dtype=bool)
|
|
1762
|
+
mask[acs_idx] = True
|
|
1763
|
+
mask[chosen] = True
|
|
1764
|
+
return mask
|
|
1765
|
+
|
|
1766
|
+
|
|
1767
|
+
def _us_keep_acs(n_shots: int, af: float, params: dict,
|
|
1768
|
+
coords_per_shot: List[Array], backend: Backend,
|
|
1769
|
+
traj_name: str) -> np.ndarray:
|
|
1770
|
+
"""
|
|
1771
|
+
Keep shots whose centre lies within the ACS region (innermost fraction of k-space).
|
|
1772
|
+
|
|
1773
|
+
params
|
|
1774
|
+
------
|
|
1775
|
+
acs_radius_fraction : float
|
|
1776
|
+
Fraction of kmax within which shots are kept (default 0.25).
|
|
1777
|
+
acs_n_lines : int
|
|
1778
|
+
For Cartesian: number of central lines to keep (overrides acs_radius_fraction).
|
|
1779
|
+
"""
|
|
1780
|
+
acs_frac = float(params.get("acs_radius_fraction", 0.25))
|
|
1781
|
+
mask = np.zeros(n_shots, dtype=bool)
|
|
1782
|
+
|
|
1783
|
+
norms = []
|
|
1784
|
+
for shot in coords_per_shot:
|
|
1785
|
+
cpu = backend.to_cpu(shot)
|
|
1786
|
+
norms.append(float(np.linalg.norm(cpu.mean(axis=0))))
|
|
1787
|
+
norms = np.array(norms, dtype=np.float32)
|
|
1788
|
+
|
|
1789
|
+
if norms.max() > 0:
|
|
1790
|
+
norms_n = norms / norms.max()
|
|
1791
|
+
else:
|
|
1792
|
+
norms_n = np.zeros(n_shots, dtype=np.float32)
|
|
1793
|
+
|
|
1794
|
+
if "acs_n_lines" in params and "cartesian" in traj_name:
|
|
1795
|
+
# Centre the selection
|
|
1796
|
+
acs_n = int(params["acs_n_lines"])
|
|
1797
|
+
centre_idx = np.argsort(norms_n)[:acs_n]
|
|
1798
|
+
mask[centre_idx] = True
|
|
1799
|
+
else:
|
|
1800
|
+
mask[norms_n <= acs_frac] = True
|
|
1801
|
+
|
|
1802
|
+
if not mask.any():
|
|
1803
|
+
warnings.warn(
|
|
1804
|
+
"keep_acs found no shots inside ACS region. "
|
|
1805
|
+
f"Increase acs_radius_fraction (currently {acs_frac}) or acs_n_lines.",
|
|
1806
|
+
UserWarning, stacklevel=3,
|
|
1807
|
+
)
|
|
1808
|
+
return mask
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def _us_poisson_disc_cartesian(n_shots: int, af: float, params: dict,
|
|
1812
|
+
coords_per_shot: List[Array],
|
|
1813
|
+
backend: Backend) -> np.ndarray:
|
|
1814
|
+
"""
|
|
1815
|
+
Variable-density Poisson-disc undersampling for Cartesian trajectories.
|
|
1816
|
+
|
|
1817
|
+
Full Poisson-disc is expensive; this uses a simplified VD-random approach
|
|
1818
|
+
with a centre-dense probability map as a practical stand-in compatible
|
|
1819
|
+
with MRI pipelines.
|
|
1820
|
+
|
|
1821
|
+
params
|
|
1822
|
+
------
|
|
1823
|
+
acs_lines : int
|
|
1824
|
+
Number of central lines always included (default: 24).
|
|
1825
|
+
vd_beta : float
|
|
1826
|
+
Density exponent (default: 3.0).
|
|
1827
|
+
seed : int
|
|
1828
|
+
RNG seed (default: 42).
|
|
1829
|
+
"""
|
|
1830
|
+
acs_lines = int(params.get("acs_lines", 24))
|
|
1831
|
+
vd_beta = float(params.get("vd_beta", 3.0))
|
|
1832
|
+
seed = int(params.get("seed", 42))
|
|
1833
|
+
rng = np.random.default_rng(seed)
|
|
1834
|
+
|
|
1835
|
+
# Phase encode index from shot centre
|
|
1836
|
+
norms = []
|
|
1837
|
+
for shot in coords_per_shot:
|
|
1838
|
+
cpu = backend.to_cpu(shot)
|
|
1839
|
+
norms.append(float(np.abs(cpu[:, 1]).mean())) # ky-like axis
|
|
1840
|
+
norms = np.array(norms, dtype=np.float32)
|
|
1841
|
+
|
|
1842
|
+
if norms.max() > 0:
|
|
1843
|
+
norms_n = norms / norms.max()
|
|
1844
|
+
else:
|
|
1845
|
+
norms_n = np.zeros(n_shots, dtype=np.float32)
|
|
1846
|
+
|
|
1847
|
+
sorted_idx = np.argsort(norms_n)
|
|
1848
|
+
acs_idx = sorted_idx[:acs_lines]
|
|
1849
|
+
|
|
1850
|
+
n_keep = max(0, math.ceil(n_shots / af) - acs_lines)
|
|
1851
|
+
remaining = sorted_idx[acs_lines:]
|
|
1852
|
+
prob = np.exp(-vd_beta * norms_n[remaining])
|
|
1853
|
+
if prob.sum() > 0:
|
|
1854
|
+
prob /= prob.sum()
|
|
1855
|
+
else:
|
|
1856
|
+
prob = np.ones(len(remaining), dtype=np.float32) / len(remaining)
|
|
1857
|
+
|
|
1858
|
+
chosen = rng.choice(remaining, size=min(n_keep, len(remaining)),
|
|
1859
|
+
replace=False, p=prob)
|
|
1860
|
+
|
|
1861
|
+
mask = np.zeros(n_shots, dtype=bool)
|
|
1862
|
+
mask[acs_idx] = True
|
|
1863
|
+
mask[chosen] = True
|
|
1864
|
+
return mask
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def _us_combined(n_shots: int, af: float, params: dict,
|
|
1868
|
+
coords_per_shot: List[Array],
|
|
1869
|
+
backend: Backend, traj_name: str) -> np.ndarray:
|
|
1870
|
+
"""
|
|
1871
|
+
Combined strategy: keep ACS + prefix/random_vd on remaining shots.
|
|
1872
|
+
|
|
1873
|
+
params
|
|
1874
|
+
------
|
|
1875
|
+
acs_radius_fraction : float (default 0.15)
|
|
1876
|
+
outer_method : str 'prefix' | 'random_vd' | 'drop_every' (default 'prefix')
|
|
1877
|
+
outer_af : float (default = af, applied to outer shots only)
|
|
1878
|
+
"""
|
|
1879
|
+
acs_mask = _us_keep_acs(n_shots, af, params, coords_per_shot, backend, traj_name)
|
|
1880
|
+
outer_idx = np.where(~acs_mask)[0]
|
|
1881
|
+
outer_method = str(params.get("outer_method", "prefix"))
|
|
1882
|
+
outer_af = float(params.get("outer_af", af))
|
|
1883
|
+
|
|
1884
|
+
outer_coords = [coords_per_shot[i] for i in outer_idx]
|
|
1885
|
+
n_outer = len(outer_idx)
|
|
1886
|
+
|
|
1887
|
+
if outer_method == "prefix":
|
|
1888
|
+
outer_mask = _us_prefix(n_outer, outer_af, params)
|
|
1889
|
+
elif outer_method == "random_vd":
|
|
1890
|
+
outer_mask = _us_random_vd(n_outer, outer_af, params, outer_coords, backend)
|
|
1891
|
+
elif outer_method == "drop_every":
|
|
1892
|
+
outer_mask = _us_drop_every(n_outer, outer_af, params)
|
|
1893
|
+
else:
|
|
1894
|
+
raise ValueError(
|
|
1895
|
+
f"combined outer_method '{outer_method}' not recognised. "
|
|
1896
|
+
"Use 'prefix', 'random_vd', or 'drop_every'."
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
mask = acs_mask.copy()
|
|
1900
|
+
mask[outer_idx[outer_mask]] = True
|
|
1901
|
+
return mask
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _us_variable_density_spiral(n_shots: int, af: float, params: dict) -> np.ndarray:
|
|
1905
|
+
"""
|
|
1906
|
+
Spiral undersampling by removing outer arms.
|
|
1907
|
+
Arms are assumed ordered centre-to-outer; keep first ceil(n/AF).
|
|
1908
|
+
"""
|
|
1909
|
+
return _us_prefix(n_shots, af, params)
|
|
1910
|
+
|
|
1911
|
+
|
|
1912
|
+
def _us_shell_based(n_shots: int, af: float, params: dict,
|
|
1913
|
+
coords_per_shot: List[Array], backend: Backend) -> np.ndarray:
|
|
1914
|
+
"""
|
|
1915
|
+
Shell-based undersampling for 3D trajectories: keep full inner shells,
|
|
1916
|
+
undersample outer shells progressively.
|
|
1917
|
+
|
|
1918
|
+
params
|
|
1919
|
+
------
|
|
1920
|
+
n_shells : int
|
|
1921
|
+
Number of concentric shells to divide shots into (default: 4).
|
|
1922
|
+
shell_afs : list[float]
|
|
1923
|
+
Per-shell acceleration factors from inner to outer
|
|
1924
|
+
(default: geometrically increasing from 1 to af).
|
|
1925
|
+
"""
|
|
1926
|
+
n_shells = int(params.get("n_shells", 4))
|
|
1927
|
+
shell_afs_param = params.get("shell_afs", None)
|
|
1928
|
+
|
|
1929
|
+
norms = np.array([float(np.linalg.norm(backend.to_cpu(s).mean(axis=0)))
|
|
1930
|
+
for s in coords_per_shot], dtype=np.float32)
|
|
1931
|
+
if norms.max() > 0:
|
|
1932
|
+
norms_n = norms / norms.max()
|
|
1933
|
+
else:
|
|
1934
|
+
norms_n = np.zeros(n_shots, dtype=np.float32)
|
|
1935
|
+
|
|
1936
|
+
shell_edges = np.linspace(0.0, 1.0, n_shells + 1)
|
|
1937
|
+
if shell_afs_param is None:
|
|
1938
|
+
shell_afs = np.geomspace(1.0, af, n_shells)
|
|
1939
|
+
else:
|
|
1940
|
+
shell_afs = np.asarray(shell_afs_param, dtype=np.float64)
|
|
1941
|
+
if len(shell_afs) != n_shells:
|
|
1942
|
+
raise ValueError(
|
|
1943
|
+
f"shell_afs length ({len(shell_afs)}) must equal n_shells ({n_shells})."
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
mask = np.zeros(n_shots, dtype=bool)
|
|
1947
|
+
for si in range(n_shells):
|
|
1948
|
+
idx = np.where((norms_n >= shell_edges[si]) &
|
|
1949
|
+
(norms_n < shell_edges[si + 1]))[0]
|
|
1950
|
+
n_keep = math.ceil(len(idx) / float(shell_afs[si]))
|
|
1951
|
+
chosen = idx[:n_keep]
|
|
1952
|
+
mask[chosen] = True
|
|
1953
|
+
|
|
1954
|
+
return mask
|
|
1955
|
+
|
|
1956
|
+
|
|
1957
|
+
# ---------------------------------------------------------------------------
|
|
1958
|
+
# Public API: undersample_shots
|
|
1959
|
+
# ---------------------------------------------------------------------------
|
|
1960
|
+
|
|
1961
|
+
def undersample_shots(
|
|
1962
|
+
coords_per_shot: List[Array],
|
|
1963
|
+
method: str,
|
|
1964
|
+
AF: float,
|
|
1965
|
+
params: ParamsDict,
|
|
1966
|
+
backend: Backend,
|
|
1967
|
+
trajectory_name: str = "",
|
|
1968
|
+
) -> Tuple[Array, Optional[List[Array]]]:
|
|
1969
|
+
"""
|
|
1970
|
+
Apply an undersampling strategy to a list of shots.
|
|
1971
|
+
|
|
1972
|
+
Parameters
|
|
1973
|
+
----------
|
|
1974
|
+
coords_per_shot : list of arrays, each shape (Nsamples, Ndims)
|
|
1975
|
+
Full trajectory shots.
|
|
1976
|
+
method : str
|
|
1977
|
+
Undersampling method. One of:
|
|
1978
|
+
'prefix', 'golden_prefix', 'drop_every', 'poisson_disc_cartesian',
|
|
1979
|
+
'random_vd', 'keep_acs', 'variable_density_spiral',
|
|
1980
|
+
'shell_based', 'combined'.
|
|
1981
|
+
AF : float
|
|
1982
|
+
Acceleration factor (≥ 1). Number of retained shots ≈ N / AF.
|
|
1983
|
+
Exact: N_keep = ceil(N_total / AF).
|
|
1984
|
+
params : dict
|
|
1985
|
+
Method-specific parameters (see each _us_* helper for keys).
|
|
1986
|
+
backend : Backend
|
|
1987
|
+
trajectory_name : str, optional
|
|
1988
|
+
Name of the trajectory (used for compatibility checks).
|
|
1989
|
+
|
|
1990
|
+
Returns
|
|
1991
|
+
-------
|
|
1992
|
+
shot_mask : boolean array, shape (N_shots,)
|
|
1993
|
+
True → shot is included.
|
|
1994
|
+
per_sample_masks : list of boolean arrays | None
|
|
1995
|
+
One boolean array of shape (Nsamples,) per *included* shot.
|
|
1996
|
+
Currently None (per-shot masks deferred to downstream partial-Fourier
|
|
1997
|
+
or variable-density readout implementations).
|
|
1998
|
+
|
|
1999
|
+
Raises
|
|
2000
|
+
------
|
|
2001
|
+
ValueError
|
|
2002
|
+
If method is not supported, AF < 1, or trajectory/method incompatible.
|
|
2003
|
+
TypeError
|
|
2004
|
+
If backend is missing required methods.
|
|
2005
|
+
"""
|
|
2006
|
+
_validate_backend(backend)
|
|
2007
|
+
|
|
2008
|
+
if AF < 1.0:
|
|
2009
|
+
raise ValueError(
|
|
2010
|
+
f"AF must be ≥ 1.0 (got {AF}). "
|
|
2011
|
+
"AF < 1 would require more shots than fully sampled — not valid."
|
|
2012
|
+
)
|
|
2013
|
+
|
|
2014
|
+
n_shots = len(coords_per_shot)
|
|
2015
|
+
if n_shots == 0:
|
|
2016
|
+
raise ValueError("coords_per_shot is empty — no shots to undersample.")
|
|
2017
|
+
|
|
2018
|
+
# Compatibility check
|
|
2019
|
+
if trajectory_name:
|
|
2020
|
+
compat = _TRAJ_US_COMPAT.get(trajectory_name, None)
|
|
2021
|
+
if compat is not None and method not in compat:
|
|
2022
|
+
raise ValueError(
|
|
2023
|
+
f"Undersampling method '{method}' is not compatible with "
|
|
2024
|
+
f"trajectory '{trajectory_name}'. "
|
|
2025
|
+
f"Supported methods for this trajectory: {compat}. "
|
|
2026
|
+
"Pass trajectory_name='' to bypass compatibility checking."
|
|
2027
|
+
)
|
|
2028
|
+
|
|
2029
|
+
valid_methods = [
|
|
2030
|
+
"prefix", "golden_prefix", "drop_every", "poisson_disc_cartesian",
|
|
2031
|
+
"random_vd", "keep_acs", "variable_density_spiral", "shell_based", "combined",
|
|
2032
|
+
]
|
|
2033
|
+
if method not in valid_methods:
|
|
2034
|
+
raise ValueError(
|
|
2035
|
+
f"Unknown undersampling method '{method}'. Valid: {valid_methods}."
|
|
2036
|
+
)
|
|
2037
|
+
|
|
2038
|
+
traj_name = trajectory_name or ""
|
|
2039
|
+
|
|
2040
|
+
if method == "prefix":
|
|
2041
|
+
mask_np = _us_prefix(n_shots, AF, params)
|
|
2042
|
+
elif method == "golden_prefix":
|
|
2043
|
+
mask_np = _us_golden_prefix(n_shots, AF, params)
|
|
2044
|
+
elif method == "drop_every":
|
|
2045
|
+
mask_np = _us_drop_every(n_shots, AF, params)
|
|
2046
|
+
elif method == "poisson_disc_cartesian":
|
|
2047
|
+
mask_np = _us_poisson_disc_cartesian(n_shots, AF, params, coords_per_shot, backend)
|
|
2048
|
+
elif method == "random_vd":
|
|
2049
|
+
mask_np = _us_random_vd(n_shots, AF, params, coords_per_shot, backend)
|
|
2050
|
+
elif method == "keep_acs":
|
|
2051
|
+
mask_np = _us_keep_acs(n_shots, AF, params, coords_per_shot, backend, traj_name)
|
|
2052
|
+
elif method == "variable_density_spiral":
|
|
2053
|
+
mask_np = _us_variable_density_spiral(n_shots, AF, params)
|
|
2054
|
+
elif method == "shell_based":
|
|
2055
|
+
mask_np = _us_shell_based(n_shots, AF, params, coords_per_shot, backend)
|
|
2056
|
+
elif method == "combined":
|
|
2057
|
+
mask_np = _us_combined(n_shots, AF, params, coords_per_shot, backend, traj_name)
|
|
2058
|
+
|
|
2059
|
+
achieved_af = n_shots / max(mask_np.sum(), 1)
|
|
2060
|
+
if abs(achieved_af - AF) > 0.5 * AF:
|
|
2061
|
+
warnings.warn(
|
|
2062
|
+
f"Achieved AF={achieved_af:.2f} differs substantially from requested AF={AF:.2f}. "
|
|
2063
|
+
"Consider adjusting 'drop_every_m', 'acs_lines', or 'acs_radius_fraction'.",
|
|
2064
|
+
UserWarning,
|
|
2065
|
+
stacklevel=2,
|
|
2066
|
+
)
|
|
2067
|
+
|
|
2068
|
+
shot_mask = backend.cast_bool(backend.asarray(mask_np.astype(np.uint8)))
|
|
2069
|
+
return shot_mask, None # per_sample_masks deferred → None
|
|
2070
|
+
|
|
2071
|
+
|
|
2072
|
+
# ---------------------------------------------------------------------------
|
|
2073
|
+
# Public API: get_kspace_shots_and_mask (full pipeline wrapper)
|
|
2074
|
+
# ---------------------------------------------------------------------------
|
|
2075
|
+
|
|
2076
|
+
def get_kspace_shots_and_mask(
|
|
2077
|
+
header: HeaderDict,
|
|
2078
|
+
trajectory: str,
|
|
2079
|
+
undersampling: str,
|
|
2080
|
+
AF: float,
|
|
2081
|
+
traj_params: ParamsDict,
|
|
2082
|
+
us_params: ParamsDict,
|
|
2083
|
+
backend: Backend,
|
|
2084
|
+
) -> Tuple[List[Array], Array, MetaDict]:
|
|
2085
|
+
"""
|
|
2086
|
+
Full pipeline: generate trajectory → apply undersampling → return results.
|
|
2087
|
+
|
|
2088
|
+
Parameters
|
|
2089
|
+
----------
|
|
2090
|
+
header : dict
|
|
2091
|
+
Parsed NIfTI-MRS header dictionary.
|
|
2092
|
+
trajectory : str
|
|
2093
|
+
Trajectory name (see generate_trajectory for full list).
|
|
2094
|
+
undersampling : str
|
|
2095
|
+
Undersampling method name (see undersample_shots for full list).
|
|
2096
|
+
AF : float
|
|
2097
|
+
Acceleration factor (≥ 1).
|
|
2098
|
+
traj_params : dict
|
|
2099
|
+
Passed verbatim to generate_trajectory.
|
|
2100
|
+
us_params : dict
|
|
2101
|
+
Passed verbatim to undersample_shots.
|
|
2102
|
+
backend : Backend
|
|
2103
|
+
NumpyBackend() or TorchBackend() instance.
|
|
2104
|
+
|
|
2105
|
+
Returns
|
|
2106
|
+
-------
|
|
2107
|
+
coords_per_shot : list of arrays, each shape (Nsamples, Ndims)
|
|
2108
|
+
Coordinates in cycles/m for **all** generated shots (before masking).
|
|
2109
|
+
Use shot_mask to select retained shots.
|
|
2110
|
+
shot_mask : boolean array, shape (N_shots,)
|
|
2111
|
+
True → shot is retained (acquired).
|
|
2112
|
+
meta : dict
|
|
2113
|
+
Extended metadata dict including:
|
|
2114
|
+
trajectory_type, kmax, fov_mm, matrix, n_shots, samples_per_shot,
|
|
2115
|
+
n_shots_retained, achieved_AF, density_estimate, params_used,
|
|
2116
|
+
undersampling_method, AF_requested.
|
|
2117
|
+
|
|
2118
|
+
Example
|
|
2119
|
+
-------
|
|
2120
|
+
>>> from kspace_sampling import get_kspace_shots_and_mask, NumpyBackend
|
|
2121
|
+
>>> header = {
|
|
2122
|
+
... "dim": [4, 64, 64, 1, 1, 1, 1, 1],
|
|
2123
|
+
... "pixdim": [1.0, 3.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0],
|
|
2124
|
+
... "DwellTime": 1e-4,
|
|
2125
|
+
... "SpectrometerFrequency": [127.74e6],
|
|
2126
|
+
... }
|
|
2127
|
+
>>> shots, mask, meta = get_kspace_shots_and_mask(
|
|
2128
|
+
... header, "golden_radial_2d", "prefix", AF=4.0,
|
|
2129
|
+
... traj_params={"n_shots": 200, "samples_per_shot": 64},
|
|
2130
|
+
... us_params={},
|
|
2131
|
+
... backend=NumpyBackend(),
|
|
2132
|
+
... )
|
|
2133
|
+
>>> # shots: list of 200 arrays each (64, 2)
|
|
2134
|
+
>>> # mask: bool array (200,), ~50 True entries
|
|
2135
|
+
"""
|
|
2136
|
+
_validate_backend(backend)
|
|
2137
|
+
|
|
2138
|
+
coords_per_shot, meta = generate_trajectory(trajectory, header, traj_params, backend)
|
|
2139
|
+
shot_mask, per_sample_masks = undersample_shots(
|
|
2140
|
+
coords_per_shot, undersampling, AF, us_params, backend,
|
|
2141
|
+
trajectory_name=trajectory,
|
|
2142
|
+
)
|
|
2143
|
+
|
|
2144
|
+
mask_np = backend.to_cpu(shot_mask).astype(bool)
|
|
2145
|
+
n_retained = int(mask_np.sum())
|
|
2146
|
+
achieved_af = len(coords_per_shot) / max(n_retained, 1)
|
|
2147
|
+
|
|
2148
|
+
meta.update({
|
|
2149
|
+
"undersampling_method": undersampling,
|
|
2150
|
+
"AF_requested": AF,
|
|
2151
|
+
"n_shots_retained": n_retained,
|
|
2152
|
+
"achieved_AF": achieved_af,
|
|
2153
|
+
"us_params_used": us_params,
|
|
2154
|
+
})
|
|
2155
|
+
|
|
2156
|
+
return coords_per_shot, shot_mask, meta
|
|
2157
|
+
|
|
2158
|
+
|
|
2159
|
+
# ---------------------------------------------------------------------------
|
|
2160
|
+
# Simulation Hooks (signatures only; brief/optional implementations)
|
|
2161
|
+
# ---------------------------------------------------------------------------
|
|
2162
|
+
|
|
2163
|
+
def apply_shot_phase_errors(
|
|
2164
|
+
coords_per_shot: List[Array],
|
|
2165
|
+
phase_std_rad: float,
|
|
2166
|
+
backend: Backend,
|
|
2167
|
+
seed: int = 0,
|
|
2168
|
+
) -> List[Array]:
|
|
2169
|
+
"""
|
|
2170
|
+
Simulation hook: inject shot-to-shot phase errors (k-space modulation).
|
|
2171
|
+
|
|
2172
|
+
This does NOT modify coordinates — it returns a list of scalar phase
|
|
2173
|
+
offset values (in radians) per shot that can be applied as complex
|
|
2174
|
+
exponential weights during NUFFT.
|
|
2175
|
+
|
|
2176
|
+
Parameters
|
|
2177
|
+
----------
|
|
2178
|
+
coords_per_shot : list of arrays
|
|
2179
|
+
phase_std_rad : float
|
|
2180
|
+
Standard deviation of normally distributed random phase offsets [rad].
|
|
2181
|
+
backend : Backend
|
|
2182
|
+
seed : int
|
|
2183
|
+
RNG seed.
|
|
2184
|
+
|
|
2185
|
+
Returns
|
|
2186
|
+
-------
|
|
2187
|
+
list of float
|
|
2188
|
+
One phase offset (radians) per shot.
|
|
2189
|
+
"""
|
|
2190
|
+
rng = np.random.default_rng(seed)
|
|
2191
|
+
return [float(rng.normal(0.0, phase_std_rad)) for _ in coords_per_shot]
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def apply_gradient_delay(
|
|
2195
|
+
coords_per_shot: List[Array],
|
|
2196
|
+
delay_samples: float,
|
|
2197
|
+
backend: Backend,
|
|
2198
|
+
) -> List[Array]:
|
|
2199
|
+
"""
|
|
2200
|
+
Simulation hook: shift each shot's k-space trajectory by a gradient delay.
|
|
2201
|
+
|
|
2202
|
+
The delay is applied along the readout dimension (axis 0 of each shot).
|
|
2203
|
+
|
|
2204
|
+
k_shifted[n] = k[n + delay_samples] approximated by:
|
|
2205
|
+
k_shifted ≈ k + delay_samples * dk where dk is the per-sample step.
|
|
2206
|
+
|
|
2207
|
+
Parameters
|
|
2208
|
+
----------
|
|
2209
|
+
coords_per_shot : list of arrays, each (Nsamples, Ndims)
|
|
2210
|
+
delay_samples : float
|
|
2211
|
+
Delay in samples (may be fractional).
|
|
2212
|
+
backend : Backend
|
|
2213
|
+
|
|
2214
|
+
Returns
|
|
2215
|
+
-------
|
|
2216
|
+
list of arrays (same shapes), shifted along readout.
|
|
2217
|
+
"""
|
|
2218
|
+
shifted = []
|
|
2219
|
+
for shot in coords_per_shot:
|
|
2220
|
+
cpu = backend.to_cpu(shot)
|
|
2221
|
+
if cpu.shape[0] < 2:
|
|
2222
|
+
shifted.append(shot)
|
|
2223
|
+
continue
|
|
2224
|
+
dk = cpu[1] - cpu[0] # per-sample k-space increment (Ndims,)
|
|
2225
|
+
shift = dk * delay_samples
|
|
2226
|
+
shifted_cpu = cpu + shift[np.newaxis, :]
|
|
2227
|
+
shifted.append(backend.asarray(shifted_cpu))
|
|
2228
|
+
return shifted
|
|
2229
|
+
|
|
2230
|
+
|
|
2231
|
+
def apply_off_resonance_phase(
|
|
2232
|
+
coords_per_shot: List[Array],
|
|
2233
|
+
freq_offset_hz: float,
|
|
2234
|
+
dwell_time_s: float,
|
|
2235
|
+
backend: Backend,
|
|
2236
|
+
) -> List[float]:
|
|
2237
|
+
"""
|
|
2238
|
+
Simulation hook: compute per-sample off-resonance phase for each shot.
|
|
2239
|
+
|
|
2240
|
+
Returns modulation phases φ(t) = 2π * Δf * t_n for downstream application
|
|
2241
|
+
as complex weights. Does not modify coordinate arrays.
|
|
2242
|
+
|
|
2243
|
+
Parameters
|
|
2244
|
+
----------
|
|
2245
|
+
coords_per_shot : list of arrays, each (Nsamples, Ndims)
|
|
2246
|
+
freq_offset_hz : float
|
|
2247
|
+
Off-resonance frequency in Hz.
|
|
2248
|
+
dwell_time_s : float
|
|
2249
|
+
Sampling interval in seconds (== DwellTime from header).
|
|
2250
|
+
backend : Backend
|
|
2251
|
+
|
|
2252
|
+
Returns
|
|
2253
|
+
-------
|
|
2254
|
+
list of arrays, each (Nsamples,) — phase in radians.
|
|
2255
|
+
"""
|
|
2256
|
+
phases = []
|
|
2257
|
+
for shot in coords_per_shot:
|
|
2258
|
+
nsamples = backend.shape(shot)[0]
|
|
2259
|
+
t = backend.arange(0, nsamples) * dwell_time_s
|
|
2260
|
+
phi = 2.0 * math.pi * freq_offset_hz * t
|
|
2261
|
+
phases.append(phi)
|
|
2262
|
+
return phases
|
|
2263
|
+
|
|
2264
|
+
|
|
2265
|
+
# ---------------------------------------------------------------------------
|
|
2266
|
+
# Density Estimate (public wrapper)
|
|
2267
|
+
# ---------------------------------------------------------------------------
|
|
2268
|
+
|
|
2269
|
+
def compute_density_estimate(
|
|
2270
|
+
coords_per_shot: List[Array],
|
|
2271
|
+
shot_mask: Array,
|
|
2272
|
+
meta: MetaDict,
|
|
2273
|
+
backend: Backend,
|
|
2274
|
+
) -> dict:
|
|
2275
|
+
"""
|
|
2276
|
+
Return a refreshed density estimate for a given (possibly undersampled) subset.
|
|
2277
|
+
|
|
2278
|
+
Does NOT compute DCF — it returns an analytic placeholder dict with
|
|
2279
|
+
recomputed subset statistics for use by downstream pipe or iterative DCF code.
|
|
2280
|
+
|
|
2281
|
+
Parameters
|
|
2282
|
+
----------
|
|
2283
|
+
coords_per_shot : list of arrays (all shots, before masking)
|
|
2284
|
+
shot_mask : boolean array (N_shots,)
|
|
2285
|
+
meta : dict from generate_trajectory or get_kspace_shots_and_mask
|
|
2286
|
+
backend : Backend
|
|
2287
|
+
|
|
2288
|
+
Returns
|
|
2289
|
+
-------
|
|
2290
|
+
dict
|
|
2291
|
+
Updated density estimate dict appropriate for the retained subset.
|
|
2292
|
+
"""
|
|
2293
|
+
mask_np = backend.to_cpu(shot_mask).astype(bool)
|
|
2294
|
+
retained = [s for s, m in zip(coords_per_shot, mask_np) if m]
|
|
2295
|
+
n_retained = len(retained)
|
|
2296
|
+
sps = backend.shape(retained[0])[0] if retained else 0
|
|
2297
|
+
|
|
2298
|
+
base = meta.get("density_estimate", {}).copy()
|
|
2299
|
+
base.update({
|
|
2300
|
+
"n_shots_subset": n_retained,
|
|
2301
|
+
"samples_per_shot_subset": sps,
|
|
2302
|
+
"total_samples_subset": n_retained * sps,
|
|
2303
|
+
"achieved_AF": meta.get("n_shots", n_retained) / max(n_retained, 1),
|
|
2304
|
+
"recomputed_for_subset": True,
|
|
2305
|
+
})
|
|
2306
|
+
return base
|
|
2307
|
+
|
|
2308
|
+
|
|
2309
|
+
# ---------------------------------------------------------------------------
|
|
2310
|
+
# Utility: export shots and mask to .npz
|
|
2311
|
+
# ---------------------------------------------------------------------------
|
|
2312
|
+
|
|
2313
|
+
def save_shots_npz(
|
|
2314
|
+
path: str,
|
|
2315
|
+
coords_per_shot: List[Array],
|
|
2316
|
+
shot_mask: Array,
|
|
2317
|
+
meta: MetaDict,
|
|
2318
|
+
backend: Backend,
|
|
2319
|
+
) -> None:
|
|
2320
|
+
"""
|
|
2321
|
+
Save trajectory and mask to a NumPy .npz archive.
|
|
2322
|
+
|
|
2323
|
+
Saved arrays
|
|
2324
|
+
------------
|
|
2325
|
+
shot_mask : (N_shots,) bool
|
|
2326
|
+
coords_N : (Nsamples, Ndims) float32 for each shot N (0-padded index)
|
|
2327
|
+
meta keys serialised as individual 0-d string arrays.
|
|
2328
|
+
|
|
2329
|
+
Parameters
|
|
2330
|
+
----------
|
|
2331
|
+
path : str
|
|
2332
|
+
File path (should end in .npz).
|
|
2333
|
+
coords_per_shot : list of arrays
|
|
2334
|
+
shot_mask : boolean array
|
|
2335
|
+
meta : dict
|
|
2336
|
+
backend : Backend
|
|
2337
|
+
"""
|
|
2338
|
+
payload: dict = {}
|
|
2339
|
+
payload["shot_mask"] = backend.to_cpu(shot_mask)
|
|
2340
|
+
for i, shot in enumerate(coords_per_shot):
|
|
2341
|
+
payload[f"coords_{i:06d}"] = backend.to_cpu(shot).astype(np.float32)
|
|
2342
|
+
# Save select meta fields as scalars
|
|
2343
|
+
for key in ["trajectory_type", "n_shots", "n_shots_retained",
|
|
2344
|
+
"achieved_AF", "AF_requested", "undersampling_method"]:
|
|
2345
|
+
if key in meta:
|
|
2346
|
+
payload[f"meta_{key}"] = np.array(str(meta[key]))
|
|
2347
|
+
kmax = meta.get("kmax")
|
|
2348
|
+
if kmax:
|
|
2349
|
+
payload["meta_kmax"] = np.array(kmax, dtype=np.float32)
|
|
2350
|
+
np.savez_compressed(path, **payload)
|
|
2351
|
+
print(f"Saved {len(coords_per_shot)} shots + mask → '{path}'")
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def load_shots_npz(path: str, backend: Backend) -> Tuple[List[Array], Array, dict]:
|
|
2355
|
+
"""
|
|
2356
|
+
Load a .npz archive saved by save_shots_npz.
|
|
2357
|
+
|
|
2358
|
+
Returns
|
|
2359
|
+
-------
|
|
2360
|
+
coords_per_shot, shot_mask, meta_partial
|
|
2361
|
+
"""
|
|
2362
|
+
data = np.load(path, allow_pickle=False)
|
|
2363
|
+
shot_mask = backend.cast_bool(backend.asarray(data["shot_mask"].astype(np.uint8)))
|
|
2364
|
+
|
|
2365
|
+
shot_keys = sorted(k for k in data.files if k.startswith("coords_"))
|
|
2366
|
+
coords_per_shot = [backend.asarray(data[k]) for k in shot_keys]
|
|
2367
|
+
|
|
2368
|
+
meta_partial: dict = {}
|
|
2369
|
+
for k in data.files:
|
|
2370
|
+
if k.startswith("meta_"):
|
|
2371
|
+
field = k[len("meta_"):]
|
|
2372
|
+
arr = data[k]
|
|
2373
|
+
try:
|
|
2374
|
+
meta_partial[field] = arr.item()
|
|
2375
|
+
except ValueError:
|
|
2376
|
+
# Multi-element array (e.g. kmax tuple)
|
|
2377
|
+
meta_partial[field] = arr.tolist()
|
|
2378
|
+
|
|
2379
|
+
return coords_per_shot, shot_mask, meta_partial
|
|
2380
|
+
|
|
2381
|
+
|
|
2382
|
+
# ---------------------------------------------------------------------------
|
|
2383
|
+
# Self-test / example block
|
|
2384
|
+
# ---------------------------------------------------------------------------
|
|
2385
|
+
|
|
2386
|
+
def _run_selftest() -> None:
|
|
2387
|
+
"""
|
|
2388
|
+
Self-test: exercise the main pipeline with a synthetic 2D golden-radial
|
|
2389
|
+
trajectory and prefix undersampling (AF=4).
|
|
2390
|
+
|
|
2391
|
+
Test description (also serves as usage example):
|
|
2392
|
+
------------------------------------------------
|
|
2393
|
+
1. Create a synthetic NIfTI-MRS-style header dict with keys:
|
|
2394
|
+
'dim', 'pixdim', 'DwellTime', 'SpectrometerFrequency'.
|
|
2395
|
+
2. Instantiate NumpyBackend.
|
|
2396
|
+
3. Call get_kspace_shots_and_mask with trajectory='golden_radial_2d',
|
|
2397
|
+
undersampling='prefix', AF=4.0, n_shots=200, samples_per_shot=64.
|
|
2398
|
+
4. Assert: len(coords_per_shot)==200, each shape (64,2), shot_mask shape==(200,),
|
|
2399
|
+
sum(shot_mask)==ceil(200/4)==50.
|
|
2400
|
+
5. Save to /tmp/kspace_test.npz and reload — verify round-trip shape.
|
|
2401
|
+
6. (Optional) Swap to TorchBackend: backend = TorchBackend(device='cpu')
|
|
2402
|
+
and rerun; all returned objects will be torch.Tensor.
|
|
2403
|
+
|
|
2404
|
+
To swap to TorchBackend verbally:
|
|
2405
|
+
Replace NumpyBackend() with TorchBackend() (or TorchBackend(device='cuda')).
|
|
2406
|
+
All coordinate arrays and the shot_mask will be torch.Tensor objects
|
|
2407
|
+
on the specified device, fully compatible with NUFFT libraries like
|
|
2408
|
+
torchkbnufft or sigpy.
|
|
2409
|
+
"""
|
|
2410
|
+
print("=" * 60)
|
|
2411
|
+
print("kspace_sampling self-test")
|
|
2412
|
+
print("=" * 60)
|
|
2413
|
+
|
|
2414
|
+
# ----- Synthetic NIfTI-MRS header -----
|
|
2415
|
+
header = {
|
|
2416
|
+
"dim": [4, 64, 64, 1, 1, 1, 1, 1], # dim[1..3] = 64×64×1
|
|
2417
|
+
"pixdim": [1.0, 3.0, 3.0, 3.0, 1.0, 1.0, 1.0, 1.0], # pixdim[1..3] = 3×3×3 mm
|
|
2418
|
+
"DwellTime": 1e-4, # 100 µs dwell
|
|
2419
|
+
"SpectrometerFrequency": [127.74e6], # 3T 1H
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
backend = NumpyBackend()
|
|
2423
|
+
print(f"Backend: {backend}")
|
|
2424
|
+
|
|
2425
|
+
# ----- Geometry parsing -----
|
|
2426
|
+
geom = read_header_geometry(header)
|
|
2427
|
+
print(f" Geometry: fov_mm={geom['fov_mm']}, matrix={geom['matrix']}, "
|
|
2428
|
+
f"voxel_mm={geom['voxel_mm']}, ndim={geom['ndim']}")
|
|
2429
|
+
|
|
2430
|
+
# ----- Trajectory generation -----
|
|
2431
|
+
traj_params = {"n_shots": 200, "samples_per_shot": 64, "center_out": True}
|
|
2432
|
+
shots, mask, meta = get_kspace_shots_and_mask(
|
|
2433
|
+
header, "golden_radial_2d", "prefix", AF=4.0,
|
|
2434
|
+
traj_params=traj_params, us_params={}, backend=backend,
|
|
2435
|
+
)
|
|
2436
|
+
|
|
2437
|
+
n_shots = len(shots)
|
|
2438
|
+
sps = backend.shape(shots[0])[0]
|
|
2439
|
+
ndims = backend.shape(shots[0])[1]
|
|
2440
|
+
mask_np = backend.to_cpu(mask).astype(bool)
|
|
2441
|
+
n_retained = int(mask_np.sum())
|
|
2442
|
+
expected_retained = math.ceil(n_shots / 4.0)
|
|
2443
|
+
|
|
2444
|
+
print(f" n_shots: {n_shots} (expected 200)")
|
|
2445
|
+
print(f" samples_per_shot: {sps} (expected 64)")
|
|
2446
|
+
print(f" Ndims: {ndims} (expected 2)")
|
|
2447
|
+
print(f" shot_mask shape: {mask_np.shape} (expected (200,))")
|
|
2448
|
+
print(f" n_retained: {n_retained} (expected {expected_retained})")
|
|
2449
|
+
print(f" kmax: {meta['kmax']}")
|
|
2450
|
+
print(f" trajectory_type: {meta['trajectory_type']}")
|
|
2451
|
+
|
|
2452
|
+
assert n_shots == 200, f"Expected 200 shots, got {n_shots}"
|
|
2453
|
+
assert sps == 64, f"Expected 64 samples/shot, got {sps}"
|
|
2454
|
+
assert ndims == 2, f"Expected 2D, got Ndims={ndims}"
|
|
2455
|
+
assert mask_np.shape == (200,), f"shot_mask shape mismatch: {mask_np.shape}"
|
|
2456
|
+
assert n_retained == expected_retained, \
|
|
2457
|
+
f"n_retained={n_retained} != expected {expected_retained}"
|
|
2458
|
+
|
|
2459
|
+
# ----- Round-trip .npz -----
|
|
2460
|
+
import tempfile, os
|
|
2461
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
2462
|
+
fpath = os.path.join(tmpdir, "kspace_test.npz")
|
|
2463
|
+
save_shots_npz(fpath, shots, mask, meta, backend)
|
|
2464
|
+
shots2, mask2, meta2 = load_shots_npz(fpath, backend)
|
|
2465
|
+
assert len(shots2) == 200
|
|
2466
|
+
assert backend.shape(shots2[0]) == (64, 2)
|
|
2467
|
+
assert backend.to_cpu(mask2).astype(bool).sum() == n_retained
|
|
2468
|
+
print(f" Round-trip .npz: OK (loaded {len(shots2)} shots)")
|
|
2469
|
+
|
|
2470
|
+
# ----- 3D phyllotaxis quick smoke test -----
|
|
2471
|
+
header3d = {
|
|
2472
|
+
"dim": [4, 32, 32, 32, 1, 1, 1, 1],
|
|
2473
|
+
"pixdim": [1.0, 5.0, 5.0, 5.0, 1.0, 1.0, 1.0, 1.0],
|
|
2474
|
+
"DwellTime": 1e-4,
|
|
2475
|
+
}
|
|
2476
|
+
shots3d, mask3d, meta3d = get_kspace_shots_and_mask(
|
|
2477
|
+
header3d, "3d_phyllotaxis", "prefix", AF=2.0,
|
|
2478
|
+
traj_params={"n_shots": 100, "samples_per_shot": 32},
|
|
2479
|
+
us_params={}, backend=backend,
|
|
2480
|
+
)
|
|
2481
|
+
assert backend.shape(shots3d[0])[1] == 3, "Expected 3D coordinates"
|
|
2482
|
+
print(f" 3D phyllotaxis: {len(shots3d)} shots, "
|
|
2483
|
+
f"shape per shot: {backend.shape(shots3d[0])}, OK")
|
|
2484
|
+
|
|
2485
|
+
print("All self-tests PASSED.")
|
|
2486
|
+
print("=" * 60)
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
# ---------------------------------------------------------------------------
|
|
2490
|
+
# Usage example (plain text at module end)
|
|
2491
|
+
# ---------------------------------------------------------------------------
|
|
2492
|
+
#
|
|
2493
|
+
# USAGE EXAMPLE
|
|
2494
|
+
# =============
|
|
2495
|
+
#
|
|
2496
|
+
# Expected header dict keys (NIfTI-MRS standard + common fallbacks):
|
|
2497
|
+
# ------------------------------------------------------------------
|
|
2498
|
+
# Required (standard):
|
|
2499
|
+
# "dim" : list[int], dim[1..3] = [nx, ny, nz]
|
|
2500
|
+
# "pixdim" : list[float], pixdim[1..3] = [vx, vy, vz] in mm
|
|
2501
|
+
# Optional standard fields:
|
|
2502
|
+
# "SpectrometerFrequency": [float] — transmitter frequency in Hz
|
|
2503
|
+
# "DwellTime" : float — ADC dwell time in seconds
|
|
2504
|
+
# "qform_code" : int — qform validity flag
|
|
2505
|
+
# "sform_code" : int — sform validity flag
|
|
2506
|
+
# Fallback (non-standard, accepted if standard absent):
|
|
2507
|
+
# "matrix" : [nx, ny, nz]
|
|
2508
|
+
# "voxel_size_mm": [vx, vy, vz]
|
|
2509
|
+
# "fov_mm" : [fx, fy, fz]
|
|
2510
|
+
# "dwell_time" : float
|
|
2511
|
+
#
|
|
2512
|
+
# Calling get_kspace_shots_and_mask:
|
|
2513
|
+
# -----------------------------------
|
|
2514
|
+
# from kspace_sampling import get_kspace_shots_and_mask, NumpyBackend, TorchBackend
|
|
2515
|
+
#
|
|
2516
|
+
# header = {
|
|
2517
|
+
# "dim": [4, 128, 128, 1, 1, 1, 1, 1],
|
|
2518
|
+
# "pixdim": [1.0, 2.5, 2.5, 2.5, 1.0, 1.0, 1.0, 1.0],
|
|
2519
|
+
# "DwellTime": 4e-6,
|
|
2520
|
+
# "SpectrometerFrequency": [297.2e6],
|
|
2521
|
+
# }
|
|
2522
|
+
#
|
|
2523
|
+
# # NumPy backend
|
|
2524
|
+
# backend = NumpyBackend()
|
|
2525
|
+
#
|
|
2526
|
+
# coords_per_shot, shot_mask, meta = get_kspace_shots_and_mask(
|
|
2527
|
+
# header = header,
|
|
2528
|
+
# trajectory = "golden_radial_2d",
|
|
2529
|
+
# undersampling = "prefix",
|
|
2530
|
+
# AF = 4.0,
|
|
2531
|
+
# traj_params = {"n_shots": 400, "samples_per_shot": 128},
|
|
2532
|
+
# us_params = {},
|
|
2533
|
+
# backend = backend,
|
|
2534
|
+
# )
|
|
2535
|
+
#
|
|
2536
|
+
# # Outputs:
|
|
2537
|
+
# # coords_per_shot : list of 400 numpy arrays, each shape (128, 2)
|
|
2538
|
+
# # coordinates in cycles/m, kx and ky columns
|
|
2539
|
+
# # shot_mask : numpy bool array shape (400,), 100 True entries (AF=4)
|
|
2540
|
+
# # meta : dict with keys:
|
|
2541
|
+
# # trajectory_type="golden_radial_2d", kmax=(kmax_x, kmax_y),
|
|
2542
|
+
# # fov_mm=(320.0, 320.0), matrix=(128,128),
|
|
2543
|
+
# # n_shots=400, n_shots_retained=100, achieved_AF≈4.0,
|
|
2544
|
+
# # density_estimate={...placeholder...}, params_used={...}
|
|
2545
|
+
#
|
|
2546
|
+
# # Select retained shots:
|
|
2547
|
+
# retained = [s for s, m in zip(coords_per_shot, shot_mask) if m]
|
|
2548
|
+
#
|
|
2549
|
+
# # Switch to PyTorch (CUDA):
|
|
2550
|
+
# backend_gpu = TorchBackend(device="cuda")
|
|
2551
|
+
# coords_pt, mask_pt, meta_pt = get_kspace_shots_and_mask(
|
|
2552
|
+
# header, "golden_radial_2d", "prefix", AF=4.0,
|
|
2553
|
+
# traj_params={"n_shots": 400, "samples_per_shot": 128},
|
|
2554
|
+
# us_params={}, backend=backend_gpu,
|
|
2555
|
+
# )
|
|
2556
|
+
# # coords_pt[0] is a torch.Tensor on cuda, shape (128, 2)
|
|
2557
|
+
# # mask_pt is a torch.BoolTensor on cuda, shape (400,)
|
|
2558
|
+
#
|
|
2559
|
+
# ---------------------------------------------------------------------------
|
|
2560
|
+
|
|
2561
|
+
if __name__ == "__main__":
|
|
2562
|
+
_run_selftest()
|