MNutils 1.2.2__tar.gz → 1.2.3__tar.gz
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-1.2.2 → mnutils-1.2.3}/PKG-INFO +1 -1
- {mnutils-1.2.2 → mnutils-1.2.3}/pyproject.toml +2 -1
- {mnutils-1.2.2 → mnutils-1.2.3}/pyproject.toml.orig +2 -1
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/GESeries.py +17 -12
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/image_grid.js +5 -1
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/images.py +39 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/testing/_spectra.py +88 -30
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/testing/synthetic.py +12 -4
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/file_helpers.py +6 -4
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/nifti.py +69 -21
- {mnutils-1.2.2 → mnutils-1.2.3}/LICENSE +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/README.md +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/GEExam.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/_scripts.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/fitting/AMARES.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/fitting/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/matlab/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/matlab/setup_matlab.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_html.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/image_grid.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/image_grid.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/saving.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/spectra.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/py.typed +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/testing/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/data_loaders.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/images.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/partial_volume.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/spectra.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "MNutils"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.3"
|
|
4
4
|
description = "Utility functions for multinuclear MR data processing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -69,6 +69,7 @@ build-backend = "uv_build"
|
|
|
69
69
|
dev = [
|
|
70
70
|
"ipykernel>=7.1.0",
|
|
71
71
|
"ipywidgets>=8.1.7",
|
|
72
|
+
"python-dotenv",
|
|
72
73
|
"nbmake>=1.5.5",
|
|
73
74
|
"pytest>=8.4.1",
|
|
74
75
|
"ruff>=0.9.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "MNutils"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.3"
|
|
4
4
|
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Sebastian Bauer", email = "28663646+bastigw@users.noreply.github.com"}
|
|
@@ -65,6 +65,7 @@ dev = [
|
|
|
65
65
|
# --- Notebooks ---
|
|
66
66
|
"ipykernel>=7.1.0",
|
|
67
67
|
"ipywidgets>=8.1.7",
|
|
68
|
+
"python-dotenv",
|
|
68
69
|
# --- Testing ---
|
|
69
70
|
"nbmake>=1.5.5",
|
|
70
71
|
"pytest>=8.4.1",
|
|
@@ -5,7 +5,6 @@ from functools import cached_property
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
import h5py
|
|
8
|
-
import nibabel as nib
|
|
9
8
|
import numpy as np
|
|
10
9
|
import numpy.typing as npt
|
|
11
10
|
import pandas as pd
|
|
@@ -96,13 +95,16 @@ class NiiBase:
|
|
|
96
95
|
else:
|
|
97
96
|
affine = self.nii.affine
|
|
98
97
|
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
98
|
+
# new_data's axes already match the index space `affine` describes (e.g. spec's own
|
|
99
|
+
# (i, j, k) grid, scaled by create_MRSI_affine()) -- no reorientation is warranted here.
|
|
100
|
+
# A previous version of this method applied a hardcoded apply_orientation(..., ("P", "L",
|
|
101
|
+
# "S")) permute+flip unconditionally, silently decoupling the stored array from the affine
|
|
102
|
+
# it was paired with (e.g. RAW_exp's data ending up several voxels from where its own
|
|
103
|
+
# affine said it was).
|
|
104
|
+
image_data = np.asarray(new_data)
|
|
103
105
|
|
|
104
106
|
new_nii = self.nii.__class__(
|
|
105
|
-
|
|
107
|
+
image_data, # pyright: ignore[reportArgumentType]
|
|
106
108
|
affine=affine.copy(),
|
|
107
109
|
header=self.nii.header,
|
|
108
110
|
extra=self.nii.extra,
|
|
@@ -146,6 +148,10 @@ class NiiBase:
|
|
|
146
148
|
except ValueError as e:
|
|
147
149
|
logger.error(f"Error applying mask: {e}. Displaying unmasked images.")
|
|
148
150
|
|
|
151
|
+
kwargs.setdefault(
|
|
152
|
+
"zooms",
|
|
153
|
+
nifti.get_display_zooms(self, orientation=orientation, display_plane=display_plane),
|
|
154
|
+
)
|
|
149
155
|
plotting.images.display_images(images, **kwargs)
|
|
150
156
|
|
|
151
157
|
def overlay_on_T1(
|
|
@@ -745,13 +751,12 @@ class MRSISeries(RawMRISeries):
|
|
|
745
751
|
# Round voxel sizes to 3 decimal places to avoid floating point issues
|
|
746
752
|
new_affine[0:3, 0:3] = np.round(new_affine[0:3, 0:3], 3)
|
|
747
753
|
|
|
748
|
-
# Offset x and y by centre offset
|
|
749
|
-
#
|
|
750
|
-
#
|
|
754
|
+
# Offset x and y by centre offset.
|
|
755
|
+
# Both affines use the standard NIfTI convention (index i's *center* sits at
|
|
756
|
+
# world = voxel_size * i + translation), and both describe the same field of view at
|
|
757
|
+
# different resolutions, so their voxel-0 centers differ by exactly half the voxel-size
|
|
758
|
+
# change: new_translation = old_translation + (new_voxel - old_voxel) / 2.
|
|
751
759
|
shift = (np.diag(new_affine[:3]) - np.diag(self.nii.affine[:3])).round(3) / 2
|
|
752
|
-
# However this creates the correct FOV but the overlay is not in the correct position
|
|
753
|
-
# For a correct shift we need to add an additonal half voxel
|
|
754
|
-
shift += np.array([new_affine[0, 0], new_affine[1, 1], 0]) / 2
|
|
755
760
|
logger.trace(
|
|
756
761
|
f"Calculated shift for new affine: {shift}. "
|
|
757
762
|
f"Original affine translation: {self.nii.affine[:3, 3]}"
|
|
@@ -150,7 +150,11 @@ function renderWidget(data, el) {
|
|
|
150
150
|
|
|
151
151
|
// The frames' aspect ratio decides how wide the panels want to be, and so
|
|
152
152
|
// how wide the whole widget is allowed to grow (see --mnu-panel-w-cap).
|
|
153
|
-
// Only measurable once a frame has decoded, hence the load handler.
|
|
153
|
+
// Only measurable once a frame has decoded, hence the load handler. When
|
|
154
|
+
// `zooms` was passed to `display_images`, the frame itself was already
|
|
155
|
+
// rendered at a physically-correct pixel size (see image_grid.py /
|
|
156
|
+
// images.py), so naturalWidth/naturalHeight already *is* the physical
|
|
157
|
+
// ratio -- no separate correction needed here.
|
|
154
158
|
const syncAspect = () => {
|
|
155
159
|
const img = images.find((candidate) => candidate.naturalWidth);
|
|
156
160
|
if (img) viewer.style.setProperty("--mnu-aspect", String(img.naturalWidth / img.naturalHeight));
|
|
@@ -241,6 +241,7 @@ def display_images(
|
|
|
241
241
|
vmax: float | None = None,
|
|
242
242
|
v_percentile: float = 1.0,
|
|
243
243
|
zeros_as_nan: bool = False,
|
|
244
|
+
zooms: tuple[float, float] | None = None,
|
|
244
245
|
**kwargs,
|
|
245
246
|
) -> None:
|
|
246
247
|
"""Display a grid of 2D/3D/4D images, with a slice slider for 3D/4D input.
|
|
@@ -283,6 +284,13 @@ def display_images(
|
|
|
283
284
|
default 1.0.
|
|
284
285
|
zeros_as_nan : bool, optional
|
|
285
286
|
If True, treat zero values as NaN for display, by default False.
|
|
287
|
+
zooms : tuple[float, float], optional
|
|
288
|
+
Physical (row, col) voxel edge lengths of the displayed slice, e.g.
|
|
289
|
+
from `utils.nifti.get_display_zooms`. When given, panels are rendered
|
|
290
|
+
with an aspect ratio that matches real anatomy instead of one square
|
|
291
|
+
pixel per voxel; the through-plane spacing is irrelevant since only
|
|
292
|
+
one 2D slice is ever on screen at a time. Defaults to the pixel-count
|
|
293
|
+
aspect ratio (square voxels) when omitted.
|
|
286
294
|
**kwargs
|
|
287
295
|
Accepted and ignored: axis parameters such as `aspect`, `xlabel` or
|
|
288
296
|
`xticks` have no Axes to apply to. Kept so existing call sites keep
|
|
@@ -393,6 +401,7 @@ def display_images(
|
|
|
393
401
|
vmin=vmin,
|
|
394
402
|
vmax=vmax,
|
|
395
403
|
per_panel_bounds=colorbar_mode == "each",
|
|
404
|
+
pixel_aspect=None if zooms is None else zooms[1] / zooms[0],
|
|
396
405
|
)
|
|
397
406
|
ipy_display(
|
|
398
407
|
ImageGridWidget(
|
|
@@ -437,6 +446,26 @@ class _Layer(NamedTuple):
|
|
|
437
446
|
alpha: float = 1.0
|
|
438
447
|
|
|
439
448
|
|
|
449
|
+
def _stretch_to_pixel_aspect(array: npt.NDArray, pixel_aspect: float) -> npt.NDArray:
|
|
450
|
+
"""Nearest-neighbour-upscale one axis so the raster's own pixels are voxel-shaped.
|
|
451
|
+
|
|
452
|
+
`pixel_aspect` is physical col-spacing / row-spacing. Only ever upscales
|
|
453
|
+
-- the smaller-per-mm axis grows to match the other -- so no resolution is
|
|
454
|
+
lost on either axis, unlike scaling both to a common isotropic spacing.
|
|
455
|
+
Nearest-neighbour (voxel duplication) rather than interpolation: this is a
|
|
456
|
+
display-only raster, not the analysis data, and duplication is what keeps
|
|
457
|
+
a hard voxel edge hard instead of blurring it.
|
|
458
|
+
"""
|
|
459
|
+
rows, cols = array.shape[:2]
|
|
460
|
+
row_scale = max(1.0, 1.0 / pixel_aspect)
|
|
461
|
+
col_scale = max(1.0, pixel_aspect)
|
|
462
|
+
new_rows, new_cols = max(1, round(rows * row_scale)), max(1, round(cols * col_scale))
|
|
463
|
+
if (new_rows, new_cols) == (rows, cols):
|
|
464
|
+
return array
|
|
465
|
+
resized = PIL.Image.fromarray(array).resize((new_cols, new_rows), PIL.Image.NEAREST)
|
|
466
|
+
return np.asarray(resized)
|
|
467
|
+
|
|
468
|
+
|
|
440
469
|
def _render_frames(
|
|
441
470
|
layers: Sequence[_Layer],
|
|
442
471
|
jobs: Sequence[tuple[int, ...]],
|
|
@@ -444,6 +473,7 @@ def _render_frames(
|
|
|
444
473
|
quality: int,
|
|
445
474
|
lossless: bool = False,
|
|
446
475
|
keep_alpha: bool = False,
|
|
476
|
+
pixel_aspect: float | None = None,
|
|
447
477
|
max_workers: int = 8,
|
|
448
478
|
) -> list[bytes]:
|
|
449
479
|
"""Render a batch of colormapped rasters to WebP bytes, one per job.
|
|
@@ -476,6 +506,11 @@ def _render_frames(
|
|
|
476
506
|
small hard-edged images, where the ringing shows and the payload argument
|
|
477
507
|
disappears. `keep_alpha` carries the colormap's "bad" pixels (NaN) through
|
|
478
508
|
as transparent instead of flattening them onto a baked-in background.
|
|
509
|
+
`pixel_aspect`, when given, upscales one axis of the composite (see
|
|
510
|
+
`_stretch_to_pixel_aspect`) so the frame's own dimensions already encode
|
|
511
|
+
the physical voxel aspect ratio -- the browser then needs no separate
|
|
512
|
+
aspect correction and stays on the same natural-size sizing path as the
|
|
513
|
+
unscaled case.
|
|
479
514
|
|
|
480
515
|
WebP encoding releases the GIL, so frames are encoded on a thread pool.
|
|
481
516
|
"""
|
|
@@ -510,6 +545,8 @@ def _render_frames(
|
|
|
510
545
|
) // 255
|
|
511
546
|
|
|
512
547
|
assert composite is not None, "at least one layer is required"
|
|
548
|
+
if pixel_aspect is not None:
|
|
549
|
+
composite = _stretch_to_pixel_aspect(composite, pixel_aspect)
|
|
513
550
|
if keep_alpha:
|
|
514
551
|
return _encode_webp(composite, "RGBA", quality=quality, lossless=lossless)
|
|
515
552
|
return _encode_webp(
|
|
@@ -541,6 +578,7 @@ def _render_image_grid_frames(
|
|
|
541
578
|
vmin: float | None,
|
|
542
579
|
vmax: float | None,
|
|
543
580
|
per_panel_bounds: bool,
|
|
581
|
+
pixel_aspect: float | None = None,
|
|
544
582
|
quality: int = GRID_FRAME_QUALITY,
|
|
545
583
|
max_workers: int = 8,
|
|
546
584
|
) -> tuple[list[list[bytes]], list[list[tuple[float, float]]]]:
|
|
@@ -579,6 +617,7 @@ def _render_image_grid_frames(
|
|
|
579
617
|
quality=quality,
|
|
580
618
|
lossless=images.shape[0] * images.shape[1] <= GRID_LOSSLESS_MAX_PIXELS,
|
|
581
619
|
keep_alpha=True,
|
|
620
|
+
pixel_aspect=pixel_aspect,
|
|
582
621
|
max_workers=max_workers,
|
|
583
622
|
)
|
|
584
623
|
return [rendered[s * num_images : (s + 1) * num_images] for s in range(n_slices)], bounds
|
|
@@ -22,9 +22,15 @@ from loguru import logger
|
|
|
22
22
|
from numpy.typing import ArrayLike
|
|
23
23
|
|
|
24
24
|
_TEMPLATE_URL = "https://raw.githubusercontent.com/neurolabusc/niivue-images/main/chris_t1.nii.gz"
|
|
25
|
+
# Same subject, same repo/license as _TEMPLATE_URL -- a real 2D-acquired proton-density scan, kept
|
|
26
|
+
# at its native (anisotropic) voxel size rather than resampled, so it's the fixture that actually
|
|
27
|
+
# exercises display_images' zooms aspect correction (issue #35). See write_template_pd.
|
|
28
|
+
_TEMPLATE_PD_URL = (
|
|
29
|
+
"https://raw.githubusercontent.com/neurolabusc/niivue-images/main/chris_PD.nii.gz"
|
|
30
|
+
)
|
|
25
31
|
_TEMPLATE_ATTRIBUTION = (
|
|
26
|
-
"T1 anatomical fixtures are resampled from Chris Rorden's 'chris_t1', part
|
|
27
|
-
"niivue-images sample set (https://github.com/neurolabusc/niivue-images), licensed "
|
|
32
|
+
"T1 and PD anatomical fixtures are resampled from Chris Rorden's 'chris_t1'/'chris_PD', part "
|
|
33
|
+
"of the niivue-images sample set (https://github.com/neurolabusc/niivue-images), licensed "
|
|
28
34
|
"CC BY-NC 4.0 (https://creativecommons.org/licenses/by-nc/4.0/) -- non-commercial use only."
|
|
29
35
|
)
|
|
30
36
|
|
|
@@ -184,13 +190,13 @@ def grid_own_affine(
|
|
|
184
190
|
def rescale_affine_xy(affine: np.ndarray, old_matrix_xy: int, new_matrix_xy: int) -> np.ndarray:
|
|
185
191
|
"""Same affine, x/y voxel size rescaled for a different in-plane matrix.
|
|
186
192
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
Both affines use the standard NIfTI convention (index `i`'s *center* sits at
|
|
194
|
+
`world = voxel_size * i + translation`) and describe the same field of view at different
|
|
195
|
+
resolutions, so their voxel-0 centers differ by exactly half the voxel-size change --
|
|
196
|
+
`new_translation = old_translation + (new_voxel - old_voxel) / 2`. That is also exactly what
|
|
197
|
+
`GESeries.MRSISeries.create_MRSI_affine()` does going the other way (fine -> blocky), so
|
|
198
|
+
`create_MRSI_affine()` applied to this function's output reproduces `affine`'s own
|
|
199
|
+
translation exactly, regardless of `affine`'s own resolution.
|
|
194
200
|
"""
|
|
195
201
|
scaled = affine.copy()
|
|
196
202
|
old_voxel = np.array([affine[0, 0], affine[1, 1]])
|
|
@@ -243,7 +249,7 @@ class SphereRing:
|
|
|
243
249
|
|
|
244
250
|
name: str
|
|
245
251
|
n_spheres: int = 8
|
|
246
|
-
ring_radius_mm: float =
|
|
252
|
+
ring_radius_mm: float = 40.0
|
|
247
253
|
z_offset_mm: float = 20.0
|
|
248
254
|
sphere_radius_mm: float = 10.0
|
|
249
255
|
intensity_range: tuple[float, float] = (0.3, 1.0)
|
|
@@ -265,7 +271,32 @@ class SphereRing:
|
|
|
265
271
|
return out
|
|
266
272
|
|
|
267
273
|
|
|
268
|
-
DEFAULT_SPHERE_RINGS: list[SphereRing] = [
|
|
274
|
+
DEFAULT_SPHERE_RINGS: list[SphereRing] = [
|
|
275
|
+
SphereRing(
|
|
276
|
+
name="metabolite_ring",
|
|
277
|
+
n_spheres=8,
|
|
278
|
+
ring_radius_mm=45.0,
|
|
279
|
+
z_offset_mm=5.0,
|
|
280
|
+
sphere_radius_mm=12.0,
|
|
281
|
+
intensity_range=(0.2, 1),
|
|
282
|
+
)
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
# A ring with no corresponding spectra -- `sphere_grid_intensity` never sees these, only
|
|
286
|
+
# `sphere_phantom_image` does, so they show up as extra anatomical structure with zero MRSI
|
|
287
|
+
# signal (a real phantom always has more visible structure than the spectroscopy sequence
|
|
288
|
+
# actually excites). Offset to the opposite side of the central slice from DEFAULT_SPHERE_RINGS
|
|
289
|
+
# so the two rings stay visually distinct despite both sitting near z=0.
|
|
290
|
+
ANATOMY_ONLY_SPHERE_RINGS: list[SphereRing] = [
|
|
291
|
+
SphereRing(
|
|
292
|
+
name="anatomy_only_ring",
|
|
293
|
+
n_spheres=6,
|
|
294
|
+
ring_radius_mm=10.0,
|
|
295
|
+
z_offset_mm=-5.0,
|
|
296
|
+
sphere_radius_mm=6.0,
|
|
297
|
+
intensity_range=(0.4, 0.9),
|
|
298
|
+
)
|
|
299
|
+
]
|
|
269
300
|
|
|
270
301
|
|
|
271
302
|
def sphere_intensity(world_xyz: np.ndarray, rings: list[SphereRing]) -> np.ndarray:
|
|
@@ -304,25 +335,19 @@ def sphere_grid_intensity(
|
|
|
304
335
|
# --- Real T1 template (brain-shaped anatomy) ----------------------------------------------------
|
|
305
336
|
|
|
306
337
|
|
|
307
|
-
def
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
def _fetch_t1_template_path() -> Path:
|
|
312
|
-
"""Download the CC BY-NC 4.0 template once per machine, cached under the system temp dir."""
|
|
313
|
-
cache = _template_cache_path()
|
|
338
|
+
def _download_and_cache(url: str, cache_name: str) -> Path:
|
|
339
|
+
"""Download `url` once per machine, cached under the system temp dir."""
|
|
340
|
+
cache = Path(tempfile.gettempdir()) / "mnutils_template_cache" / cache_name
|
|
314
341
|
if not cache.is_file():
|
|
315
342
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
316
|
-
logger.info(f"Downloading
|
|
317
|
-
with urllib.request.urlopen(
|
|
343
|
+
logger.info(f"Downloading template fixture from {url} to {cache}")
|
|
344
|
+
with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
|
|
318
345
|
cache.write_bytes(response.read())
|
|
319
346
|
return cache
|
|
320
347
|
|
|
321
348
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
"""The template's data cropped to its non-background bounding box, and the matching affine."""
|
|
325
|
-
img = nib.load(_fetch_t1_template_path())
|
|
349
|
+
def _crop_nonbackground(img: nib.Nifti1Image) -> tuple[np.ndarray, np.ndarray]:
|
|
350
|
+
"""Crop `img`'s data to its non-background bounding box; return data and the matching affine."""
|
|
326
351
|
data = np.asarray(img.get_fdata(), dtype=np.float32)
|
|
327
352
|
mask = data > (data.max() * 0.05)
|
|
328
353
|
idx = np.array(np.where(mask))
|
|
@@ -333,6 +358,17 @@ def _cropped_template() -> tuple[np.ndarray, np.ndarray]:
|
|
|
333
358
|
return cropped, affine
|
|
334
359
|
|
|
335
360
|
|
|
361
|
+
def _fetch_t1_template_path() -> Path:
|
|
362
|
+
"""Download the CC BY-NC 4.0 T1 template once per machine, cached under the system temp dir."""
|
|
363
|
+
return _download_and_cache(_TEMPLATE_URL, "chris_t1.nii.gz")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@functools.cache
|
|
367
|
+
def _cropped_template() -> tuple[np.ndarray, np.ndarray]:
|
|
368
|
+
"""T1 template data cropped to its non-background bounding box, and the matching affine."""
|
|
369
|
+
return _crop_nonbackground(nib.load(_fetch_t1_template_path()))
|
|
370
|
+
|
|
371
|
+
|
|
336
372
|
def write_template_t1(path: Path) -> None:
|
|
337
373
|
"""Write the cropped, full-resolution template as an anatomical NIfTI."""
|
|
338
374
|
data, affine = _cropped_template()
|
|
@@ -340,6 +376,29 @@ def write_template_t1(path: Path) -> None:
|
|
|
340
376
|
nib.save(nib.Nifti1Image(data, affine), path)
|
|
341
377
|
|
|
342
378
|
|
|
379
|
+
def _fetch_pd_template_path() -> Path:
|
|
380
|
+
"""Download the CC BY-NC 4.0 PD template once per machine, cached under the system temp dir."""
|
|
381
|
+
return _download_and_cache(_TEMPLATE_PD_URL, "chris_pd.nii.gz")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@functools.cache
|
|
385
|
+
def _cropped_pd_template() -> tuple[np.ndarray, np.ndarray]:
|
|
386
|
+
"""PD template data cropped to its non-background bounding box, and the matching affine."""
|
|
387
|
+
return _crop_nonbackground(nib.load(_fetch_pd_template_path()))
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def write_template_pd(path: Path) -> None:
|
|
391
|
+
"""Write the cropped, full-resolution PD template as an anatomical NIfTI.
|
|
392
|
+
|
|
393
|
+
Unlike `write_template_t1`, this is kept at its native 0.86x0.86x2.4mm voxel size -- a real 2D
|
|
394
|
+
-acquired scan's in-plane vs. slice-thickness anisotropy, not resampled to isotropic -- so it's
|
|
395
|
+
the fixture that exercises `display_images`' `zooms` aspect correction (issue #35).
|
|
396
|
+
"""
|
|
397
|
+
data, affine = _cropped_pd_template()
|
|
398
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
399
|
+
nib.save(nib.Nifti1Image(data, affine), path)
|
|
400
|
+
|
|
401
|
+
|
|
343
402
|
def template_grid_intensity(grid: tuple[int, int, int]) -> tuple[np.ndarray, np.ndarray]:
|
|
344
403
|
"""Downsample the template onto `grid`; returns `(own_affine, intensity_map in [0, 1])`.
|
|
345
404
|
|
|
@@ -375,14 +434,13 @@ def template_grid_intensity(grid: tuple[int, int, int]) -> tuple[np.ndarray, np.
|
|
|
375
434
|
intensity_map = np.maximum(intensity_map, 0.0)
|
|
376
435
|
own_affine = np.diag([*voxel_size, 1.0])
|
|
377
436
|
own_affine[:3, 3] = affine[:3, 3]
|
|
378
|
-
# MRSISeries.create_MRSI_affine() always adds an extra half-*blocky*-voxel shift in x/y on top
|
|
379
|
-
# of its resize shift (tuned for real GE headers, where the "fine" reference affine needs it).
|
|
380
437
|
# For grid_mode=True zoom, blocky voxel i's true center is:
|
|
381
438
|
# native_translation + blocky_voxel*(i + 0.5) - native_voxel/2
|
|
382
|
-
# i.e.
|
|
383
|
-
#
|
|
384
|
-
#
|
|
385
|
-
|
|
439
|
+
# i.e. as a diag affine's translation (the world position of index 0): native_translation +
|
|
440
|
+
# blocky_voxel/2 - native_voxel/2. This holds on every axis, independent of
|
|
441
|
+
# `create_MRSI_affine()`/`rescale_affine_xy()` -- own_affine is this function's own ground
|
|
442
|
+
# truth, derived purely from how the template was downsampled.
|
|
443
|
+
own_affine[:3, 3] += voxel_size / 2 - voxel_native / 2
|
|
386
444
|
return own_affine, intensity_map
|
|
387
445
|
|
|
388
446
|
|
|
@@ -220,6 +220,10 @@ def _build_brain_mrs_mrsi_exam(root: Path) -> None:
|
|
|
220
220
|
|
|
221
221
|
# The real T1 template, cropped to the head -- see _spectra._TEMPLATE_ATTRIBUTION.
|
|
222
222
|
_spectra.write_template_t1(data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO.nii.gz")
|
|
223
|
+
# Same subject, real 2D-acquired PD scan at its native anisotropic voxel size -- the fixture
|
|
224
|
+
# docs/data-model/geseries.md uses to show display_images' zooms aspect correction actually
|
|
225
|
+
# doing something, which the (isotropic) T1 above can't demonstrate.
|
|
226
|
+
_spectra.write_template_pd(data / "004_2D_Ax_PD" / "4_2D_Ax_PD.nii.gz")
|
|
223
227
|
|
|
224
228
|
# Series 8's intensity map seeds create_MRSI_affine's reference voxel size *and* the
|
|
225
229
|
# per-grid-cell simulate_grid scaling below -- real brain signal in, brain-shaped spectra out.
|
|
@@ -326,20 +330,24 @@ def _build_nist_phantom_exam(root: Path) -> None:
|
|
|
326
330
|
data = root / "data"
|
|
327
331
|
exam = data / "ExamNISTanon"
|
|
328
332
|
grid = (16, 16, 16)
|
|
329
|
-
fov_mm = 300.0 # -> 14 mm in-plane voxels, comfortably resolves the
|
|
333
|
+
fov_mm = 300.0 # -> 14 mm in-plane voxels, comfortably resolves the sphere rings
|
|
330
334
|
z_extent_mm = 300.0
|
|
331
|
-
|
|
335
|
+
signal_rings = _spectra.DEFAULT_SPHERE_RINGS
|
|
336
|
+
# The anatomical phantom shows more structure than the spectroscopy sequence excites --
|
|
337
|
+
# ANATOMY_ONLY_SPHERE_RINGS never reaches sphere_grid_intensity, so those spheres carry no
|
|
338
|
+
# MRSI signal at all, only a T1-visible presence.
|
|
339
|
+
anatomy_rings = signal_rings + _spectra.ANATOMY_ONLY_SPHERE_RINGS
|
|
332
340
|
|
|
333
341
|
_spectra.sphere_phantom_image(
|
|
334
342
|
data / "002_3D_Ax_T1_BRAVO" / "2_3D_Ax_T1_BRAVO.nii.gz",
|
|
335
343
|
shape=(128, 128, 100),
|
|
336
344
|
voxel_mm=1,
|
|
337
|
-
rings=
|
|
345
|
+
rings=anatomy_rings,
|
|
338
346
|
seed=2,
|
|
339
347
|
)
|
|
340
348
|
|
|
341
349
|
intensity_map, voxel_xy = _spectra.sphere_grid_intensity(
|
|
342
|
-
grid, fov_mm=fov_mm, z_extent_mm=z_extent_mm, rings=
|
|
350
|
+
grid, fov_mm=fov_mm, z_extent_mm=z_extent_mm, rings=signal_rings
|
|
343
351
|
)
|
|
344
352
|
own_affine = _spectra.grid_own_affine(grid, (voxel_xy, voxel_xy, z_extent_mm / grid[2]))
|
|
345
353
|
|
|
@@ -68,11 +68,13 @@ def get_all_dicom_series_ids(data_folder: str | Path) -> tuple[list[int], list[i
|
|
|
68
68
|
|
|
69
69
|
# Only flag gaps within the low, dense protocol-step range (series <= 99).
|
|
70
70
|
# Series >= 100 are reformats/resaves with sparse-by-design numbering
|
|
71
|
-
# (e.g. 500/501, 40003) and must never be flagged as "missing"
|
|
71
|
+
# (e.g. 500/501, 40003) and must never be flagged as "missing" -- nor may
|
|
72
|
+
# they stretch the checked range: it ends at the last series <= 99 that
|
|
73
|
+
# actually exists, not at 99.
|
|
74
|
+
dense = [n for n in series_numbers if n <= 99]
|
|
72
75
|
missing = []
|
|
73
|
-
if
|
|
74
|
-
|
|
75
|
-
missing = sorted(set(dense_range) - set(series_numbers))
|
|
76
|
+
if dense:
|
|
77
|
+
missing = sorted(set(range(dense[0], dense[-1] + 1)) - set(dense))
|
|
76
78
|
if missing:
|
|
77
79
|
logger.error(f"Missing series IDs: {missing}. DOUBLE CHECK DATA FOLDER!")
|
|
78
80
|
|
|
@@ -24,22 +24,66 @@ DEFAULT_RESAMPLE_PARAMS = {
|
|
|
24
24
|
type DISPLAY_PLANES = Literal["axial", "coronal", "sagittal"]
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _resolve_orientation(
|
|
28
|
+
orientation: tuple[str, str, str] | None, display_plane: DISPLAY_PLANES | None
|
|
29
|
+
) -> tuple[str, str, str]:
|
|
30
|
+
"""Resolve the target axis codes from `orientation`/`display_plane`.
|
|
31
|
+
|
|
32
|
+
Shared by `orient_nifti` and `get_display_affine` so the two can't drift apart.
|
|
33
|
+
"""
|
|
34
|
+
if display_plane is not None:
|
|
35
|
+
match display_plane:
|
|
36
|
+
case "axial":
|
|
37
|
+
orientation = ("L", "P", "S")
|
|
38
|
+
case "coronal":
|
|
39
|
+
orientation = ("L", "I", "P")
|
|
40
|
+
case "sagittal":
|
|
41
|
+
orientation = ("I", "L", "P")
|
|
42
|
+
case _:
|
|
43
|
+
orientation = DEFAULT_PARAMS["orientation"]
|
|
44
|
+
logger.debug(f"Orienting NIfTI to {display_plane} plane. Using orientation {orientation}.")
|
|
45
|
+
return orientation
|
|
46
|
+
|
|
47
|
+
if orientation is None:
|
|
48
|
+
logger.debug(
|
|
49
|
+
f"No orientation specified. Using default orientation: {DEFAULT_PARAMS['orientation']}."
|
|
50
|
+
)
|
|
51
|
+
return DEFAULT_PARAMS["orientation"]
|
|
52
|
+
|
|
53
|
+
return orientation
|
|
54
|
+
|
|
55
|
+
|
|
27
56
|
def get_display_affine(
|
|
28
57
|
nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
29
|
-
orientation: tuple[str, str, str]
|
|
58
|
+
orientation: tuple[str, str, str] | None = None,
|
|
59
|
+
display_plane: DISPLAY_PLANES | None = None,
|
|
30
60
|
) -> npt.NDArray[np.float64]:
|
|
31
61
|
"""Return the affine mapping display voxel indices (after orient_nifti) to world mm.
|
|
32
62
|
|
|
33
63
|
orient_nifti applies apply_orientation then flipud(rot90(k=1)) which equals
|
|
34
64
|
a transpose of axes 0 and 1. This function encodes both steps as an affine.
|
|
35
65
|
Use inv(get_display_affine(t1)) @ mrsi_affine to get mrsi→display mapping.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
nii : nibabel.spatialimages.SpatialImage or GESeries.NiiBase
|
|
70
|
+
The image to compute the display affine for.
|
|
71
|
+
orientation : tuple[str, str, str], optional
|
|
72
|
+
Target axis codes. Defaults to `DEFAULT_PARAMS["orientation"]` if
|
|
73
|
+
neither this nor `display_plane` is given.
|
|
74
|
+
display_plane : {"axial", "coronal", "sagittal"}, optional
|
|
75
|
+
If given, overrides `orientation` with a preset for the named plane,
|
|
76
|
+
matching `orient_nifti`.
|
|
36
77
|
"""
|
|
37
78
|
if isinstance(nii, GESeries.NiiBase):
|
|
38
79
|
nii: spatialimages.SpatialImage = nii.nii
|
|
39
|
-
|
|
80
|
+
orientation = _resolve_orientation(orientation, display_plane)
|
|
81
|
+
# Reorient with the target axcodes applied directly (not composed with the
|
|
82
|
+
# image's current orientation via ornt_transform) -- orient_nifti does the same on
|
|
83
|
+
# the data via apply_orientation, and the two must use the identical permutation
|
|
84
|
+
# or the zooms end up describing axes other than the ones actually displayed.
|
|
40
85
|
target_ornt = nib.orientations.axcodes2ornt(orientation)
|
|
41
|
-
|
|
42
|
-
reoriented = nii.as_reoriented(transform) # pyright: ignore[reportArgumentType]
|
|
86
|
+
reoriented = nii.as_reoriented(target_ornt)
|
|
43
87
|
oriented_affine = np.asarray(reoriented.affine, dtype=float)
|
|
44
88
|
# flipud(rot90(k=1, axes=(0,1))) = pure transpose of axes 0 and 1.
|
|
45
89
|
# This swaps columns 0 and 1 of the affine.
|
|
@@ -49,6 +93,26 @@ def get_display_affine(
|
|
|
49
93
|
return oriented_affine @ P
|
|
50
94
|
|
|
51
95
|
|
|
96
|
+
def get_display_zooms(
|
|
97
|
+
nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
98
|
+
orientation: tuple[str, str, str] | None = None,
|
|
99
|
+
display_plane: DISPLAY_PLANES | None = None,
|
|
100
|
+
) -> tuple[float, float]:
|
|
101
|
+
"""Return the (row, col) physical voxel edge lengths of the displayed grid, in mm.
|
|
102
|
+
|
|
103
|
+
The column norms of `get_display_affine`'s first two columns give the
|
|
104
|
+
physical spacing along the two in-plane axes `orient_nifti` puts at
|
|
105
|
+
positions 0 and 1 -- correct even for oblique/rotated affines, unlike
|
|
106
|
+
reading `nii.header.get_zooms()` and guessing which entries survived the
|
|
107
|
+
reorientation/transpose. Meant to be passed straight through as
|
|
108
|
+
`display_images(..., zooms=...)`.
|
|
109
|
+
"""
|
|
110
|
+
display_affine = get_display_affine(nii, orientation=orientation, display_plane=display_plane)
|
|
111
|
+
row_spacing = float(np.linalg.norm(display_affine[:3, 0]))
|
|
112
|
+
col_spacing = float(np.linalg.norm(display_affine[:3, 1]))
|
|
113
|
+
return row_spacing, col_spacing
|
|
114
|
+
|
|
115
|
+
|
|
52
116
|
def orient_nifti(
|
|
53
117
|
nii: spatialimages.SpatialImage | GESeries.NiiBase,
|
|
54
118
|
orientation: tuple[str, str, str] | None = None,
|
|
@@ -82,23 +146,7 @@ def orient_nifti(
|
|
|
82
146
|
"""
|
|
83
147
|
if isinstance(nii, GESeries.NiiBase):
|
|
84
148
|
nii: spatialimages.SpatialImage = nii.nii
|
|
85
|
-
|
|
86
|
-
match display_plane:
|
|
87
|
-
case "axial":
|
|
88
|
-
orientation = ("L", "P", "S")
|
|
89
|
-
case "coronal":
|
|
90
|
-
orientation = ("L", "I", "P")
|
|
91
|
-
case "sagittal":
|
|
92
|
-
orientation = ("I", "L", "P")
|
|
93
|
-
case _:
|
|
94
|
-
orientation = DEFAULT_PARAMS["orientation"]
|
|
95
|
-
logger.debug(f"Orienting NIfTI to {display_plane} plane. Using orientation {orientation}.")
|
|
96
|
-
|
|
97
|
-
if orientation is None:
|
|
98
|
-
logger.debug(
|
|
99
|
-
f"No orientation specified. Using default orientation: {DEFAULT_PARAMS['orientation']}."
|
|
100
|
-
)
|
|
101
|
-
orientation = DEFAULT_PARAMS["orientation"]
|
|
149
|
+
orientation = _resolve_orientation(orientation, display_plane)
|
|
102
150
|
|
|
103
151
|
if caching:
|
|
104
152
|
logger.debug(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|