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.
- {mnutils-1.2.0 → mnutils-1.2.2}/PKG-INFO +15 -5
- {mnutils-1.2.0 → mnutils-1.2.2}/README.md +13 -3
- {mnutils-1.2.0 → mnutils-1.2.2}/pyproject.toml +2 -2
- {mnutils-1.2.0 → mnutils-1.2.2}/pyproject.toml.orig +2 -2
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/images.py +193 -54
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/testing/_spectra.py +71 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/testing/synthetic.py +55 -16
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/__init__.py +4 -1
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/file_helpers.py +7 -3
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/nifti.py +1 -141
- mnutils-1.2.2/src/mnutils/utils/partial_volume.py +360 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/LICENSE +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/GEExam.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/GESeries.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/_scripts.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/fitting/AMARES.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/fitting/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/matlab/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/matlab/setup_matlab.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_html.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/image_grid.css +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/image_grid.js +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/image_grid.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/saving.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/plotting/spectra.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/py.typed +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/testing/__init__.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/data_loaders.py +0 -0
- {mnutils-1.2.0 → mnutils-1.2.2}/src/mnutils/utils/images.py +0 -0
- {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.
|
|
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
|
+
[](https://pypi.org/project/mnutils/)
|
|
50
|
+
[](https://pypi.org/project/mnutils/)
|
|
49
51
|
[](https://github.com/bastigw/mnutils/actions/workflows/ci-fast.yml)
|
|
50
52
|
[](https://bastigw.github.io/mnutils/)
|
|
51
53
|
[](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
|
|
74
|
+
uv add mnutils
|
|
71
75
|
```
|
|
72
76
|
|
|
73
77
|
Optional extras:
|
|
74
78
|
|
|
75
79
|
```bash
|
|
76
|
-
uv add "mnutils[bet]
|
|
77
|
-
uv add "mnutils[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
|
+
[](https://pypi.org/project/mnutils/)
|
|
10
|
+
[](https://pypi.org/project/mnutils/)
|
|
9
11
|
[](https://github.com/bastigw/mnutils/actions/workflows/ci-fast.yml)
|
|
10
12
|
[](https://bastigw.github.io/mnutils/)
|
|
11
13
|
[](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
|
|
34
|
+
uv add mnutils
|
|
31
35
|
```
|
|
32
36
|
|
|
33
37
|
Optional extras:
|
|
34
38
|
|
|
35
39
|
```bash
|
|
36
|
-
uv add "mnutils[bet]
|
|
37
|
-
uv add "mnutils[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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
displayed
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|