MNutils 1.2.3__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.3 → mnutils-1.2.4}/PKG-INFO +4 -10
- {mnutils-1.2.3 → mnutils-1.2.4}/README.md +3 -6
- {mnutils-1.2.3 → mnutils-1.2.4}/pyproject.toml +4 -5
- {mnutils-1.2.3 → mnutils-1.2.4}/pyproject.toml.orig +5 -5
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/GEExam.py +50 -77
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/GESeries.py +81 -22
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/__init__.py +13 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/_scripts.py +34 -16
- mnutils-1.2.4/src/mnutils/fitting/__init__.py +16 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/__init__.py +1 -13
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.css +21 -5
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.js +7 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.py +8 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/images.py +228 -96
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/saving.py +23 -45
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/spectra.py +18 -17
- mnutils-1.2.4/src/mnutils/rcparams.py +449 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/testing/synthetic.py +176 -34
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/data_loaders.py +163 -5
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/file_helpers.py +305 -175
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/images.py +9 -9
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/nifti.py +277 -287
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/partial_volume.py +105 -20
- mnutils-1.2.3/src/mnutils/fitting/__init__.py +0 -5
- {mnutils-1.2.3 → mnutils-1.2.4}/LICENSE +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/fitting/AMARES.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/matlab/__init__.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/matlab/setup_matlab.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/__init__.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_html.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/py.typed +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/testing/__init__.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/testing/_spectra.py +0 -0
- {mnutils-1.2.3 → mnutils-1.2.4}/src/mnutils/utils/__init__.py +0 -0
- {mnutils-1.2.3 → 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,7 +60,7 @@ 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]
|
|
@@ -78,10 +76,11 @@ dev = [
|
|
|
78
76
|
"griffe<0.40.0",
|
|
79
77
|
"jupyter-server>=2.17.0",
|
|
80
78
|
"jupytext>=1.19.1",
|
|
79
|
+
"pytest-xdist>=3.8.0",
|
|
81
80
|
]
|
|
82
81
|
|
|
83
82
|
[tool.pytest.ini_options]
|
|
84
|
-
addopts = "--
|
|
83
|
+
addopts = "--nbmake -n auto -v --durations=0"
|
|
85
84
|
testpaths = ["tests"]
|
|
86
85
|
|
|
87
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]
|
|
@@ -77,10 +75,12 @@ dev = [
|
|
|
77
75
|
"griffe<0.40.0", # Explicit pin to fix quartodoc parsing bug
|
|
78
76
|
"jupyter-server>=2.17.0", # Required for rendering docs
|
|
79
77
|
"jupytext>=1.19.1",
|
|
78
|
+
"pytest-xdist>=3.8.0",
|
|
80
79
|
]
|
|
81
80
|
|
|
82
81
|
[tool.pytest.ini_options]
|
|
83
|
-
|
|
82
|
+
# See conftest.py for further details
|
|
83
|
+
addopts = "--nbmake -n auto -v --durations=0"
|
|
84
84
|
testpaths = ["tests"]
|
|
85
85
|
|
|
86
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(
|
|
@@ -8,7 +8,6 @@ import h5py
|
|
|
8
8
|
import numpy as np
|
|
9
9
|
import numpy.typing as npt
|
|
10
10
|
import pandas as pd
|
|
11
|
-
import pyAMARES
|
|
12
11
|
import xarray as xr
|
|
13
12
|
import xmris # noqa: F401, necessary for xarray accessor to work
|
|
14
13
|
from loguru import logger
|
|
@@ -283,7 +282,7 @@ class NiiBase:
|
|
|
283
282
|
|
|
284
283
|
|
|
285
284
|
class MRISeries(NiiBase):
|
|
286
|
-
"""A reconstructed MRI series, loaded as a NIfTI
|
|
285
|
+
"""A reconstructed MRI series, loaded as a NIfTI (found by its sidecar's SeriesNumber)."""
|
|
287
286
|
|
|
288
287
|
def __init__(
|
|
289
288
|
self,
|
|
@@ -295,9 +294,7 @@ class MRISeries(NiiBase):
|
|
|
295
294
|
self.OUTPUT_FOLDER = (DATA_FOLDER / ".." / "output").resolve()
|
|
296
295
|
self.SERIES_ID = SERIES_ID
|
|
297
296
|
try:
|
|
298
|
-
nii_path: Path = file_helpers.get_niftis_from_series(
|
|
299
|
-
self.DATA_FOLDER, self.SERIES_ID, convert_dicoms=True
|
|
300
|
-
)
|
|
297
|
+
nii_path: Path = file_helpers.get_niftis_from_series(self.DATA_FOLDER, self.SERIES_ID)
|
|
301
298
|
super().__init__(nii_path, orientation)
|
|
302
299
|
except (ValueError, FileNotFoundError) as e:
|
|
303
300
|
logger.warning(
|
|
@@ -307,17 +304,71 @@ class MRISeries(NiiBase):
|
|
|
307
304
|
|
|
308
305
|
|
|
309
306
|
class RawMRISeries(MRISeries):
|
|
310
|
-
"""An MRI series with the associated raw reconstructed .mat data loaded.
|
|
307
|
+
"""An MRI series with the associated raw reconstructed .mat data loaded.
|
|
311
308
|
|
|
312
|
-
|
|
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:
|
|
313
317
|
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
314
|
-
self.mat_path: Path
|
|
315
|
-
|
|
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 {}
|
|
316
336
|
)
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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))
|
|
321
372
|
|
|
322
373
|
@cached_property
|
|
323
374
|
def fids(self) -> xr.DataArray:
|
|
@@ -597,8 +648,10 @@ class RawMRISeries(MRISeries):
|
|
|
597
648
|
def _get_header_value(self, key_path: list[str]) -> None | str | np.double | npt.NDArray:
|
|
598
649
|
"""Traverse the header dictionary using a key path.
|
|
599
650
|
|
|
600
|
-
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).
|
|
601
653
|
"""
|
|
654
|
+
self._require_mat()
|
|
602
655
|
value = self.header
|
|
603
656
|
try:
|
|
604
657
|
for key in key_path:
|
|
@@ -632,11 +685,13 @@ class MRSISeries(RawMRISeries):
|
|
|
632
685
|
) -> None:
|
|
633
686
|
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
634
687
|
# Expect spec and header to be present
|
|
635
|
-
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)
|
|
636
689
|
if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
+
)
|
|
640
695
|
else:
|
|
641
696
|
self.spec: xr.DataArray = xr.DataArray(
|
|
642
697
|
spec_data,
|
|
@@ -701,6 +756,8 @@ class MRSISeries(RawMRISeries):
|
|
|
701
756
|
@cached_property
|
|
702
757
|
def SNR_map(self) -> xr.DataArray:
|
|
703
758
|
"""The per-voxel SNR, computed from the FIDs via pyAMARES.fidSNR."""
|
|
759
|
+
import pyAMARES
|
|
760
|
+
|
|
704
761
|
return xr.apply_ufunc(
|
|
705
762
|
pyAMARES.fidSNR, self.fids, input_core_dims=[["time"]], vectorize=True
|
|
706
763
|
)
|
|
@@ -1264,11 +1321,13 @@ class MRSSeries(RawMRISeries):
|
|
|
1264
1321
|
|
|
1265
1322
|
def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
|
|
1266
1323
|
super().__init__(DATA_FOLDER, SERIES_ID)
|
|
1267
|
-
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)
|
|
1268
1325
|
if not isinstance(spec_data, np.ndarray) or spec_data.size == 0:
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
+
)
|
|
1272
1331
|
else:
|
|
1273
1332
|
# This assumes no zero filling was applied yet
|
|
1274
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
|
]
|
|
@@ -11,6 +11,7 @@ import re
|
|
|
11
11
|
import shutil
|
|
12
12
|
import subprocess
|
|
13
13
|
import sys
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
|
|
16
17
|
|
|
@@ -124,7 +125,24 @@ def docs_all() -> None:
|
|
|
124
125
|
# chapter, mirrored into tests/autogen_notebooks/<chapter>/. Keep in sync with
|
|
125
126
|
# GENRES in .claude/skills/docs-page/check_docs.py -- that file decides which
|
|
126
127
|
# house rules a chapter's pages are held to, this one decides which get run.
|
|
127
|
-
|
|
128
|
+
# `matlab` is deliberately excluded: its pages need a live MATLAB install and
|
|
129
|
+
# a matching `matlabengine` version (see CLAUDE.md's Gotchas), which no CI or
|
|
130
|
+
# contributor machine can be assumed to have -- it isn't run by default.
|
|
131
|
+
TEST_CHAPTERS = ("basics", "data-model", "plotting", "fitting", "nifti")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _convert_to_notebook(md_file: Path, out_dir: Path) -> None:
|
|
135
|
+
subprocess.run(
|
|
136
|
+
[
|
|
137
|
+
"jupytext",
|
|
138
|
+
"--to",
|
|
139
|
+
"notebook",
|
|
140
|
+
"--output",
|
|
141
|
+
str(out_dir / f"{md_file.stem}.ipynb"),
|
|
142
|
+
str(md_file),
|
|
143
|
+
],
|
|
144
|
+
check=True,
|
|
145
|
+
)
|
|
128
146
|
|
|
129
147
|
|
|
130
148
|
def generate_test_notebooks() -> None:
|
|
@@ -135,6 +153,11 @@ def generate_test_notebooks() -> None:
|
|
|
135
153
|
chapter `index.md`, are skipped rather than failing with no kernel to
|
|
136
154
|
start). Output goes under tests/autogen_notebooks/, which is gitignored --
|
|
137
155
|
regenerate it before running pytest, never commit it.
|
|
156
|
+
|
|
157
|
+
Each page is its own `jupytext` subprocess (~0.3-0.5s of interpreter
|
|
158
|
+
startup apiece) with nothing shared between them, so they run in a thread
|
|
159
|
+
pool -- the GIL doesn't matter here since every thread spends its time
|
|
160
|
+
blocked in `subprocess.run`, not executing Python.
|
|
138
161
|
"""
|
|
139
162
|
project_root = _find_project_root()
|
|
140
163
|
docs_dir = project_root / "docs"
|
|
@@ -143,26 +166,21 @@ def generate_test_notebooks() -> None:
|
|
|
143
166
|
if out_root.exists():
|
|
144
167
|
shutil.rmtree(out_root)
|
|
145
168
|
|
|
169
|
+
jobs: list[tuple[Path, Path]] = []
|
|
146
170
|
for chapter in TEST_CHAPTERS:
|
|
147
171
|
chapter_dir = docs_dir / chapter
|
|
148
172
|
if not chapter_dir.is_dir():
|
|
149
173
|
continue
|
|
174
|
+
out_dir = out_root / chapter
|
|
150
175
|
for md_file in sorted(chapter_dir.glob("*.md")):
|
|
151
|
-
if
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"notebook",
|
|
160
|
-
"--output",
|
|
161
|
-
str(out_dir / f"{md_file.stem}.ipynb"),
|
|
162
|
-
str(md_file),
|
|
163
|
-
],
|
|
164
|
-
check=True,
|
|
165
|
-
)
|
|
176
|
+
if _is_executable_page(md_file):
|
|
177
|
+
jobs.append((md_file, out_dir))
|
|
178
|
+
|
|
179
|
+
for out_dir in {out_dir for _, out_dir in jobs}:
|
|
180
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
|
|
182
|
+
with ThreadPoolExecutor() as pool:
|
|
183
|
+
list(pool.map(lambda job: _convert_to_notebook(*job), jobs))
|
|
166
184
|
|
|
167
185
|
|
|
168
186
|
def _is_executable_page(md_file: Path) -> bool:
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Spectral fitting, delegated to pyAMARES through a thin wrapper."""
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from . import AMARES
|
|
7
|
+
|
|
8
|
+
__all__ = ["AMARES"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def __getattr__(name: str):
|
|
12
|
+
if name == "AMARES":
|
|
13
|
+
from . import AMARES
|
|
14
|
+
|
|
15
|
+
return AMARES
|
|
16
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|