MNutils 1.2.0__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.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Realistic-ish spectra and spatial phantoms for the synthetic exam fixtures.
|
|
2
|
+
|
|
3
|
+
Spectra come from `xmris.simulate_fid` (real AMARES-formulation peak simulation), not noise, with
|
|
4
|
+
three peaks always present -- HDO, Glucose, Glx -- at roughly their real chemical shifts. Spatial
|
|
5
|
+
realism comes from two sources: a downloaded real T1 template for brain-shaped anatomy (see
|
|
6
|
+
`_TEMPLATE_ATTRIBUTION`), and a synthetic ring-of-spheres phantom for the NIST-style dataset, where
|
|
7
|
+
each sphere gets a different, increasing signal intensity and that same intensity drives the
|
|
8
|
+
spectra simulated at its location.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import tempfile
|
|
13
|
+
import urllib.request
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import nibabel as nib
|
|
18
|
+
import numpy as np
|
|
19
|
+
import xarray as xr
|
|
20
|
+
import xmris
|
|
21
|
+
from loguru import logger
|
|
22
|
+
from numpy.typing import ArrayLike
|
|
23
|
+
|
|
24
|
+
_TEMPLATE_URL = "https://raw.githubusercontent.com/neurolabusc/niivue-images/main/chris_t1.nii.gz"
|
|
25
|
+
_TEMPLATE_ATTRIBUTION = (
|
|
26
|
+
"T1 anatomical fixtures are resampled from Chris Rorden's 'chris_t1', part of the "
|
|
27
|
+
"niivue-images sample set (https://github.com/neurolabusc/niivue-images), licensed "
|
|
28
|
+
"CC BY-NC 4.0 (https://creativecommons.org/licenses/by-nc/4.0/) -- non-commercial use only."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Deuterium (2H) chemical shifts track their 1H counterparts (chemical shift is a property of the
|
|
32
|
+
# electronic environment, not the nucleus). HDO dominates real 2H-MRSI spectra by roughly an order
|
|
33
|
+
# of magnitude over the metabolite peaks -- these ratios, and the per-peak linewidths, are
|
|
34
|
+
# illustrative, not measured. `Baseline` is a broad, low-amplitude component standing in for the
|
|
35
|
+
# macromolecular/lipid background every real spectrum sits on top of.
|
|
36
|
+
# name: (relative amplitude, chemical shift [ppm], linewidth [Hz])
|
|
37
|
+
DMI_TRUTH: dict[str, tuple[float, float, float]] = {
|
|
38
|
+
"HDO": (1.0, 4.70, 12.0),
|
|
39
|
+
"Glucose": (0.25, 3.80, 12.0),
|
|
40
|
+
"Glx": (0.18, 2.40, 12.0),
|
|
41
|
+
"Baseline": (1.0, 1.9, 70.0),
|
|
42
|
+
}
|
|
43
|
+
PEAKS_PPM: dict[str, float] = {name: ppm for name, (_, ppm, _) in DMI_TRUTH.items()}
|
|
44
|
+
PEAK_REL_AMPLITUDE: dict[str, float] = {name: amp for name, (amp, _, _) in DMI_TRUTH.items()}
|
|
45
|
+
|
|
46
|
+
FREQ_2H_MHz_3T = 19.61
|
|
47
|
+
CARRIER_PPM = 4.68
|
|
48
|
+
_BASE_AMPLITUDE = 40.0
|
|
49
|
+
_BASE_SNR_UNLOC = 50 # target SNR per simulated spectrum, unlocalized MRS
|
|
50
|
+
_BASE_SNR_MRSI = 30 # MRSI voxels are effectively single-transient -- no sqrt(N) boost
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def simulate_fid(
|
|
54
|
+
*,
|
|
55
|
+
npts: int,
|
|
56
|
+
bandwidth: float,
|
|
57
|
+
centre_freq_mhz: float = FREQ_2H_MHz_3T,
|
|
58
|
+
carrier_ppm: float = CARRIER_PPM,
|
|
59
|
+
b0_ppm: float = 0.0,
|
|
60
|
+
phase_deg: float = 0.0,
|
|
61
|
+
base_snr: float = _BASE_SNR_UNLOC,
|
|
62
|
+
intensities: ArrayLike | None = None,
|
|
63
|
+
seed: int,
|
|
64
|
+
n_specs: int = 1,
|
|
65
|
+
) -> xr.DataArray:
|
|
66
|
+
"""`n_specs` simulated FIDs (HDO/Glucose/Glx/Baseline), time-domain, via `xmris.simulate_fid`.
|
|
67
|
+
|
|
68
|
+
Returns the raw `xr.DataArray` -- callers convert it to whatever they need: `xmris.to_spectrum`
|
|
69
|
+
for the frequency domain, `.values`/`np.asarray` for a plain numpy array. This lets one
|
|
70
|
+
simulated dataset back both a `.mat` spectrum and a raw-FID fixture (same peaks, same noise)
|
|
71
|
+
instead of two independently-random ones. `intensities` scales each peak's amplitude, per
|
|
72
|
+
spectrum -- either one `(n_peaks,)` vector broadcast across all `n_specs`, or a `(n_specs,
|
|
73
|
+
n_peaks)` array for per-spectrum control (e.g. a washin series ramping Glucose/Glx over time).
|
|
74
|
+
`b0_ppm`/`phase_deg` add a uniform chemical-shift offset / zero-order phase across all peaks,
|
|
75
|
+
matching real per-repetition/per-voxel B0 and phase drift.
|
|
76
|
+
"""
|
|
77
|
+
names = list(DMI_TRUTH)
|
|
78
|
+
amps = np.asarray([_BASE_AMPLITUDE * DMI_TRUTH[name][0] for name in names], dtype=np.float32)
|
|
79
|
+
chemical_shifts = np.asarray([DMI_TRUTH[name][1] + b0_ppm for name in names], dtype=np.float32)
|
|
80
|
+
dampings = np.asarray([np.pi * DMI_TRUTH[name][2] for name in names], dtype=np.float32)
|
|
81
|
+
phases = np.deg2rad(phase_deg)
|
|
82
|
+
|
|
83
|
+
if intensities is None:
|
|
84
|
+
intensities = np.ones(len(names))
|
|
85
|
+
intensities = np.atleast_1d(intensities).astype(np.float32)
|
|
86
|
+
|
|
87
|
+
# Check if the number of intensities matches the number of spectra to simulate
|
|
88
|
+
if intensities.ndim == 1:
|
|
89
|
+
intensities = np.broadcast_to(intensities, (n_specs, len(names)))
|
|
90
|
+
elif intensities.ndim == 2:
|
|
91
|
+
if intensities.shape[0] != n_specs or intensities.shape[1] != len(names):
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"intensities shape {intensities.shape} does not match n_specs={n_specs} and "
|
|
94
|
+
f"number of peaks={len(names)}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
fid = xr.concat(
|
|
98
|
+
[
|
|
99
|
+
xmris.simulate_fid(
|
|
100
|
+
amplitudes=amps * intensities[i],
|
|
101
|
+
chemical_shifts=chemical_shifts,
|
|
102
|
+
reference_frequency=centre_freq_mhz,
|
|
103
|
+
carrier_ppm=carrier_ppm,
|
|
104
|
+
spectral_width=bandwidth,
|
|
105
|
+
n_points=npts,
|
|
106
|
+
dampings=dampings,
|
|
107
|
+
phases=phases,
|
|
108
|
+
target_snr=base_snr,
|
|
109
|
+
seed=seed + i, # a shared seed across i would give every voxel identical noise
|
|
110
|
+
)
|
|
111
|
+
for i in range(n_specs)
|
|
112
|
+
],
|
|
113
|
+
dim="voxel",
|
|
114
|
+
).assign_coords(voxel=np.arange(n_specs))
|
|
115
|
+
fid.attrs = {"reference_frequency": centre_freq_mhz, "carrier_ppm": carrier_ppm}
|
|
116
|
+
return fid
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def simulate_grid(
|
|
120
|
+
*,
|
|
121
|
+
npts: int,
|
|
122
|
+
grid: tuple[int, int, int],
|
|
123
|
+
bandwidth: float,
|
|
124
|
+
centre_freq_mhz: float = FREQ_2H_MHz_3T,
|
|
125
|
+
carrier_ppm: float = CARRIER_PPM,
|
|
126
|
+
intensity_map: np.ndarray,
|
|
127
|
+
seed: int,
|
|
128
|
+
) -> np.ndarray:
|
|
129
|
+
"""`(npts, i, j, k)` grid of spectra, one per cell, scaled by `intensity_map` (same shape).
|
|
130
|
+
|
|
131
|
+
Every cell gets its own unscaled spectrum from `simulate_fid`, then `intensity_map` scales
|
|
132
|
+
each cell's signal post-FFT. MRSI voxels are effectively single-transient, so `_BASE_SNR_MRSI`
|
|
133
|
+
(no `sqrt(N)` boost) sets the noise floor.
|
|
134
|
+
"""
|
|
135
|
+
n_cells = int(np.prod(grid))
|
|
136
|
+
|
|
137
|
+
# Simulate all FIDs, then convert to the frequency domain
|
|
138
|
+
fid = simulate_fid(
|
|
139
|
+
npts=npts,
|
|
140
|
+
bandwidth=bandwidth,
|
|
141
|
+
centre_freq_mhz=centre_freq_mhz,
|
|
142
|
+
carrier_ppm=carrier_ppm,
|
|
143
|
+
base_snr=_BASE_SNR_MRSI,
|
|
144
|
+
seed=seed,
|
|
145
|
+
n_specs=n_cells,
|
|
146
|
+
)
|
|
147
|
+
# xmris.to_spectrum returns (voxel, frequency) -- transpose to (frequency, voxel) before
|
|
148
|
+
# reshaping, or the flat buffer scrambles frequency bins across voxels.
|
|
149
|
+
specs_flat = np.asarray(xmris.to_spectrum(fid).values, dtype=complex).T
|
|
150
|
+
|
|
151
|
+
# Reshape to (npts, i, j, k) and apply intensity map
|
|
152
|
+
specs = specs_flat.reshape((npts, *grid))
|
|
153
|
+
specs *= intensity_map.reshape((1, *grid))
|
|
154
|
+
return specs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def grid_world_centers(
|
|
158
|
+
grid: tuple[int, int, int], voxel_size: tuple[float, float, float]
|
|
159
|
+
) -> np.ndarray:
|
|
160
|
+
"""World-mm center of every grid cell, `(i, j, k, 3)`, under a `-shape*voxel/2` origin.
|
|
161
|
+
|
|
162
|
+
This is the same convention `grid_own_affine`/`sphere_phantom_image` use for anatomical
|
|
163
|
+
volumes, and matches `MRSISeries.create_MRSI_affine()` in x/y to within half a voxel when the
|
|
164
|
+
MRSI series' own NIfTI is built with matching shape/voxel_size (see `grid_own_affine`) --
|
|
165
|
+
comfortably inside a sphere's radius, so sphere placement lines up between the anatomical image
|
|
166
|
+
and the MRSI grid without needing to replicate that method's exact half-voxel shift.
|
|
167
|
+
"""
|
|
168
|
+
idx = np.indices(grid).astype(np.float64) # (3, i, j, k)
|
|
169
|
+
dims = np.array(grid, dtype=np.float64).reshape(3, 1, 1, 1)
|
|
170
|
+
vox = np.array(voxel_size, dtype=np.float64).reshape(3, 1, 1, 1)
|
|
171
|
+
world = vox * (idx - dims / 2)
|
|
172
|
+
return np.moveaxis(world, 0, -1)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def grid_own_affine(
|
|
176
|
+
grid: tuple[int, int, int], voxel_size: tuple[float, float, float]
|
|
177
|
+
) -> np.ndarray:
|
|
178
|
+
"""The `-shape*voxel/2`-centered affine for an image shaped exactly like `grid`."""
|
|
179
|
+
affine = np.diag([*voxel_size, 1.0])
|
|
180
|
+
affine[:3, 3] = -np.array(grid) * np.array(voxel_size) / 2
|
|
181
|
+
return affine
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- Sphere-ring phantom (NIST-style) -----------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class SphereRing:
|
|
189
|
+
"""One ring of spheres in the x/y plane, offset along z, each with its own signal intensity.
|
|
190
|
+
|
|
191
|
+
Add another metabolite range by appending a second `SphereRing` (different `peaks_ppm`/
|
|
192
|
+
`peak_rel_amplitude`) to the list passed to `sphere_intensity`/`sphere_phantom_image` -- rings
|
|
193
|
+
are independent and just take the max where they'd overlap.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
name: str
|
|
197
|
+
n_spheres: int = 8
|
|
198
|
+
ring_radius_mm: float = 60.0
|
|
199
|
+
z_offset_mm: float = 20.0
|
|
200
|
+
sphere_radius_mm: float = 10.0
|
|
201
|
+
intensity_range: tuple[float, float] = (0.3, 1.0)
|
|
202
|
+
peaks_ppm: dict[str, float] = field(default_factory=lambda: dict(PEAKS_PPM))
|
|
203
|
+
peak_rel_amplitude: dict[str, float] = field(default_factory=lambda: dict(PEAK_REL_AMPLITUDE))
|
|
204
|
+
|
|
205
|
+
def spheres(self) -> list[tuple[tuple[float, float, float], float]]:
|
|
206
|
+
"""`[(center_xyz_mm, intensity), ...]`, intensity strictly increasing around the ring."""
|
|
207
|
+
intensities = np.linspace(*self.intensity_range, self.n_spheres)
|
|
208
|
+
out = []
|
|
209
|
+
for k in range(self.n_spheres):
|
|
210
|
+
theta = 2 * np.pi * k / self.n_spheres
|
|
211
|
+
center = (
|
|
212
|
+
self.ring_radius_mm * np.cos(theta),
|
|
213
|
+
self.ring_radius_mm * np.sin(theta),
|
|
214
|
+
self.z_offset_mm,
|
|
215
|
+
)
|
|
216
|
+
out.append((center, float(intensities[k])))
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
DEFAULT_SPHERE_RINGS: list[SphereRing] = [SphereRing(name="metabolite_ring")]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def sphere_intensity(world_xyz: np.ndarray, rings: list[SphereRing]) -> np.ndarray:
|
|
224
|
+
"""Per-position intensity in `[0, 1]`, `world_xyz` shaped `(..., 3)` -- 0 outside spheres."""
|
|
225
|
+
intensity = np.zeros(world_xyz.shape[:-1], dtype=np.float32)
|
|
226
|
+
for ring in rings:
|
|
227
|
+
for center, amp in ring.spheres():
|
|
228
|
+
dist = np.linalg.norm(world_xyz - np.array(center), axis=-1)
|
|
229
|
+
intensity = np.maximum(intensity, np.where(dist <= ring.sphere_radius_mm, amp, 0.0))
|
|
230
|
+
return intensity
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def sphere_phantom_image(
|
|
234
|
+
path: Path, *, shape: tuple[int, int, int], voxel_mm: float, rings: list[SphereRing], seed: int
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Write a fine-resolution anatomical NIfTI showing the sphere ring as bright regions."""
|
|
237
|
+
world = grid_world_centers(shape, (voxel_mm, voxel_mm, voxel_mm))
|
|
238
|
+
intensity = sphere_intensity(world, rings)
|
|
239
|
+
background = np.random.default_rng(seed).random(shape).astype(np.float32) * 5.0
|
|
240
|
+
data = (background + intensity * 200.0).astype(np.float32)
|
|
241
|
+
affine = grid_own_affine(shape, (voxel_mm, voxel_mm, voxel_mm))
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
nib.save(nib.Nifti1Image(data, affine), path)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def sphere_grid_intensity(
|
|
247
|
+
grid: tuple[int, int, int], *, fov_mm: float, z_extent_mm: float, rings: list[SphereRing]
|
|
248
|
+
) -> tuple[np.ndarray, float]:
|
|
249
|
+
"""Intensity map over an MRSI grid plus the x/y voxel size (`fov_mm / grid[0]`) it implies."""
|
|
250
|
+
voxel_xy = fov_mm / grid[0]
|
|
251
|
+
voxel_z = z_extent_mm / grid[2]
|
|
252
|
+
world = grid_world_centers(grid, (voxel_xy, voxel_xy, voxel_z))
|
|
253
|
+
return sphere_intensity(world, rings), voxel_xy
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# --- Real T1 template (brain-shaped anatomy) ----------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _template_cache_path() -> Path:
|
|
260
|
+
return Path(tempfile.gettempdir()) / "mnutils_template_cache" / "chris_t1.nii.gz"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _fetch_t1_template_path() -> Path:
|
|
264
|
+
"""Download the CC BY-NC 4.0 template once per machine, cached under the system temp dir."""
|
|
265
|
+
cache = _template_cache_path()
|
|
266
|
+
if not cache.is_file():
|
|
267
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
logger.info(f"Downloading T1 template fixture from {_TEMPLATE_URL} to {cache}")
|
|
269
|
+
with urllib.request.urlopen(_TEMPLATE_URL, timeout=30) as response: # noqa: S310
|
|
270
|
+
cache.write_bytes(response.read())
|
|
271
|
+
return cache
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@functools.cache
|
|
275
|
+
def _cropped_template() -> tuple[np.ndarray, np.ndarray]:
|
|
276
|
+
"""The template's data cropped to its non-background bounding box, and the matching affine."""
|
|
277
|
+
img = nib.load(_fetch_t1_template_path())
|
|
278
|
+
data = np.asarray(img.get_fdata(), dtype=np.float32)
|
|
279
|
+
mask = data > (data.max() * 0.05)
|
|
280
|
+
idx = np.array(np.where(mask))
|
|
281
|
+
lo, hi = idx.min(axis=1), idx.max(axis=1) + 1
|
|
282
|
+
cropped = data[lo[0] : hi[0], lo[1] : hi[1], lo[2] : hi[2]]
|
|
283
|
+
affine = img.affine.copy()
|
|
284
|
+
affine[:3, 3] = affine[:3, :3] @ lo + affine[:3, 3]
|
|
285
|
+
return cropped, affine
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def write_template_t1(path: Path) -> None:
|
|
289
|
+
"""Write the cropped, full-resolution template as an anatomical NIfTI."""
|
|
290
|
+
data, affine = _cropped_template()
|
|
291
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
292
|
+
nib.save(nib.Nifti1Image(data, affine), path)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def template_grid_intensity(grid: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]:
|
|
296
|
+
"""Downsample the template onto `grid`; returns `(own_affine, intensity_map in [0, 1])`.
|
|
297
|
+
|
|
298
|
+
`own_affine` is what an MRSI series' own NIfTI (the one `create_MRSI_nii` derives its affine
|
|
299
|
+
from) should carry so the low-res grid sits inside the real brain's footprint; `intensity_map`
|
|
300
|
+
is that same downsampled data, normalized, and is meant to drive `simulate_grid`'s
|
|
301
|
+
`intensity_map` directly -- real brain signal in, brain-shaped spectra out.
|
|
302
|
+
"""
|
|
303
|
+
from scipy.ndimage import zoom
|
|
304
|
+
|
|
305
|
+
data, affine = _cropped_template()
|
|
306
|
+
voxel_native = np.diag(affine)[:3]
|
|
307
|
+
physical_extent = np.array(data.shape) * voxel_native
|
|
308
|
+
voxel_size = physical_extent / np.array(grid)
|
|
309
|
+
|
|
310
|
+
# grid_mode=True samples voxel *centers* under the same uniform physical_extent/grid stride
|
|
311
|
+
# own_affine assumes below -- the default corner-aligned mapping (native index 0 -> low-res
|
|
312
|
+
# index 0, native's *last* index -> low-res's last index) stretches across a shorter span than
|
|
313
|
+
# physical_extent implies, growing to a full blocky-voxel misalignment at the far edge.
|
|
314
|
+
zoom_factors = np.array(grid) / np.array(data.shape)
|
|
315
|
+
low_res = zoom(data, zoom_factors, order=1, grid_mode=True, mode="nearest")
|
|
316
|
+
if low_res.shape != tuple(grid):
|
|
317
|
+
padded = np.zeros(grid, dtype=np.float32)
|
|
318
|
+
sl = tuple(slice(0, min(a, b)) for a, b in zip(low_res.shape, grid, strict=True))
|
|
319
|
+
padded[sl] = low_res[sl]
|
|
320
|
+
low_res = padded
|
|
321
|
+
|
|
322
|
+
# `simulate_grid` scales each cell's whole complex spectrum (signal *and* noise) by this map,
|
|
323
|
+
# so a hard 0.0 outside the head would silence the noise floor there too -- a background voxel
|
|
324
|
+
# would come back a perfectly flat, noiseless zero instead of a real acquisition's noise-only
|
|
325
|
+
# spectrum. Floor it so background stays low-signal but still noisy.
|
|
326
|
+
intensity_map = (low_res / (low_res.max() + 1e-6)).astype(np.float32)
|
|
327
|
+
intensity_map = np.maximum(intensity_map, 0.0)
|
|
328
|
+
own_affine = np.diag([*voxel_size, 1.0])
|
|
329
|
+
own_affine[:3, 3] = affine[:3, 3]
|
|
330
|
+
# MRSISeries.create_MRSI_affine() always adds an extra half-*blocky*-voxel shift in x/y on top
|
|
331
|
+
# of its resize shift (tuned for real GE headers, where the "fine" reference affine needs it).
|
|
332
|
+
# For grid_mode=True zoom, blocky voxel i's true center is:
|
|
333
|
+
# native_translation + blocky_voxel*(i + 0.5) - native_voxel/2
|
|
334
|
+
# i.e. own_affine's translation should be native_translation - native_voxel/2 to land there once
|
|
335
|
+
# create_MRSI_affine's own +blocky_voxel/2 shift is added back on top -- the blocky_voxel terms
|
|
336
|
+
# cancel exactly, so the correction here is half a *native* voxel, not half the blocky one.
|
|
337
|
+
own_affine[:2, 3] -= voxel_native[:2] / 2
|
|
338
|
+
return own_affine, intensity_map
|