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.
Files changed (44) hide show
  1. {mnutils-1.2.2 → mnutils-1.2.4}/PKG-INFO +4 -10
  2. {mnutils-1.2.2 → mnutils-1.2.4}/README.md +3 -6
  3. {mnutils-1.2.2 → mnutils-1.2.4}/pyproject.toml +5 -5
  4. {mnutils-1.2.2 → mnutils-1.2.4}/pyproject.toml.orig +6 -5
  5. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/GEExam.py +50 -77
  6. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/GESeries.py +98 -34
  7. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/__init__.py +13 -0
  8. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/_scripts.py +34 -16
  9. mnutils-1.2.4/src/mnutils/fitting/__init__.py +16 -0
  10. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/__init__.py +1 -13
  11. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.css +21 -5
  12. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.js +12 -1
  13. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/image_grid.py +8 -0
  14. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/images.py +267 -96
  15. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/saving.py +23 -45
  16. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/spectra.py +18 -17
  17. mnutils-1.2.4/src/mnutils/rcparams.py +449 -0
  18. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/testing/_spectra.py +88 -30
  19. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/testing/synthetic.py +187 -37
  20. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/data_loaders.py +163 -5
  21. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/file_helpers.py +311 -179
  22. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/images.py +9 -9
  23. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/nifti.py +277 -239
  24. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/partial_volume.py +105 -20
  25. mnutils-1.2.2/src/mnutils/fitting/__init__.py +0 -5
  26. {mnutils-1.2.2 → mnutils-1.2.4}/LICENSE +0 -0
  27. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/fitting/AMARES.py +0 -0
  28. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/matlab/__init__.py +0 -0
  29. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/matlab/setup_matlab.py +0 -0
  30. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/__init__.py +0 -0
  31. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_html.py +0 -0
  32. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/__init__.py +0 -0
  33. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/dom.js +0 -0
  34. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/_shared/theme.css +0 -0
  35. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.css +0 -0
  36. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.js +0 -0
  37. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/mrsi_inspector.py +0 -0
  38. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.css +0 -0
  39. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.js +0 -0
  40. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/plotting/_widgets/slice_viewer.py +0 -0
  41. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/py.typed +0 -0
  42. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/testing/__init__.py +0 -0
  43. {mnutils-1.2.2 → mnutils-1.2.4}/src/mnutils/utils/__init__.py +0 -0
  44. {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.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/DICOM conversion** — convert scanner output to 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
- `2025a` pins `matlabengine==25.1.*`. If your local MATLAB install is a different version, install
91
- the matching `matlabengine` release yourself instead (see [MATLAB Engine
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/DICOM conversion** — convert scanner output to 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
- `2025a` pins `matlabengine==25.1.*`. If your local MATLAB install is a different version, install
51
- the matching `matlabengine` release yourself instead (see [MATLAB Engine
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.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.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 = "--capture=no --nbmake"
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.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.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
- addopts = "--capture=no --nbmake"
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
- folder_name = self.exam_overview.at[series_num, "Folder Name"]
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 has_dicom and not has_exam:
101
- logger.trace(f"Series {series_num} does not have raw data.")
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 has_exam and has_dicom:
110
- logger.trace(
111
- f"Series {series_num} has both exam and DICOM data. Checking folder name."
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
- self.exam_overview[
203
- self.exam_overview["Folder Name"].str.contains("MRS_unloc", na=False)
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, no_DICOM_series)
213
-
214
- self.MRSI_series = (
215
- self.exam_overview[
216
- self.exam_overview["Folder Name"].str.contains("MRSI_pseudo", na=False)
217
- ]
218
- .index.to_numpy()
219
- .astype(int)
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, 'Folder Name']}'."
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, 'Folder Name']}'."
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
- # Image data is to be oriented the following
100
- orientated_data = nib.orientations.apply_orientation(
101
- new_data, nib.orientations.axcodes2ornt(("P", "L", "S"))
102
- )
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
- orientated_data, # pyright: ignore[reportArgumentType]
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 from a scan's DATA_FOLDER."""
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
- def __init__(self, DATA_FOLDER: Path, SERIES_ID: int) -> None:
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 = file_helpers.get_mat_data_from_series(
309
- self.DATA_FOLDER, self.SERIES_ID
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
- self.recon: dict = data_loaders.load_mat_file(self.mat_path)
312
- self.header: dict = self.recon.get("h", {})
313
- self.bb = self.recon.get("bb", np.array([]))
314
- self.bbabs = self.recon.get("bbabs", np.abs(self.bb))
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
- logger.error(
632
- "Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
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
- # The shift is important as we want to make sure the overlay is in the correct position
750
- # The correct shift is the following
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
- logger.error(
1265
- "Expected 'spec' to be a non-empty numpy array. Not initializing object properly"
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
  ]