MNutils 1.2.2__tar.gz → 1.2.4__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.4}/PKG-INFO +4 -10
- {mnutils-1.2.2 → mnutils-1.2.4}/README.md +3 -6
- {mnutils-1.2.2 → mnutils-1.2.4}/pyproject.toml +5 -5
- {mnutils-1.2.2 → mnutils-1.2.4}/pyproject.toml.orig +6 -5
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/GEExam.py +50 -77
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/GESeries.py +98 -34
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/__init__.py +13 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/_scripts.py +34 -16
- mnutils-1.2.4/src/mnutils/fitting/__init__.py +16 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/__init__.py +1 -13
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.css +21 -5
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.js +12 -1
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.py +8 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/images.py +267 -96
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/saving.py +23 -45
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/spectra.py +18 -17
- mnutils-1.2.4/src/mnutils/rcparams.py +449 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/testing/_spectra.py +88 -30
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/testing/synthetic.py +187 -37
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/data_loaders.py +163 -5
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/file_helpers.py +311 -179
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/images.py +9 -9
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/nifti.py +277 -239
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/partial_volume.py +105 -20
- mnutils-1.2.2/src/mnutils/fitting/__init__.py +0 -5
- {mnutils-1.2.2 → mnutils-1.2.4}/LICENSE +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/fitting/AMARES.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/matlab/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/matlab/setup_matlab.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_html.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/py.typed +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/testing/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/__init__.py +0 -0
- {mnutils-1.2.2 → mnutils-1.2.4}/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.4
|
|
4
4
|
Summary: Utility functions for multinuclear MR data processing
|
|
5
5
|
Keywords: mri,mrs,xarray,spectroscopy,nmr
|
|
6
6
|
Author: Sebastian Bauer
|
|
@@ -14,7 +14,6 @@ Classifier: Intended Audience :: Science/Research
|
|
|
14
14
|
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
15
15
|
Classifier: Operating System :: OS Independent
|
|
16
16
|
Classifier: Framework :: Jupyter
|
|
17
|
-
Requires-Dist: dcm2niiw>=0.1.5
|
|
18
17
|
Requires-Dist: ipython>=8.0
|
|
19
18
|
Requires-Dist: loguru>=0.7.3
|
|
20
19
|
Requires-Dist: mat73
|
|
@@ -26,7 +25,6 @@ Requires-Dist: scipy>=1.15.3
|
|
|
26
25
|
Requires-Dist: seaborn>=0.13.2
|
|
27
26
|
Requires-Dist: xarray>=2025.6.1
|
|
28
27
|
Requires-Dist: xmris[fitting]
|
|
29
|
-
Requires-Dist: matlabengine==25.1.* ; extra == '2025a'
|
|
30
28
|
Requires-Dist: hd-bet>=2.0.1 ; extra == 'bet'
|
|
31
29
|
Requires-Dist: torch>=2.10.0 ; extra == 'bet'
|
|
32
30
|
Requires-Python: >=3.12
|
|
@@ -34,7 +32,6 @@ Project-URL: Homepage, https://github.com/bastigw/mnutils
|
|
|
34
32
|
Project-URL: Documentation, https://bastigw.github.io/mnutils/
|
|
35
33
|
Project-URL: Repository, https://github.com/bastigw/mnutils
|
|
36
34
|
Project-URL: Issues, https://github.com/bastigw/mnutils/issues
|
|
37
|
-
Provides-Extra: 2025a
|
|
38
35
|
Provides-Extra: bet
|
|
39
36
|
Description-Content-Type: text/markdown
|
|
40
37
|
|
|
@@ -58,7 +55,7 @@ scanners.
|
|
|
58
55
|
## Features
|
|
59
56
|
|
|
60
57
|
- **Data loading** — read GE raw/reconstructed data via `GEExam`/`GESeries`.
|
|
61
|
-
- **NIfTI
|
|
58
|
+
- **NIfTI handling** — read dcm2niix-converted scanner output (NIfTI + JSON sidecar); DICOMs are not read.
|
|
62
59
|
- **Plotting** — spectral and slice plotting utilities.
|
|
63
60
|
- **Spectral fitting** — AMARES fitting delegated to [`xmris`](https://github.com/andrewendlinger/xmris)
|
|
64
61
|
(`xmris[fitting]`, which wraps pyAMARES).
|
|
@@ -78,7 +75,6 @@ Optional extras:
|
|
|
78
75
|
|
|
79
76
|
```bash
|
|
80
77
|
uv add "mnutils[bet]" # brain extraction (hd-bet, torch)
|
|
81
|
-
uv add "mnutils[2025a]" # MATLAB Engine pinned to MATLAB 2025a
|
|
82
78
|
```
|
|
83
79
|
|
|
84
80
|
To track the development version instead:
|
|
@@ -87,10 +83,8 @@ To track the development version instead:
|
|
|
87
83
|
uv add git+https://github.com/bastigw/mnutils.git
|
|
88
84
|
```
|
|
89
85
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Dependency](#matlab-engine-dependency) below) — there's no way to pin one version project-wide
|
|
93
|
-
across contributors' machines.
|
|
86
|
+
The MATLAB Engine is not an extra: its version has to match your local MATLAB install, so you
|
|
87
|
+
install it yourself (see [MATLAB Engine Dependency](#matlab-engine-dependency) below).
|
|
94
88
|
|
|
95
89
|
## Usage
|
|
96
90
|
|
|
@@ -18,7 +18,7 @@ scanners.
|
|
|
18
18
|
## Features
|
|
19
19
|
|
|
20
20
|
- **Data loading** — read GE raw/reconstructed data via `GEExam`/`GESeries`.
|
|
21
|
-
- **NIfTI
|
|
21
|
+
- **NIfTI handling** — read dcm2niix-converted scanner output (NIfTI + JSON sidecar); DICOMs are not read.
|
|
22
22
|
- **Plotting** — spectral and slice plotting utilities.
|
|
23
23
|
- **Spectral fitting** — AMARES fitting delegated to [`xmris`](https://github.com/andrewendlinger/xmris)
|
|
24
24
|
(`xmris[fitting]`, which wraps pyAMARES).
|
|
@@ -38,7 +38,6 @@ Optional extras:
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
uv add "mnutils[bet]" # brain extraction (hd-bet, torch)
|
|
41
|
-
uv add "mnutils[2025a]" # MATLAB Engine pinned to MATLAB 2025a
|
|
42
41
|
```
|
|
43
42
|
|
|
44
43
|
To track the development version instead:
|
|
@@ -47,10 +46,8 @@ To track the development version instead:
|
|
|
47
46
|
uv add git+https://github.com/bastigw/mnutils.git
|
|
48
47
|
```
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Dependency](#matlab-engine-dependency) below) — there's no way to pin one version project-wide
|
|
53
|
-
across contributors' machines.
|
|
49
|
+
The MATLAB Engine is not an extra: its version has to match your local MATLAB install, so you
|
|
50
|
+
install it yourself (see [MATLAB Engine Dependency](#matlab-engine-dependency) below).
|
|
54
51
|
|
|
55
52
|
## Usage
|
|
56
53
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "MNutils"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.4"
|
|
4
4
|
description = "Utility functions for multinuclear MR data processing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "BSD-3-Clause"
|
|
@@ -23,7 +23,6 @@ classifiers = [
|
|
|
23
23
|
]
|
|
24
24
|
requires-python = ">=3.12"
|
|
25
25
|
dependencies = [
|
|
26
|
-
"dcm2niiw>=0.1.5",
|
|
27
26
|
"ipython>=8.0",
|
|
28
27
|
"loguru>=0.7.3",
|
|
29
28
|
"mat73",
|
|
@@ -48,7 +47,6 @@ Repository = "https://github.com/bastigw/mnutils"
|
|
|
48
47
|
Issues = "https://github.com/bastigw/mnutils/issues"
|
|
49
48
|
|
|
50
49
|
[project.optional-dependencies]
|
|
51
|
-
2025a = ["matlabengine==25.1.*"]
|
|
52
50
|
bet = [
|
|
53
51
|
"hd-bet>=2.0.1",
|
|
54
52
|
"torch>=2.10.0",
|
|
@@ -62,13 +60,14 @@ test-mnutils-gen = "mnutils._scripts:generate_test_notebooks"
|
|
|
62
60
|
test-mnutils = "mnutils._scripts:run_tests"
|
|
63
61
|
|
|
64
62
|
[build-system]
|
|
65
|
-
requires = ["uv_build>=0.8.13,<0.
|
|
63
|
+
requires = ["uv_build>=0.8.13,<0.14.0"]
|
|
66
64
|
build-backend = "uv_build"
|
|
67
65
|
|
|
68
66
|
[dependency-groups]
|
|
69
67
|
dev = [
|
|
70
68
|
"ipykernel>=7.1.0",
|
|
71
69
|
"ipywidgets>=8.1.7",
|
|
70
|
+
"python-dotenv",
|
|
72
71
|
"nbmake>=1.5.5",
|
|
73
72
|
"pytest>=8.4.1",
|
|
74
73
|
"ruff>=0.9.0",
|
|
@@ -77,10 +76,11 @@ dev = [
|
|
|
77
76
|
"griffe<0.40.0",
|
|
78
77
|
"jupyter-server>=2.17.0",
|
|
79
78
|
"jupytext>=1.19.1",
|
|
79
|
+
"pytest-xdist>=3.8.0",
|
|
80
80
|
]
|
|
81
81
|
|
|
82
82
|
[tool.pytest.ini_options]
|
|
83
|
-
addopts = "--
|
|
83
|
+
addopts = "--nbmake -n auto -v --durations=0"
|
|
84
84
|
testpaths = ["tests"]
|
|
85
85
|
|
|
86
86
|
[tool.uv.sources.mat73]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "MNutils"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.4"
|
|
4
4
|
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Sebastian Bauer", email = "28663646+bastigw@users.noreply.github.com"}
|
|
@@ -25,7 +25,6 @@ classifiers = [
|
|
|
25
25
|
requires-python = ">=3.12"
|
|
26
26
|
|
|
27
27
|
dependencies = [
|
|
28
|
-
"dcm2niiw>=0.1.5",
|
|
29
28
|
"ipython>=8.0",
|
|
30
29
|
"loguru>=0.7.3",
|
|
31
30
|
"mat73",
|
|
@@ -46,7 +45,6 @@ Repository = "https://github.com/bastigw/mnutils"
|
|
|
46
45
|
Issues = "https://github.com/bastigw/mnutils/issues"
|
|
47
46
|
|
|
48
47
|
[project.optional-dependencies]
|
|
49
|
-
2025a = ["matlabengine==25.1.*"]
|
|
50
48
|
bet = ["hd-bet>=2.0.1", "torch>=2.10.0"]
|
|
51
49
|
|
|
52
50
|
[project.scripts]
|
|
@@ -57,7 +55,7 @@ test-mnutils-gen = "mnutils._scripts:generate_test_notebooks" # docs/*.md -> te
|
|
|
57
55
|
test-mnutils = "mnutils._scripts:run_tests" # test-gen, then run the full pytest suite
|
|
58
56
|
|
|
59
57
|
[build-system]
|
|
60
|
-
requires = ["uv_build>=0.8.13,<0.
|
|
58
|
+
requires = ["uv_build>=0.8.13,<0.14.0"]
|
|
61
59
|
build-backend = "uv_build"
|
|
62
60
|
|
|
63
61
|
[dependency-groups]
|
|
@@ -65,6 +63,7 @@ dev = [
|
|
|
65
63
|
# --- Notebooks ---
|
|
66
64
|
"ipykernel>=7.1.0",
|
|
67
65
|
"ipywidgets>=8.1.7",
|
|
66
|
+
"python-dotenv",
|
|
68
67
|
# --- Testing ---
|
|
69
68
|
"nbmake>=1.5.5",
|
|
70
69
|
"pytest>=8.4.1",
|
|
@@ -76,10 +75,12 @@ dev = [
|
|
|
76
75
|
"griffe<0.40.0", # Explicit pin to fix quartodoc parsing bug
|
|
77
76
|
"jupyter-server>=2.17.0", # Required for rendering docs
|
|
78
77
|
"jupytext>=1.19.1",
|
|
78
|
+
"pytest-xdist>=3.8.0",
|
|
79
79
|
]
|
|
80
80
|
|
|
81
81
|
[tool.pytest.ini_options]
|
|
82
|
-
|
|
82
|
+
# See conftest.py for further details
|
|
83
|
+
addopts = "--nbmake -n auto -v --durations=0"
|
|
83
84
|
testpaths = ["tests"]
|
|
84
85
|
|
|
85
86
|
# [tool.uv]
|
|
@@ -16,6 +16,11 @@ from .utils import file_helpers
|
|
|
16
16
|
ANATOMICAL_SERIES_TO_SKIP = ["FGRE", "localizer", "GE_HOS", "localiser"]
|
|
17
17
|
|
|
18
18
|
|
|
19
|
+
def _normalise_description(description: object) -> str:
|
|
20
|
+
"""Lower-case, spaces as underscores: ``MRS washin`` -> ``mrs_washin``."""
|
|
21
|
+
return description.strip().lower().replace(" ", "_") if isinstance(description, str) else ""
|
|
22
|
+
|
|
23
|
+
|
|
19
24
|
class ExamBase:
|
|
20
25
|
"""Base class for a GE exam, loading series metadata and providing series access."""
|
|
21
26
|
|
|
@@ -69,60 +74,45 @@ class ExamBase:
|
|
|
69
74
|
"""Iterate over loaded series as (series ID, series object) pairs."""
|
|
70
75
|
yield from self.all.items()
|
|
71
76
|
|
|
77
|
+
def _ids_with_description(self, fragment: str) -> npt.NDArray[np.int_]:
|
|
78
|
+
"""Series IDs whose normalised description contains `fragment` (also normalised)."""
|
|
79
|
+
normalised = self.exam_overview["Description"].map(_normalise_description)
|
|
80
|
+
mask = normalised.str.contains(_normalise_description(fragment), regex=False)
|
|
81
|
+
return self.exam_overview[mask].index.to_numpy().astype(int)
|
|
82
|
+
|
|
72
83
|
def _create_series_dict(self) -> dict[int, type[GESeries.MRISeries]]:
|
|
84
|
+
"""Pick a series class per series from its description, never from a folder name."""
|
|
73
85
|
series_class_dict = {}
|
|
74
86
|
for series_num in self.series:
|
|
75
|
-
|
|
87
|
+
raw_description = self.exam_overview.at[series_num, "Description"]
|
|
88
|
+
description = _normalise_description(raw_description)
|
|
76
89
|
has_exam = self.exam_overview.at[series_num, "Has Exam"]
|
|
77
|
-
has_dicom = self.exam_overview.at[series_num, "Has DICOM"]
|
|
78
|
-
|
|
79
|
-
if has_exam and not has_dicom:
|
|
80
|
-
logger.debug(
|
|
81
|
-
f"Series {series_num} does not have DICOM. Assuming it is an MRS series."
|
|
82
|
-
)
|
|
83
|
-
self.exam_overview.at[series_num, "Folder Name"] = "ASSUMING MRS"
|
|
84
|
-
series_class_dict[series_num] = GESeries.MRSSeries
|
|
85
|
-
continue
|
|
86
|
-
|
|
87
|
-
# Make sure folder name is a string
|
|
88
|
-
if isinstance(folder_name, str) and has_dicom:
|
|
89
|
-
folder_name = folder_name.strip().lower()
|
|
90
|
-
else:
|
|
91
|
-
logger.warning(
|
|
92
|
-
f"Folder name for series {series_num} is not a string: {folder_name}. Skipping."
|
|
93
|
-
)
|
|
94
|
-
continue
|
|
95
|
-
|
|
96
|
-
if not has_exam and not has_dicom:
|
|
97
|
-
logger.debug(f"Series {series_num} does not have exam or DICOM data. Skipping")
|
|
98
|
-
continue
|
|
99
90
|
|
|
100
|
-
if
|
|
101
|
-
|
|
102
|
-
# Based on the folder name determine if it is anatomical data we want to load
|
|
103
|
-
if any(skip_name.lower() in folder_name for skip_name in ANATOMICAL_SERIES_TO_SKIP):
|
|
91
|
+
if not has_exam:
|
|
92
|
+
if any(skip_name.lower() in description for skip_name in ANATOMICAL_SERIES_TO_SKIP):
|
|
104
93
|
logger.debug(f"Series {series_num} is anatomical data to skip.")
|
|
105
94
|
continue
|
|
106
95
|
series_class_dict[series_num] = GESeries.MRISeries
|
|
107
96
|
continue
|
|
108
97
|
|
|
109
|
-
if
|
|
110
|
-
logger.
|
|
111
|
-
f"Series {series_num} has
|
|
98
|
+
if not description:
|
|
99
|
+
logger.debug(
|
|
100
|
+
f"Series {series_num} has no series description. Assuming it is an MRS series."
|
|
101
|
+
)
|
|
102
|
+
series_class_dict[series_num] = GESeries.MRSSeries
|
|
103
|
+
elif "mrs_washin" in description:
|
|
104
|
+
series_class_dict[series_num] = GESeries.MRSWashinSeries
|
|
105
|
+
elif "mrsi" in description:
|
|
106
|
+
series_class_dict[series_num] = GESeries.MRSISeries
|
|
107
|
+
elif "mrs_" in description:
|
|
108
|
+
series_class_dict[series_num] = GESeries.MRSSeries
|
|
109
|
+
elif "bs_prescan" in description:
|
|
110
|
+
continue # Skip the bs_prescan series
|
|
111
|
+
else:
|
|
112
|
+
logger.warning(
|
|
113
|
+
f"Series {series_num} with description '{raw_description}' does not "
|
|
114
|
+
f"match any known series types. Skipping."
|
|
112
115
|
)
|
|
113
|
-
if "mrs_washin" in folder_name:
|
|
114
|
-
series_class_dict[series_num] = GESeries.MRSWashinSeries
|
|
115
|
-
elif "mrsi_pseudo" in folder_name:
|
|
116
|
-
series_class_dict[series_num] = GESeries.MRSISeries
|
|
117
|
-
elif "mrs_" in folder_name:
|
|
118
|
-
series_class_dict[series_num] = GESeries.MRSSeries
|
|
119
|
-
elif "bs_prescan" in folder_name:
|
|
120
|
-
continue # Skip the bs_prescan series
|
|
121
|
-
else:
|
|
122
|
-
logger.warning(
|
|
123
|
-
f"Series {series_num} with folder name '{folder_name}' does not "
|
|
124
|
-
f"match any known series types. Skipping."
|
|
125
|
-
)
|
|
126
116
|
|
|
127
117
|
return series_class_dict
|
|
128
118
|
|
|
@@ -190,33 +180,22 @@ class DMIExam(ExamBase):
|
|
|
190
180
|
identifier = ANATOMICAL_series.strip().lower()
|
|
191
181
|
else:
|
|
192
182
|
identifier = "t1_bravo"
|
|
193
|
-
self.ANATOMICAL_series = (
|
|
194
|
-
self.exam_overview[
|
|
195
|
-
self.exam_overview["Folder Name"].str.contains(identifier, na=False, case=False)
|
|
196
|
-
]
|
|
197
|
-
.index.to_numpy()
|
|
198
|
-
.astype(int)[0]
|
|
199
|
-
)
|
|
183
|
+
self.ANATOMICAL_series = self._ids_with_description(identifier)[0]
|
|
200
184
|
|
|
201
|
-
self.MRS_series = (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
]
|
|
205
|
-
.index.to_numpy()
|
|
206
|
-
.astype(int)
|
|
207
|
-
)
|
|
208
|
-
# By default append all series where Has Dicom in the exam overview table is False
|
|
209
|
-
no_DICOM_series = (
|
|
210
|
-
self.exam_overview[~self.exam_overview["Has DICOM"]].index.to_numpy().astype(int)
|
|
185
|
+
self.MRS_series = self._ids_with_description("MRS_unloc")
|
|
186
|
+
# By default append all series without a NIfTI (raw data only)
|
|
187
|
+
no_nifti_series = (
|
|
188
|
+
self.exam_overview[~self.exam_overview["Has NIfTI"]].index.to_numpy().astype(int)
|
|
211
189
|
)
|
|
212
|
-
self.MRS_series = np.union1d(self.MRS_series,
|
|
213
|
-
|
|
214
|
-
self.MRSI_series = (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
190
|
+
self.MRS_series = np.union1d(self.MRS_series, no_nifti_series)
|
|
191
|
+
|
|
192
|
+
self.MRSI_series = np.array(
|
|
193
|
+
[
|
|
194
|
+
series_id
|
|
195
|
+
for series_id, series_class in self.series_dict.items()
|
|
196
|
+
if issubclass(series_class, GESeries.MRSISeries)
|
|
197
|
+
],
|
|
198
|
+
dtype=int,
|
|
220
199
|
)
|
|
221
200
|
|
|
222
201
|
self.anatomical: GESeries.MRISeries
|
|
@@ -253,9 +232,9 @@ class DMIExam(ExamBase):
|
|
|
253
232
|
logger.warning(
|
|
254
233
|
f"Multiple anatomical datasets detected. "
|
|
255
234
|
f"Previous: Series ID {self.anatomical.SERIES_ID}, "
|
|
256
|
-
f"Name '{self.exam_overview.at[self.anatomical.SERIES_ID, '
|
|
235
|
+
f"Name '{self.exam_overview.at[self.anatomical.SERIES_ID, 'Description']}'."
|
|
257
236
|
f" New: Series ID {series_obj.SERIES_ID}, "
|
|
258
|
-
f"Name '{self.exam_overview.at[series_obj.SERIES_ID, '
|
|
237
|
+
f"Name '{self.exam_overview.at[series_obj.SERIES_ID, 'Description']}'."
|
|
259
238
|
)
|
|
260
239
|
self.anatomical = series_obj
|
|
261
240
|
|
|
@@ -333,13 +312,7 @@ class DMIinjExam(DMIExam):
|
|
|
333
312
|
) -> None:
|
|
334
313
|
super().__init__(BASE_FOLDER, DATA_FOLDER, OUTPUT_FOLDER, ANATOMICAL_series)
|
|
335
314
|
|
|
336
|
-
self.WASHIN_series = (
|
|
337
|
-
self.exam_overview[
|
|
338
|
-
self.exam_overview["Folder Name"].str.contains("MRS_washin", na=False)
|
|
339
|
-
]
|
|
340
|
-
.index.to_numpy()
|
|
341
|
-
.astype(int)[0]
|
|
342
|
-
)
|
|
315
|
+
self.WASHIN_series = self._ids_with_description("MRS_washin")[0]
|
|
343
316
|
|
|
344
317
|
# Define pre and post series based on wash-in series
|
|
345
318
|
self.pre_MRS_series, self.post_MRS_series = self._split_series(
|
|
@@ -5,11 +5,9 @@ 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
|
|
12
|
-
import pyAMARES
|
|
13
11
|
import xarray as xr
|
|
14
12
|
import xmris # noqa: F401, necessary for xarray accessor to work
|
|
15
13
|
from loguru import logger
|
|
@@ -96,13 +94,16 @@ class NiiBase:
|
|
|
96
94
|
else:
|
|
97
95
|
affine = self.nii.affine
|
|
98
96
|
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
97
|
+
# new_data's axes already match the index space `affine` describes (e.g. spec's own
|
|
98
|
+
# (i, j, k) grid, scaled by create_MRSI_affine()) -- no reorientation is warranted here.
|
|
99
|
+
# A previous version of this method applied a hardcoded apply_orientation(..., ("P", "L",
|
|
100
|
+
# "S")) permute+flip unconditionally, silently decoupling the stored array from the affine
|
|
101
|
+
# it was paired with (e.g. RAW_exp's data ending up several voxels from where its own
|
|
102
|
+
# affine said it was).
|
|
103
|
+
image_data = np.asarray(new_data)
|
|
103
104
|
|
|
104
105
|
new_nii = self.nii.__class__(
|
|
105
|
-
|
|
106
|
+
image_data, # pyright: ignore[reportArgumentType]
|
|
106
107
|
affine=affine.copy(),
|
|
107
108
|
header=self.nii.header,
|
|
108
109
|
extra=self.nii.extra,
|
|
@@ -146,6 +147,10 @@ class NiiBase:
|
|
|
146
147
|
except ValueError as e:
|
|
147
148
|
logger.error(f"Error applying mask: {e}. Displaying unmasked images.")
|
|
148
149
|
|
|
150
|
+
kwargs.setdefault(
|
|
151
|
+
"zooms",
|
|
152
|
+
nifti.get_display_zooms(self, orientation=orientation, display_plane=display_plane),
|
|
153
|
+
)
|
|
149
154
|
plotting.images.display_images(images, **kwargs)
|
|
150
155
|
|
|
151
156
|
def overlay_on_T1(
|
|
@@ -277,7 +282,7 @@ class NiiBase:
|
|
|
277
282
|
|
|
278
283
|
|
|
279
284
|
class MRISeries(NiiBase):
|
|
280
|
-
"""A reconstructed MRI series, loaded as a NIfTI
|
|
285
|
+
"""A reconstructed MRI series, loaded as a NIfTI (found by its sidecar's SeriesNumber)."""
|
|
281
286
|
|
|
282
287
|
def __init__(
|
|
283
288
|
self,
|
|
@@ -289,9 +294,7 @@ class MRISeries(NiiBase):
|
|
|
289
294
|
self.OUTPUT_FOLDER = (DATA_FOLDER / ".." / "output").resolve()
|
|
290
295
|
self.SERIES_ID = SERIES_ID
|
|
291
296
|
try:
|
|
292
|
-
nii_path: Path = file_helpers.get_niftis_from_series(
|
|
293
|
-
self.DATA_FOLDER, self.SERIES_ID, convert_dicoms=True
|
|
294
|
-
)
|
|
297
|
+
nii_path: Path = file_helpers.get_niftis_from_series(self.DATA_FOLDER, self.SERIES_ID)
|
|
295
298
|
super().__init__(nii_path, orientation)
|
|
296
299
|
except (ValueError, FileNotFoundError) as e:
|
|
297
300
|
logger.warning(
|
|
@@ -301,17 +304,71 @@ class MRISeries(NiiBase):
|
|
|
301
304
|
|
|
302
305
|
|
|
303
306
|
class RawMRISeries(MRISeries):
|
|
304
|
-
"""An MRI series with the associated raw reconstructed .mat data loaded.
|
|
307
|
+
"""An MRI series with the associated raw reconstructed .mat data loaded.
|
|
305
308
|
|
|
306
|
-
|
|
309
|
+
The ``.mat`` is optional at construction: if none is found, a warning is logged and
|
|
310
|
+
`mat_path` is ``None``. Using the data (`recon`, `bb`, header properties) then raises.
|
|
311
|
+
Check `has_mat` first when a series may lack one.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(
|
|
315
|
+
self, DATA_FOLDER: Path, SERIES_ID: int, mat_path: str | Path | None = None
|
|
316
|
+
) -> None:
|
|
307
317
|
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
308
|
-
self.mat_path: Path
|
|
309
|
-
|
|
318
|
+
self.mat_path: Path | None
|
|
319
|
+
self.header: dict
|
|
320
|
+
if mat_path is not None:
|
|
321
|
+
self.mat_path = Path(mat_path)
|
|
322
|
+
else:
|
|
323
|
+
try:
|
|
324
|
+
self.mat_path = file_helpers.get_mat_data_from_series(
|
|
325
|
+
self.DATA_FOLDER, self.SERIES_ID
|
|
326
|
+
)
|
|
327
|
+
except FileNotFoundError as e:
|
|
328
|
+
logger.warning(
|
|
329
|
+
f"Could not find a .mat file for series {self.SERIES_ID}. "
|
|
330
|
+
f"Proceeding without raw data.\nError: {e}"
|
|
331
|
+
)
|
|
332
|
+
self.mat_path = None
|
|
333
|
+
# Only the header fields MNUtils reads: a v7.3 header is ~1600 HDF5 datasets (~0.45 s).
|
|
334
|
+
self.header = (
|
|
335
|
+
data_loaders.read_mat_header(self.mat_path) if self.mat_path is not None else {}
|
|
310
336
|
)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def has_mat(self) -> bool:
|
|
340
|
+
"""Whether this series has a ``.mat`` file to read raw data and header from."""
|
|
341
|
+
return self.mat_path is not None
|
|
342
|
+
|
|
343
|
+
def _require_mat(self) -> Path:
|
|
344
|
+
if self.mat_path is None:
|
|
345
|
+
raise FileNotFoundError(
|
|
346
|
+
f"Series {self.SERIES_ID} has no .mat file (looked in {self.DATA_FOLDER})."
|
|
347
|
+
)
|
|
348
|
+
return self.mat_path
|
|
349
|
+
|
|
350
|
+
@cached_property
|
|
351
|
+
def recon(self) -> dict:
|
|
352
|
+
"""Every top-level variable of the ``.mat`` file, with ``h`` pruned to `self.header`.
|
|
353
|
+
|
|
354
|
+
Loaded on first access, not at construction, so reading only the header stays cheap.
|
|
355
|
+
The full ``h`` (~1600 datasets on v7.3) is skipped; use ``load_mat_file`` for it.
|
|
356
|
+
"""
|
|
357
|
+
mat_path = self._require_mat()
|
|
358
|
+
names = [n for n in data_loaders.list_mat_variables(mat_path) if n != "h"]
|
|
359
|
+
recon = data_loaders.load_mat_file(mat_path, variable_names=names)
|
|
360
|
+
recon["h"] = self.header
|
|
361
|
+
return recon
|
|
362
|
+
|
|
363
|
+
@cached_property
|
|
364
|
+
def bb(self) -> np.ndarray:
|
|
365
|
+
"""The reconstructed image, or an empty array if the file has none."""
|
|
366
|
+
return self.recon.get("bb", np.array([]))
|
|
367
|
+
|
|
368
|
+
@cached_property
|
|
369
|
+
def bbabs(self) -> np.ndarray:
|
|
370
|
+
"""The magnitude image: ``bbabs`` from the file, else ``abs(bb)``."""
|
|
371
|
+
return self.recon.get("bbabs", np.abs(self.bb))
|
|
315
372
|
|
|
316
373
|
@cached_property
|
|
317
374
|
def fids(self) -> xr.DataArray:
|
|
@@ -591,8 +648,10 @@ class RawMRISeries(MRISeries):
|
|
|
591
648
|
def _get_header_value(self, key_path: list[str]) -> None | str | np.double | npt.NDArray:
|
|
592
649
|
"""Traverse the header dictionary using a key path.
|
|
593
650
|
|
|
594
|
-
Raises AttributeError if the path is invalid
|
|
651
|
+
Raises AttributeError if the path is invalid, FileNotFoundError if the series has no
|
|
652
|
+
``.mat`` (and so no header).
|
|
595
653
|
"""
|
|
654
|
+
self._require_mat()
|
|
596
655
|
value = self.header
|
|
597
656
|
try:
|
|
598
657
|
for key in key_path:
|
|
@@ -626,11 +685,13 @@ class MRSISeries(RawMRISeries):
|
|
|
626
685
|
) -> None:
|
|
627
686
|
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
628
687
|
# Expect spec and header to be present
|
|
629
|
-
spec_data = self.recon.get("spec", np.ndarray([]))
|
|
688
|
+
spec_data = self.recon.get("spec", np.ndarray([])) if self.has_mat else np.empty(0)
|
|
630
689
|
if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
690
|
+
if self.has_mat: # without a .mat the missing data was already warned about
|
|
691
|
+
logger.error(
|
|
692
|
+
"Expected 'spec' to be a non-empty numpy array. "
|
|
693
|
+
"Not initializing object properly"
|
|
694
|
+
)
|
|
634
695
|
else:
|
|
635
696
|
self.spec: xr.DataArray = xr.DataArray(
|
|
636
697
|
spec_data,
|
|
@@ -695,6 +756,8 @@ class MRSISeries(RawMRISeries):
|
|
|
695
756
|
@cached_property
|
|
696
757
|
def SNR_map(self) -> xr.DataArray:
|
|
697
758
|
"""The per-voxel SNR, computed from the FIDs via pyAMARES.fidSNR."""
|
|
759
|
+
import pyAMARES
|
|
760
|
+
|
|
698
761
|
return xr.apply_ufunc(
|
|
699
762
|
pyAMARES.fidSNR, self.fids, input_core_dims=[["time"]], vectorize=True
|
|
700
763
|
)
|
|
@@ -745,13 +808,12 @@ class MRSISeries(RawMRISeries):
|
|
|
745
808
|
# Round voxel sizes to 3 decimal places to avoid floating point issues
|
|
746
809
|
new_affine[0:3, 0:3] = np.round(new_affine[0:3, 0:3], 3)
|
|
747
810
|
|
|
748
|
-
# Offset x and y by centre offset
|
|
749
|
-
#
|
|
750
|
-
#
|
|
811
|
+
# Offset x and y by centre offset.
|
|
812
|
+
# Both affines use the standard NIfTI convention (index i's *center* sits at
|
|
813
|
+
# world = voxel_size * i + translation), and both describe the same field of view at
|
|
814
|
+
# different resolutions, so their voxel-0 centers differ by exactly half the voxel-size
|
|
815
|
+
# change: new_translation = old_translation + (new_voxel - old_voxel) / 2.
|
|
751
816
|
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
817
|
logger.trace(
|
|
756
818
|
f"Calculated shift for new affine: {shift}. "
|
|
757
819
|
f"Original affine translation: {self.nii.affine[:3, 3]}"
|
|
@@ -1259,11 +1321,13 @@ class MRSSeries(RawMRISeries):
|
|
|
1259
1321
|
|
|
1260
1322
|
def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
|
|
1261
1323
|
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
1262
|
-
spec_data = self.recon.get("spec", np.ndarray([]))
|
|
1324
|
+
spec_data = self.recon.get("spec", np.ndarray([])) if self.has_mat else np.empty(0)
|
|
1263
1325
|
if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1326
|
+
if self.has_mat: # without a .mat the missing data was already warned about
|
|
1327
|
+
logger.error(
|
|
1328
|
+
"Expected 'spec' to be a non-empty numpy array. "
|
|
1329
|
+
"Not initializing object properly"
|
|
1330
|
+
)
|
|
1267
1331
|
else:
|
|
1268
1332
|
# This assumes no zero filling was applied yet
|
|
1269
1333
|
self.npts: int = spec_data.shape[1]
|
|
@@ -8,6 +8,11 @@ reachable without knowing the internal module layout::
|
|
|
8
8
|
exam = mnutils.DMIExam("/data/exam")
|
|
9
9
|
mnutils.plotting.images.display_images(exam.anatomical[0].data)
|
|
10
10
|
|
|
11
|
+
Package-wide defaults live in `mnutils.rcParams`, which works like
|
|
12
|
+
matplotlib's::
|
|
13
|
+
|
|
14
|
+
mnutils.rcParams["image.cmap"] = "viridis"
|
|
15
|
+
|
|
11
16
|
`mnutils.matlab` is deliberately *not* imported here: it needs the optional
|
|
12
17
|
`matlabengine` dependency, so import it explicitly when you need it.
|
|
13
18
|
"""
|
|
@@ -24,6 +29,7 @@ from .GESeries import (
|
|
|
24
29
|
NiiBase,
|
|
25
30
|
RawMRISeries,
|
|
26
31
|
)
|
|
32
|
+
from .rcparams import RcParams, rc_context, rc_presets, rcdefaults, rcParams, rcParamsDefault
|
|
27
33
|
|
|
28
34
|
try:
|
|
29
35
|
__version__ = version("mnutils")
|
|
@@ -50,5 +56,12 @@ __all__ = [
|
|
|
50
56
|
"MRSISeries",
|
|
51
57
|
"MRSSeries",
|
|
52
58
|
"MRSWashinSeries",
|
|
59
|
+
# configuration
|
|
60
|
+
"RcParams",
|
|
61
|
+
"rcParams",
|
|
62
|
+
"rcParamsDefault",
|
|
63
|
+
"rc_context",
|
|
64
|
+
"rc_presets",
|
|
65
|
+
"rcdefaults",
|
|
53
66
|
"__version__",
|
|
54
67
|
]
|