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.
Files changed (42) hide show
  1. {mnutils-1.2.2 → mnutils-1.2.3}/PKG-INFO +1 -1
  2. {mnutils-1.2.2 → mnutils-1.2.3}/pyproject.toml +2 -1
  3. {mnutils-1.2.2 → mnutils-1.2.3}/pyproject.toml.orig +2 -1
  4. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/GESeries.py +17 -12
  5. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/image_grid.js +5 -1
  6. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/images.py +39 -0
  7. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/testing/_spectra.py +88 -30
  8. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/testing/synthetic.py +12 -4
  9. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/file_helpers.py +6 -4
  10. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/nifti.py +69 -21
  11. {mnutils-1.2.2 → mnutils-1.2.3}/LICENSE +0 -0
  12. {mnutils-1.2.2 → mnutils-1.2.3}/README.md +0 -0
  13. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/GEExam.py +0 -0
  14. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/__init__.py +0 -0
  15. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/_scripts.py +0 -0
  16. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/fitting/AMARES.py +0 -0
  17. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/fitting/__init__.py +0 -0
  18. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/matlab/__init__.py +0 -0
  19. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/matlab/setup_matlab.py +0 -0
  20. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/__init__.py +0 -0
  21. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/__init__.py +0 -0
  22. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_html.py +0 -0
  23. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
  24. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
  25. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
  26. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/image_grid.css +0 -0
  27. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/image_grid.py +0 -0
  28. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
  29. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
  30. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
  31. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
  32. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
  33. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
  34. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/saving.py +0 -0
  35. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/plotting/spectra.py +0 -0
  36. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/py.typed +0 -0
  37. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/testing/__init__.py +0 -0
  38. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/__init__.py +0 -0
  39. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/data_loaders.py +0 -0
  40. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/images.py +0 -0
  41. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/partial_volume.py +0 -0
  42. {mnutils-1.2.2 → mnutils-1.2.3}/src/mnutils/utils/spectra.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: MNutils
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: Utility functions for multinuclear MR data processing
5
5
  Keywords: mri,mrs,xarray,spectroscopy,nmr
6
6
  Author: Sebastian Bauer
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "MNutils"
3
- version = "1.2.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.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
- # Image data is to be oriented the following
100
- orientated_data = nib.orientations.apply_orientation(
101
- new_data, nib.orientations.axcodes2ornt(("P", "L", "S"))
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
- orientated_data, # pyright: ignore[reportArgumentType]
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
- # The shift is important as we want to make sure the overlay is in the correct position
750
- # The correct shift is the following
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 of the "
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
- `GESeries.MRSISeries.create_MRSI_affine()` derives `RAW_exp`'s affine from this one's voxel
188
- size via a shift correction tuned assuming the own NIfTI's resolution roughly matches the
189
- blocky MRSI grid's (`own_diag/2`, meant to be a half-*blocky*-voxel nudge). A much finer own
190
- NIfTI (e.g. an interpolated pseudo image) makes that term negligible instead, dragging
191
- `RAW_exp`'s translation off by about half the *old* voxel size. Shifting this affine's own
192
- translation by `(new_voxel - old_voxel) / 2` exactly cancels that drift, so
193
- `create_MRSI_affine()`'s output is unchanged no matter this affine's own resolution.
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 = 60.0
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] = [SphereRing(name="metabolite_ring")]
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 _template_cache_path() -> Path:
308
- return Path(tempfile.gettempdir()) / "mnutils_template_cache" / "chris_t1.nii.gz"
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 T1 template fixture from {_TEMPLATE_URL} to {cache}")
317
- with urllib.request.urlopen(_TEMPLATE_URL, timeout=30) as response: # noqa: S310
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
- @functools.cache
323
- def _cropped_template() -> tuple[np.ndarray, np.ndarray]:
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. own_affine's translation should be native_translation - native_voxel/2 to land there once
383
- # create_MRSI_affine's own +blocky_voxel/2 shift is added back on top -- the blocky_voxel terms
384
- # cancel exactly, so the correction here is half a *native* voxel, not half the blocky one.
385
- own_affine[:2, 3] -= voxel_native[:2] / 2
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 8-sphere ring
333
+ fov_mm = 300.0 # -> 14 mm in-plane voxels, comfortably resolves the sphere rings
330
334
  z_extent_mm = 300.0
331
- rings = _spectra.DEFAULT_SPHERE_RINGS
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=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=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 series_numbers[0] <= 99:
74
- dense_range = range(series_numbers[0], min(series_numbers[-1], 99) + 1)
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] = ("L", "P", "S"),
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
- current_ornt = nib.orientations.io_orientation(nii.affine)
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
- transform = nib.orientations.ornt_transform(current_ornt, target_ornt)
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
- if display_plane is not None:
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