MNutils 1.2.0__tar.gz → 1.2.2__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.0 → mnutils-1.2.2}/PKG-INFO +15 -5
  2. {mnutils-1.2.0 → mnutils-1.2.2}/README.md +13 -3
  3. {mnutils-1.2.0 → mnutils-1.2.2}/pyproject.toml +2 -2
  4. {mnutils-1.2.0 → mnutils-1.2.2}/pyproject.toml.orig +2 -2
  5. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/images.py +193 -54
  6. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/testing/_spectra.py +71 -0
  7. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/testing/synthetic.py +55 -16
  8. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/__init__.py +4 -1
  9. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/file_helpers.py +7 -3
  10. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/nifti.py +1 -141
  11. mnutils-1.2.2/src/mnutils/utils/partial_volume.py +360 -0
  12. {mnutils-1.2.0 → mnutils-1.2.2}/LICENSE +0 -0
  13. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/GEExam.py +0 -0
  14. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/GESeries.py +0 -0
  15. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/__init__.py +0 -0
  16. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/_scripts.py +0 -0
  17. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/fitting/AMARES.py +0 -0
  18. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/fitting/__init__.py +0 -0
  19. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/matlab/__init__.py +0 -0
  20. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/matlab/setup_matlab.py +0 -0
  21. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/__init__.py +0 -0
  22. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/__init__.py +0 -0
  23. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_html.py +0 -0
  24. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
  25. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
  26. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
  27. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/image_grid.css +0 -0
  28. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/image_grid.js +0 -0
  29. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/image_grid.py +0 -0
  30. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
  31. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
  32. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
  33. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
  34. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
  35. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
  36. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/saving.py +0 -0
  37. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/spectra.py +0 -0
  38. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/py.typed +0 -0
  39. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/testing/__init__.py +0 -0
  40. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/data_loaders.py +0 -0
  41. {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/images.py +0 -0
  42. {mnutils-1.2.0 → mnutils-1.2.2}/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.0
3
+ Version: 1.2.2
4
4
  Summary: Utility functions for multinuclear MR data processing
5
5
  Keywords: mri,mrs,xarray,spectroscopy,nmr
6
6
  Author: Sebastian Bauer
@@ -12,7 +12,6 @@ Classifier: Programming Language :: Python :: 3.13
12
12
  Classifier: Programming Language :: Python :: 3.14
13
13
  Classifier: Intended Audience :: Science/Research
14
14
  Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
- Classifier: License :: OSI Approved :: BSD License
16
15
  Classifier: Operating System :: OS Independent
17
16
  Classifier: Framework :: Jupyter
18
17
  Requires-Dist: dcm2niiw>=0.1.5
@@ -25,6 +24,7 @@ Requires-Dist: nmrglue>=0.11
25
24
  Requires-Dist: pillow>=12.3.0
26
25
  Requires-Dist: scipy>=1.15.3
27
26
  Requires-Dist: seaborn>=0.13.2
27
+ Requires-Dist: xarray>=2025.6.1
28
28
  Requires-Dist: xmris[fitting]
29
29
  Requires-Dist: matlabengine==25.1.* ; extra == '2025a'
30
30
  Requires-Dist: hd-bet>=2.0.1 ; extra == 'bet'
@@ -46,6 +46,8 @@ Description-Content-Type: text/markdown
46
46
  </picture>
47
47
  </p>
48
48
 
49
+ [![PyPI](https://img.shields.io/pypi/v/mnutils.svg)](https://pypi.org/project/mnutils/)
50
+ [![Python](https://img.shields.io/pypi/pyversions/mnutils.svg)](https://pypi.org/project/mnutils/)
49
51
  [![Tests](https://github.com/bastigw/mnutils/actions/workflows/ci-fast.yml/badge.svg)](https://github.com/bastigw/mnutils/actions/workflows/ci-fast.yml)
50
52
  [![Docs](https://github.com/bastigw/mnutils/actions/workflows/deploy.yml/badge.svg)](https://bastigw.github.io/mnutils/)
51
53
  [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)
@@ -66,15 +68,23 @@ plotting, fitting, NIfTI, and MATLAB-bridge guides.
66
68
 
67
69
  ## Installation
68
70
 
71
+ MNUtils is on PyPI:
72
+
69
73
  ```bash
70
- uv add git+https://github.com/bastigw/mnutils.git
74
+ uv add mnutils
71
75
  ```
72
76
 
73
77
  Optional extras:
74
78
 
75
79
  ```bash
76
- uv add "mnutils[bet] @ git+https://github.com/bastigw/mnutils.git" # brain extraction (hd-bet, torch)
77
- uv add "mnutils[2025a] @ git+https://github.com/bastigw/mnutils.git" # MATLAB Engine pinned to MATLAB 2025a
80
+ uv add "mnutils[bet]" # brain extraction (hd-bet, torch)
81
+ uv add "mnutils[2025a]" # MATLAB Engine pinned to MATLAB 2025a
82
+ ```
83
+
84
+ To track the development version instead:
85
+
86
+ ```bash
87
+ uv add git+https://github.com/bastigw/mnutils.git
78
88
  ```
79
89
 
80
90
  `2025a` pins `matlabengine==25.1.*`. If your local MATLAB install is a different version, install
@@ -6,6 +6,8 @@
6
6
  </picture>
7
7
  </p>
8
8
 
9
+ [![PyPI](https://img.shields.io/pypi/v/mnutils.svg)](https://pypi.org/project/mnutils/)
10
+ [![Python](https://img.shields.io/pypi/pyversions/mnutils.svg)](https://pypi.org/project/mnutils/)
9
11
  [![Tests](https://github.com/bastigw/mnutils/actions/workflows/ci-fast.yml/badge.svg)](https://github.com/bastigw/mnutils/actions/workflows/ci-fast.yml)
10
12
  [![Docs](https://github.com/bastigw/mnutils/actions/workflows/deploy.yml/badge.svg)](https://bastigw.github.io/mnutils/)
11
13
  [![License: BSD-3-Clause](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)
@@ -26,15 +28,23 @@ plotting, fitting, NIfTI, and MATLAB-bridge guides.
26
28
 
27
29
  ## Installation
28
30
 
31
+ MNUtils is on PyPI:
32
+
29
33
  ```bash
30
- uv add git+https://github.com/bastigw/mnutils.git
34
+ uv add mnutils
31
35
  ```
32
36
 
33
37
  Optional extras:
34
38
 
35
39
  ```bash
36
- uv add "mnutils[bet] @ git+https://github.com/bastigw/mnutils.git" # brain extraction (hd-bet, torch)
37
- uv add "mnutils[2025a] @ git+https://github.com/bastigw/mnutils.git" # MATLAB Engine pinned to MATLAB 2025a
40
+ uv add "mnutils[bet]" # brain extraction (hd-bet, torch)
41
+ uv add "mnutils[2025a]" # MATLAB Engine pinned to MATLAB 2025a
42
+ ```
43
+
44
+ To track the development version instead:
45
+
46
+ ```bash
47
+ uv add git+https://github.com/bastigw/mnutils.git
38
48
  ```
39
49
 
40
50
  `2025a` pins `matlabengine==25.1.*`. If your local MATLAB install is a different version, install
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "MNutils"
3
- version = "1.2.0"
3
+ version = "1.2.2"
4
4
  description = "Utility functions for multinuclear MR data processing"
5
5
  readme = "README.md"
6
6
  license = "BSD-3-Clause"
@@ -18,7 +18,6 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.14",
19
19
  "Intended Audience :: Science/Research",
20
20
  "Topic :: Scientific/Engineering :: Medical Science Apps.",
21
- "License :: OSI Approved :: BSD License",
22
21
  "Operating System :: OS Independent",
23
22
  "Framework :: Jupyter",
24
23
  ]
@@ -34,6 +33,7 @@ dependencies = [
34
33
  "pillow>=12.3.0",
35
34
  "scipy>=1.15.3",
36
35
  "seaborn>=0.13.2",
36
+ "xarray>=2025.6.1",
37
37
  "xmris[fitting]",
38
38
  ]
39
39
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "MNutils"
3
- version = "1.2.0"
3
+ version = "1.2.2"
4
4
 
5
5
  authors = [
6
6
  {name = "Sebastian Bauer", email = "28663646+bastigw@users.noreply.github.com"}
@@ -18,7 +18,6 @@ classifiers = [
18
18
  "Programming Language :: Python :: 3.14",
19
19
  "Intended Audience :: Science/Research",
20
20
  "Topic :: Scientific/Engineering :: Medical Science Apps.",
21
- "License :: OSI Approved :: BSD License",
22
21
  "Operating System :: OS Independent",
23
22
  "Framework :: Jupyter",
24
23
  ]
@@ -36,6 +35,7 @@ dependencies = [
36
35
  "pillow>=12.3.0",
37
36
  "scipy>=1.15.3",
38
37
  "seaborn>=0.13.2",
38
+ "xarray>=2025.6.1",
39
39
  "xmris[fitting]",
40
40
  ]
41
41
 
@@ -4,7 +4,7 @@ import io
4
4
  import zlib
5
5
  from collections.abc import Sequence
6
6
  from concurrent.futures import ThreadPoolExecutor
7
- from typing import Any, NamedTuple, TypedDict, Unpack
7
+ from typing import Any, Literal, NamedTuple, TypedDict, Unpack, overload
8
8
 
9
9
  import matplotlib.pyplot as plt
10
10
  import numpy as np
@@ -667,44 +667,161 @@ def overlay_nifti_data_on_T1(
667
667
  return overlay_image_data_on_T1(t1_images, data_images, mask=mask_to_pass_on, **kwargs)
668
668
 
669
669
 
670
+ def _render_overlay_raster_frames(
671
+ t1_images: npt.NDArray,
672
+ data_images: npt.NDArray,
673
+ mask: npt.NDArray[np.bool_] | None,
674
+ only_overlay: bool,
675
+ cmap: str = DEFAULT_PARAMS["cmap"],
676
+ alpha: float = 0.5,
677
+ vmin: float | None = None,
678
+ vmax: float | None = None,
679
+ v_percentile: float = 1.0,
680
+ ) -> tuple[list[list[bytes]], list[list[tuple[float, float]]], list[str]]:
681
+ """Render `overlay_image_data_on_T1`'s panels straight to raster, one job per slice.
682
+
683
+ Reuses `_render_frames` exactly as `inspect_MRSI_spectra` already does --
684
+ a gray T1 layer, a colormapped data layer, alpha-blended for the overlay
685
+ panel -- rather than a matplotlib `Figure`/`savefig` round-trip. `mask`
686
+ (fill, non-contour) is applied the same way the matplotlib engine applies
687
+ it: `np.where(mask, data, nan)` before the data layer is built. Panel
688
+ bounds are computed the same way `_draw_single_axis_overlay` computes
689
+ them, for visual parity between engines.
690
+ """
691
+ n_slices = t1_images.shape[2]
692
+ jobs = [(s,) for s in range(n_slices)]
693
+
694
+ anat_bounds = fast_bounds(t1_images, p_lower=1.0, p_upper=99.0)
695
+ masked_data = np.where(mask, data_images, np.nan) if mask is not None else data_images
696
+ data_bounds = _resolve_display_bounds(masked_data, vmin, vmax, v_percentile)
697
+
698
+ panels: list[tuple[str, list[_Layer], tuple[float, float]]] = []
699
+ if not only_overlay:
700
+ panels.append(("T1 Image", [_Layer(t1_images, "gray", anat_bounds)], anat_bounds))
701
+ panels.append(("Resampled Data", [_Layer(masked_data, cmap, data_bounds)], data_bounds))
702
+ panels.append(
703
+ (
704
+ "Overlay",
705
+ [
706
+ _Layer(t1_images, "gray", anat_bounds),
707
+ _Layer(masked_data, cmap, data_bounds, alpha=alpha),
708
+ ],
709
+ data_bounds,
710
+ )
711
+ )
712
+
713
+ rendered = [
714
+ _render_frames(layers, jobs, quality=GRID_FRAME_QUALITY, keep_alpha=True)
715
+ for _, layers, _ in panels
716
+ ]
717
+ frames = [[rendered[p][s] for p in range(len(panels))] for s in range(n_slices)]
718
+ bounds = [[panel_bounds for _, _, panel_bounds in panels] for _ in range(n_slices)]
719
+ titles = [title for title, _, _ in panels]
720
+ return frames, bounds, titles
721
+
722
+
723
+ @overload
724
+ def overlay_image_data_on_T1(
725
+ t1_images: npt.NDArray,
726
+ data_images: npt.NDArray | None = None,
727
+ *,
728
+ ax: Axes,
729
+ mask: npt.NDArray[np.bool_] | None = None,
730
+ mask_contour: npt.NDArray[np.bool_] | bool | None = None,
731
+ mask_kwargs: dict[str, Any] | None = None,
732
+ **kwargs,
733
+ ) -> tuple[list[AxesImage], QuadContourSet | None]: ...
734
+ @overload
670
735
  def overlay_image_data_on_T1(
671
736
  t1_images: npt.NDArray,
672
737
  data_images: npt.NDArray,
738
+ *,
739
+ engine: Literal["matplotlib"],
673
740
  mask: npt.NDArray[np.bool_] | None = None,
674
741
  mask_contour: npt.NDArray[np.bool_] | bool | None = None,
675
742
  only_overlay: bool = False,
676
743
  mask_kwargs: dict[str, Any] | None = None,
677
744
  **kwargs,
678
- ) -> tuple[Figure, list[Axes]] | None:
745
+ ) -> tuple[Figure, list[Axes]] | None: ...
746
+ @overload
747
+ def overlay_image_data_on_T1(
748
+ t1_images: npt.NDArray,
749
+ data_images: npt.NDArray,
750
+ *,
751
+ engine: Literal["raster"] | None = None,
752
+ mask: npt.NDArray[np.bool_] | None = None,
753
+ mask_contour: npt.NDArray[np.bool_] | bool | None = None,
754
+ only_overlay: bool = False,
755
+ mask_kwargs: dict[str, Any] | None = None,
756
+ **kwargs,
757
+ ) -> None: ...
758
+ def overlay_image_data_on_T1(
759
+ t1_images: npt.NDArray,
760
+ data_images: npt.NDArray | None = None,
761
+ mask: npt.NDArray[np.bool_] | None = None,
762
+ mask_contour: npt.NDArray[np.bool_] | bool | None = None,
763
+ only_overlay: bool = False,
764
+ mask_kwargs: dict[str, Any] | None = None,
765
+ engine: Literal["raster", "matplotlib"] | None = None,
766
+ ax: Axes | None = None,
767
+ **kwargs,
768
+ ) -> tuple[Figure, list[Axes]] | tuple[list[AxesImage], QuadContourSet | None] | None:
679
769
  """Display T1, resampled data, and overlay images side by side with a slice slider.
680
770
 
681
771
  Parameters
682
772
  ----------
683
773
  t1_images : ndarray
684
774
  T1-weighted anatomical image data.
685
- data_images : ndarray
686
- Data image, resampled into T1 space, to overlay.
775
+ data_images : ndarray, optional
776
+ Data image, resampled into T1 space, to overlay. Required unless `ax`
777
+ is given for a T1-only draw.
687
778
  mask : ndarray, optional
688
779
  Boolean mask applied to the overlay data, by default None.
689
780
  mask_contour : ndarray or bool, optional
690
781
  Mask (or `True` to reuse `mask`) whose contour is drawn on the
691
- overlay axis, by default None.
782
+ overlay axis. Matplotlib engine only -- the raster engine has no
783
+ cheap contour equivalent and warns and ignores it.
692
784
  only_overlay : bool, optional
693
785
  If True, only show the overlay axis instead of T1, data, and
694
786
  overlay side by side, by default False.
695
787
  mask_kwargs : dict, optional
696
788
  Keyword arguments customizing the mask contour's appearance.
789
+ engine : {"raster", "matplotlib"}, optional
790
+ Rendering path. Defaults to `"raster"` (fast, no `Figure`) unless
791
+ `ax` is given, which forces matplotlib -- there is no client-side
792
+ `Axes` for a raster widget to draw into. If both are given, `ax`
793
+ wins and a mismatched `engine="raster"` is ignored with a warning.
794
+ ax : matplotlib.axes.Axes, optional
795
+ Draw a single overlay panel onto this axis instead of displaying the
796
+ T1/data/overlay widget. Implies the matplotlib engine.
697
797
  **kwargs
698
- Additional keyword arguments forwarded to
699
- `overlay_image_data_on_T1_on_ax`.
798
+ Additional keyword arguments forwarded to the resolved rendering path.
700
799
 
701
800
  Returns
702
801
  -------
703
- tuple[Figure, list[Axes]] or None
704
- For a single-slice input, the created figure and its axes. For
705
- multi-slice input, an interactive slice-scrubbing widget is
706
- displayed instead and `None` is returned.
802
+ tuple[Figure, list[Axes]] or tuple[list[AxesImage], QuadContourSet | None] or None
803
+ `ax` given: the plotted images and contours. `engine="matplotlib"`
804
+ with no `ax`: `(Figure, axes)` for single-slice input, `None` (widget
805
+ displayed) for multi-slice. Raster engine (default): always `None`
806
+ (widget displayed), single-slice included.
707
807
  """
808
+ if ax is not None:
809
+ if engine == "raster":
810
+ logger.warning(
811
+ "`ax` was given, which requires the matplotlib engine; ignoring engine='raster'."
812
+ )
813
+ return _draw_single_axis_overlay(
814
+ t1_images,
815
+ data_images,
816
+ mask=mask,
817
+ mask_contour=mask_contour,
818
+ ax=ax,
819
+ mask_kwargs=mask_kwargs,
820
+ **kwargs,
821
+ )
822
+
823
+ if data_images is None:
824
+ raise ValueError("data_images is required unless ax is given for a T1-only draw.")
708
825
  if data_images.shape != t1_images.shape:
709
826
  raise ValueError(
710
827
  f"T1 images and data images must have the same shape for overlay. "
@@ -722,31 +839,62 @@ def overlay_image_data_on_T1(
722
839
  else:
723
840
  slice_idx = t1_images.shape[2] // 2
724
841
 
725
- # `mask_contour` only draws a contour when it's `True` (reuse `mask`); an
726
- # explicit array is not applied here (matches this function's long-standing
727
- # behavior; `overlay_image_data_on_T1_on_ax` is the one that honours it).
728
- resolved_mask_contour = mask if mask_contour is True else None
842
+ if engine == "matplotlib":
843
+ # `mask_contour` only draws a contour when it's `True` (reuse `mask`); an
844
+ # explicit array is not applied here (matches this function's long-standing
845
+ # behavior; `_draw_single_axis_overlay` is the one that honours it).
846
+ resolved_mask_contour = mask if mask_contour is True else None
847
+ overlay_kwargs = dict(
848
+ only_overlay=only_overlay,
849
+ mask=mask,
850
+ resolved_mask_contour=resolved_mask_contour,
851
+ mask_kwargs=mask_kwargs,
852
+ kwargs=kwargs,
853
+ )
854
+ n_slices = t1_images.shape[2]
855
+ if n_slices > 1:
856
+ frames = _render_overlay_frames(
857
+ t1_images, data_images, list(range(n_slices)), **overlay_kwargs
858
+ )
859
+ ipy_display(
860
+ SliceViewerWidget(frames=frames, n_slices=n_slices, initial_index=slice_idx)
861
+ )
862
+ return None
729
863
 
730
- overlay_kwargs = dict(
731
- only_overlay=only_overlay,
732
- mask=mask,
733
- resolved_mask_contour=resolved_mask_contour,
734
- mask_kwargs=mask_kwargs,
735
- kwargs=kwargs,
736
- )
864
+ fig = plt.figure(figsize=(12, 4))
865
+ axes = [fig.subplots(1, 1)] if only_overlay else list(fig.subplots(1, 3))
866
+ _draw_overlay_slice(fig, axes, t1_images, data_images, slice_idx, **overlay_kwargs)
867
+ return fig, axes
737
868
 
738
- n_slices = t1_images.shape[2]
739
- if n_slices > 1:
740
- frames = _render_overlay_frames(
741
- t1_images, data_images, list(range(n_slices)), **overlay_kwargs
869
+ if mask_contour is not None and mask_contour is not False:
870
+ logger.warning(
871
+ "mask_contour has no raster engine equivalent (no contour tracing in "
872
+ "_render_frames); ignoring it. Pass engine='matplotlib' to draw a contour."
742
873
  )
743
- ipy_display(SliceViewerWidget(frames=frames, n_slices=n_slices, initial_index=slice_idx))
744
- return None
745
874
 
746
- fig = plt.figure(figsize=(12, 4))
747
- axes = [fig.subplots(1, 1)] if only_overlay else list(fig.subplots(1, 3))
748
- _draw_overlay_slice(fig, axes, t1_images, data_images, slice_idx, **overlay_kwargs)
749
- return fig, axes
875
+ raster_kwarg_names = {"cmap", "alpha", "vmin", "vmax", "v_percentile"}
876
+ raster_kwargs = {k: v for k, v in kwargs.items() if k in raster_kwarg_names}
877
+ ignored = {k: v for k, v in kwargs.items() if k not in raster_kwarg_names}
878
+ if ignored:
879
+ logger.debug(
880
+ f"Ignoring matplotlib-only arguments {sorted(ignored)} on the raster engine: "
881
+ "there is no Axes to configure."
882
+ )
883
+
884
+ frames, bounds, titles = _render_overlay_raster_frames(
885
+ t1_images, data_images, mask, only_overlay, **raster_kwargs
886
+ )
887
+ ipy_display(
888
+ ImageGridWidget(
889
+ frames=frames,
890
+ bounds=bounds,
891
+ colormap_stops=_colormap_stops(raster_kwargs.get("cmap", DEFAULT_PARAMS["cmap"])),
892
+ num_cols=len(titles),
893
+ titles=titles,
894
+ initial_index=slice_idx,
895
+ )
896
+ )
897
+ return None
750
898
 
751
899
 
752
900
  def _draw_overlay_slice(
@@ -765,16 +913,16 @@ def _draw_overlay_slice(
765
913
  """Draw one slice of the `overlay_image_data_on_T1` panels onto given axes."""
766
914
  axes_idx = 0
767
915
  if not only_overlay:
768
- overlay_image_data_on_T1_on_ax(
916
+ _draw_single_axis_overlay(
769
917
  t1_images[:, :, slice_idx], ax=axes[axes_idx], cmap="gray", **kwargs
770
918
  )
771
919
  axes[axes_idx].set_title("T1 Image")
772
920
  axes_idx += 1
773
- overlay_image_data_on_T1_on_ax(data_images[:, :, slice_idx], ax=axes[axes_idx], **kwargs)
921
+ _draw_single_axis_overlay(data_images[:, :, slice_idx], ax=axes[axes_idx], **kwargs)
774
922
  axes[axes_idx].set_title("Resampled Data")
775
923
  axes_idx += 1
776
924
 
777
- overlay_image_data_on_T1_on_ax(
925
+ _draw_single_axis_overlay(
778
926
  t1_images[:, :, slice_idx],
779
927
  data_images[:, :, slice_idx],
780
928
  mask=mask[:, :, slice_idx] if mask is not None else None,
@@ -815,30 +963,25 @@ def _render_overlay_frames(
815
963
  return frames
816
964
 
817
965
 
818
- def overlay_image_data_on_T1_on_ax(
966
+ def _draw_single_axis_overlay(
819
967
  base_image: npt.NDArray,
820
968
  overlay_image: npt.NDArray | None = None,
821
969
  mask: npt.NDArray[np.bool_] | None = None,
822
970
  mask_contour: npt.NDArray[np.bool_] | bool | None = None,
823
- ax: Axes | None = None,
971
+ *,
972
+ ax: Axes,
824
973
  cmap: str = DEFAULT_PARAMS["cmap"],
825
974
  alpha: float = 0.5,
826
975
  mask_kwargs: dict[str, Any] | None = None,
827
- figsize: tuple[int, int] = (4, 4),
828
976
  **kwargs,
829
- ) -> (
830
- tuple[list[AxesImage], QuadContourSet | None]
831
- | tuple[Figure, Axes, list[AxesImage], QuadContourSet | None]
832
- ):
833
- """Draw a single 2D anatomical image, optionally with a data overlay/mask.
834
-
835
- If `ax` is omitted, creates its own figure and axes and returns them
836
- alongside the plotted images/contours.
837
- """
838
- created_fig = None
839
- if ax is None:
840
- created_fig, ax = plt.subplots(figsize=figsize)
977
+ ) -> tuple[list[AxesImage], QuadContourSet | None]:
978
+ """Draw one anatomical image, optionally with a data overlay/mask, onto `ax`.
841
979
 
980
+ The folded body of the former `overlay_image_data_on_T1_on_ax` --
981
+ `overlay_image_data_on_T1(..., ax=...)` dispatches straight here. Real
982
+ matplotlib is unavoidable on this path: there is no client-side `Axes` for
983
+ a raster widget to draw into.
984
+ """
842
985
  # Copy default image params and update with any provided kwargs
843
986
  remove_keys = ["vmin", "vmax", "v_percentile"]
844
987
  # Adjust aspect ratio based on image shape
@@ -867,8 +1010,6 @@ def overlay_image_data_on_T1_on_ax(
867
1010
  # See if vmin and vmax are provided in kwargs and apply to data image
868
1011
  if overlay_image is None:
869
1012
  ax.set(**image_params)
870
- if created_fig is not None:
871
- return created_fig, ax, im, None
872
1013
  return im, None
873
1014
 
874
1015
  vmin, vmax = _resolve_display_bounds(
@@ -920,8 +1061,6 @@ def overlay_image_data_on_T1_on_ax(
920
1061
  contours = None
921
1062
 
922
1063
  ax.set(**image_params)
923
- if created_fig is not None:
924
- return created_fig, ax, im, contours
925
1064
  return im, contours
926
1065
 
927
1066
 
@@ -181,6 +181,54 @@ def grid_own_affine(
181
181
  return affine
182
182
 
183
183
 
184
+ def rescale_affine_xy(affine: np.ndarray, old_matrix_xy: int, new_matrix_xy: int) -> np.ndarray:
185
+ """Same affine, x/y voxel size rescaled for a different in-plane matrix.
186
+
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.
194
+ """
195
+ scaled = affine.copy()
196
+ old_voxel = np.array([affine[0, 0], affine[1, 1]])
197
+ new_voxel = old_voxel * (old_matrix_xy / new_matrix_xy)
198
+ scaled[0, 0], scaled[1, 1] = new_voxel
199
+ scaled[:2, 3] += (new_voxel - old_voxel) / 2
200
+ return scaled
201
+
202
+
203
+ def compute_pseudo_image(spec: np.ndarray, npts: int = 4) -> np.ndarray:
204
+ """A simplified port of the scanner's own MRSI pseudo-image algorithm (`mrsi2pseu.m`).
205
+
206
+ `how2ind=2` mode: RMS of the `npts` spectral points with the most energy, averaged over
207
+ every voxel, real-valued, at the spectrum's native `(i, j, k)` grid resolution. `spec` is
208
+ `(n_freq, i, j, k)`, matching `simulate_grid`'s output.
209
+ """
210
+ power_spec = np.abs(spec) ** 2
211
+ mean_power = power_spec.mean(axis=(1, 2, 3))
212
+ top_idx = np.argsort(mean_power)[-npts:]
213
+ return np.sqrt(power_spec[top_idx].mean(axis=0)).astype(np.float32)
214
+
215
+
216
+ def fourier_zerofill_interpolate(data: np.ndarray, new_shape: tuple[int, ...]) -> np.ndarray:
217
+ """Zero-fill k-space interpolation onto `new_shape`.
218
+
219
+ Adds no new frequency content -- the same way a scanner console builds its higher-resolution
220
+ pseudo-image display from the native low-res MRSI grid.
221
+ """
222
+ orig_shape = np.array(data.shape)
223
+ new_shape_arr = np.maximum(np.array(new_shape), orig_shape)
224
+ kspace = np.fft.ifftshift(np.fft.ifftn(data))
225
+ padded = np.zeros(tuple(new_shape_arr), dtype=complex)
226
+ start = (new_shape_arr - orig_shape) // 2
227
+ end = start + orig_shape
228
+ padded[tuple(slice(s, e) for s, e in zip(start, end, strict=True))] = kspace
229
+ return np.fft.fftn(np.fft.fftshift(padded))
230
+
231
+
184
232
  # --- Sphere-ring phantom (NIST-style) -----------------------------------------------------------
185
233
 
186
234
 
@@ -336,3 +384,26 @@ def template_grid_intensity(grid: tuple[int, int, int]) -> tuple[np.ndarray, np.
336
384
  # cancel exactly, so the correction here is half a *native* voxel, not half the blocky one.
337
385
  own_affine[:2, 3] -= voxel_native[:2] / 2
338
386
  return own_affine, intensity_map
387
+
388
+
389
+ def template_tissue_masks() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
390
+ """A binary head mask and a 3-label tissue segmentation of the cropped T1 template.
391
+
392
+ Returns `(brain, seg, affine)`, all on the template's own full-resolution grid. `seg`
393
+ labels are 1/2/3, split by intensity percentile inside `brain`.
394
+
395
+ These are **not** anatomically faithful -- intensity banding is not tissue segmentation.
396
+ They exist so partial-volume pages have a mask with realistic *shape*: convoluted borders,
397
+ a solid interior, and an extent that only partly overlaps the MRSI grid. That geometry is
398
+ what those pages test; which voxel is really grey matter is irrelevant to them.
399
+ """
400
+ data, affine = _cropped_template()
401
+ brain = data > data.max() * 0.15
402
+
403
+ seg = np.zeros(data.shape, dtype=np.int16)
404
+ lo, hi = np.percentile(data[brain], [33.0, 66.0])
405
+ seg[brain & (data <= lo)] = 1
406
+ seg[brain & (data > lo) & (data <= hi)] = 2
407
+ seg[brain & (data > hi)] = 3
408
+
409
+ return brain, seg, affine