MNutils 1.2.0__py3-none-any.whl

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 (41) hide show
  1. mnutils/GEExam.py +495 -0
  2. mnutils/GESeries.py +1578 -0
  3. mnutils/__init__.py +54 -0
  4. mnutils/_scripts.py +177 -0
  5. mnutils/fitting/AMARES.py +831 -0
  6. mnutils/fitting/__init__.py +5 -0
  7. mnutils/matlab/__init__.py +13 -0
  8. mnutils/matlab/setup_matlab.py +116 -0
  9. mnutils/plotting/__init__.py +38 -0
  10. mnutils/plotting/_widgets/__init__.py +5 -0
  11. mnutils/plotting/_widgets/_html.py +34 -0
  12. mnutils/plotting/_widgets/_shared/__init__.py +50 -0
  13. mnutils/plotting/_widgets/_shared/dom.js +124 -0
  14. mnutils/plotting/_widgets/_shared/theme.css +140 -0
  15. mnutils/plotting/_widgets/image_grid.css +202 -0
  16. mnutils/plotting/_widgets/image_grid.js +212 -0
  17. mnutils/plotting/_widgets/image_grid.py +86 -0
  18. mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
  19. mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
  20. mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
  21. mnutils/plotting/_widgets/slice_viewer.css +38 -0
  22. mnutils/plotting/_widgets/slice_viewer.js +41 -0
  23. mnutils/plotting/_widgets/slice_viewer.py +35 -0
  24. mnutils/plotting/images.py +1399 -0
  25. mnutils/plotting/saving.py +138 -0
  26. mnutils/plotting/spectra.py +357 -0
  27. mnutils/py.typed +0 -0
  28. mnutils/testing/__init__.py +3 -0
  29. mnutils/testing/_spectra.py +338 -0
  30. mnutils/testing/synthetic.py +373 -0
  31. mnutils/utils/__init__.py +25 -0
  32. mnutils/utils/data_loaders.py +218 -0
  33. mnutils/utils/file_helpers.py +708 -0
  34. mnutils/utils/images.py +202 -0
  35. mnutils/utils/nifti.py +379 -0
  36. mnutils/utils/spectra.py +149 -0
  37. mnutils-1.2.0.dist-info/METADATA +161 -0
  38. mnutils-1.2.0.dist-info/RECORD +41 -0
  39. mnutils-1.2.0.dist-info/WHEEL +4 -0
  40. mnutils-1.2.0.dist-info/entry_points.txt +7 -0
  41. mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,5 @@
1
+ """Spectral fitting, delegated to pyAMARES through a thin wrapper."""
2
+
3
+ from . import AMARES
4
+
5
+ __all__ = ["AMARES"]
@@ -0,0 +1,13 @@
1
+ """MATLAB Engine helpers (requires the optional `matlabengine` dependency)."""
2
+
3
+ from .setup_matlab import (
4
+ add_matlablatest_path,
5
+ connect_to_matlab,
6
+ setup_util_path,
7
+ )
8
+
9
+ __all__ = [
10
+ "add_matlablatest_path",
11
+ "connect_to_matlab",
12
+ "setup_util_path",
13
+ ]
@@ -0,0 +1,116 @@
1
+ import os
2
+
3
+ import matlab.engine
4
+ from loguru import logger
5
+
6
+
7
+ def connect_to_matlab() -> matlab.engine.MatlabEngine:
8
+ """Connect to an existing shared MATLAB session, or start a new one.
9
+
10
+ Make sure to only call this function once per session — a second call
11
+ to connect to the same shared engine raises an `EngineError`.
12
+
13
+ Returns
14
+ -------
15
+ matlab.engine.MatlabEngine
16
+ The connected (or newly started) MATLAB engine instance.
17
+ """
18
+ engines = matlab.engine.find_matlab()
19
+ if engines:
20
+ logger.debug(f"Found running shared MATLAB engines. Connecting to engine {engines[0]}")
21
+ try:
22
+ eng = matlab.engine.connect_matlab(engines[0])
23
+ except matlab.engine.EngineError as e: # type: ignore
24
+ logger.error(f"EngineError while connecting to MATLAB engine {engines[0]}: {e}")
25
+ logger.error("This may be due to multiple connections. Only one connection is allowed.")
26
+ raise
27
+ except (RuntimeError, TypeError, OSError) as e:
28
+ logger.error(f"Failed to connect to MATLAB engine {engines[0]}: {e}")
29
+ raise
30
+ else:
31
+ logger.debug(
32
+ "No running MATLAB engine found. Starting a new MATLAB engine. "
33
+ "This may take a second..."
34
+ )
35
+ eng = matlab.engine.start_matlab()
36
+
37
+ if not isinstance(eng, matlab.engine.MatlabEngine):
38
+ raise TypeError(f"Expected eng to be of type matlab.engine.MatlabEngine, got {type(eng)}")
39
+ logger.debug("Connected to MATLAB engine successfully.")
40
+ return eng
41
+
42
+
43
+ def setup_util_path(
44
+ eng: matlab.engine.MatlabEngine,
45
+ matlab_utils_path: str | None = os.environ.get("MATLAB_UTILS"),
46
+ ) -> None:
47
+ """Add the MATLAB utils path to the MATLAB engine's path, if not already present.
48
+
49
+ Parameters
50
+ ----------
51
+ eng : matlab.engine.MatlabEngine
52
+ MATLAB engine instance.
53
+ matlab_utils_path : str | None
54
+ Path to the MATLAB utils folder. Defaults to the `MATLAB_UTILS` environment
55
+ variable.
56
+ """
57
+ if matlab_utils_path and os.path.exists(matlab_utils_path):
58
+ logger.debug(f"Adding MATLAB utils path: {matlab_utils_path}")
59
+ currentMatlabPath = str(eng.path())
60
+ if matlab_utils_path not in currentMatlabPath.split(":"):
61
+ eng.addpath(matlab_utils_path)
62
+ else:
63
+ logger.debug(f"MATLAB utils path already exists: {matlab_utils_path}")
64
+ else:
65
+ logger.warning(f"MATLAB_UTILS path is not set or does not exist: {matlab_utils_path}")
66
+
67
+
68
+ def add_matlablatest_path(eng: matlab.engine.MatlabEngine) -> None:
69
+ """Add the 'matlablatest' path to the MATLAB environment using `manage_paths`.
70
+
71
+ Parameters
72
+ ----------
73
+ eng : matlab.engine.MatlabEngine
74
+ MATLAB engine instance.
75
+
76
+ Raises
77
+ ------
78
+ ValueError
79
+ If the MATLAB engine instance is invalid, or no 'matlabfiles' paths
80
+ are found after adding 'matlablatest'.
81
+ RuntimeError
82
+ If the 'manage_paths' function is not available in MATLAB.
83
+ """
84
+ try:
85
+ if not isinstance(eng, matlab.engine.MatlabEngine):
86
+ raise ValueError("Invalid MATLAB engine instance provided.")
87
+
88
+ logger.debug("Attempting to add 'matlablatest' path using manage_paths...")
89
+
90
+ # Check if the 'manage_paths' function exists in MATLAB
91
+ if not eng.which("manage_paths"):
92
+ raise RuntimeError("'manage_paths' function not found in MATLAB environment.")
93
+
94
+ # Add the 'matlablatest' path
95
+ eng.manage_paths("matlablatest", True, nargout=0)
96
+
97
+ # Retrieve and log paths containing 'matlabfiles'
98
+ matlabPath = str(eng.path())
99
+ matlabfiles_paths = [p for p in matlabPath.split(":") if "matlabfiles" in p.lower()]
100
+ if not matlabfiles_paths:
101
+ matlab_sources = eng.eval("getenv('MATLAB_SOURCES')", nargout=1)
102
+ raise ValueError(
103
+ f"No 'matlabfiles' paths found after adding 'matlablatest'. "
104
+ f"Check if MATLAB_SOURCES is set correctly. Current value: {matlab_sources}"
105
+ )
106
+ logger.debug("Successfully added 'matlablatest' path.")
107
+
108
+ except ValueError as ve:
109
+ logger.error(f"ValueError: {ve}")
110
+ raise
111
+ except RuntimeError as re:
112
+ logger.error(f"RuntimeError: {re}")
113
+ raise
114
+ except (AttributeError, TypeError) as e:
115
+ logger.error(f"An unexpected error occurred: {e}")
116
+ raise
@@ -0,0 +1,38 @@
1
+ """Plotting for images and spectra, plus figure-saving helpers.
2
+
3
+ Importing this package applies the MNUtils matplotlib defaults (sans-serif
4
+ font stack, bold axis labels, constrained layout).
5
+ """
6
+
7
+ import matplotlib.pyplot as plt
8
+
9
+ from . import images, saving, spectra
10
+ from .saving import (
11
+ DEFAULT_SAVE_PARAMS,
12
+ PAPER_SAVE_PARAMS,
13
+ POSTER_SAVE_PARAMS,
14
+ TALK_SAVE_PARAMS,
15
+ contexts,
16
+ save_current_figure,
17
+ save_figure,
18
+ )
19
+
20
+ plt.rcParams["font.family"] = "sans-serif"
21
+ plt.rcParams["font.sans-serif"] = ["Helvetica", "Liberation Sans", "Arial", "sans-serif"]
22
+ plt.rcParams["axes.labelweight"] = "bold"
23
+ plt.rcParams["figure.constrained_layout.use"] = True
24
+
25
+ __all__ = [
26
+ # modules
27
+ "images",
28
+ "saving",
29
+ "spectra",
30
+ # figure saving
31
+ "DEFAULT_SAVE_PARAMS",
32
+ "POSTER_SAVE_PARAMS",
33
+ "TALK_SAVE_PARAMS",
34
+ "PAPER_SAVE_PARAMS",
35
+ "contexts",
36
+ "save_figure",
37
+ "save_current_figure",
38
+ ]
@@ -0,0 +1,5 @@
1
+ from .image_grid import ImageGridWidget
2
+ from .mrsi_inspector import MRSIVoxelInspectorWidget
3
+ from .slice_viewer import SliceViewerWidget
4
+
5
+ __all__ = ["ImageGridWidget", "MRSIVoxelInspectorWidget", "SliceViewerWidget"]
@@ -0,0 +1,34 @@
1
+ import json
2
+ import uuid
3
+ from typing import Any
4
+
5
+
6
+ def render_html(js: str, css: str, data: dict[str, Any]) -> str:
7
+ """Build a self-contained HTML/JS output embedding `data` for a widget's JS.
8
+
9
+ Deliberately not the ipywidgets/anywidget comm protocol: every widget in
10
+ this package is one-way (all data is baked in at construction, no
11
+ Python-side callback is ever needed after display), so there's nothing
12
+ to gain from a live model-sync channel -- and mystmd's execution engine
13
+ doesn't support it anyway (it has no registered `jupyter.widget` comm
14
+ target, so `widget-state+json` never gets captured into a static build,
15
+ only a dangling `widget-view+json` reference). A plain HTML/JS output
16
+ has no such dependency: it works identically in a live kernel, in myst's
17
+ live preview, and in a static `myst build --html` site. See
18
+ `docs/diary/2026-08-14-anywidget-slice-viewer.md` for the measurements.
19
+
20
+ `js` must define a function `renderWidget(data, el)` in module scope --
21
+ called directly by the trailing line below, so no `export` is involved --
22
+ and read every value it needs from `data`.
23
+ """
24
+ container_id = f"mnutils-widget-{uuid.uuid4().hex}"
25
+ # `</script>` inside the JSON payload would terminate the script block early.
26
+ data_json = json.dumps(data).replace("<", "\\u003c")
27
+ return f"""\
28
+ <div id="{container_id}"></div>
29
+ <style>{css}</style>
30
+ <script type="module">
31
+ {js}
32
+ renderWidget({data_json}, document.getElementById("{container_id}"));
33
+ </script>
34
+ """
@@ -0,0 +1,50 @@
1
+ """Shared frontend assets for MNUtils plotting widgets.
2
+
3
+ Every widget's browser code is the concatenation of a common layer
4
+ (``dom.js`` / ``theme.css``) and the widget's own ``<name>.js`` / ``<name>.css``.
5
+ Because there is no JS bundler in this project, the sharing happens in Python:
6
+ :func:`load_esm` / :func:`load_css` read both files and join them into a single
7
+ source string that feeds `_widgets._html.render_html`.
8
+
9
+ The shared JS is deliberately ``import``/``export``-free so it can sit in the
10
+ same module scope as the widget's own ``renderWidget``.
11
+ """
12
+
13
+ import pathlib
14
+
15
+ _SHARED = pathlib.Path(__file__).parent
16
+
17
+
18
+ def load_esm(widget_js: pathlib.Path) -> str:
19
+ """Return the shared JS helpers concatenated ahead of a widget's own JS.
20
+
21
+ Parameters
22
+ ----------
23
+ widget_js : pathlib.Path
24
+ Path to the widget's ``<name>.js`` (owns the ``renderWidget`` entry
25
+ point that `render_html` calls).
26
+
27
+ Returns
28
+ -------
29
+ str
30
+ Combined ESM source: ``dom.js`` followed by the widget's module.
31
+ """
32
+ shared = (_SHARED / "dom.js").read_text(encoding="utf-8")
33
+ return shared + "\n" + widget_js.read_text(encoding="utf-8")
34
+
35
+
36
+ def load_css(widget_css: pathlib.Path) -> str:
37
+ """Return the shared theme concatenated ahead of a widget's own CSS.
38
+
39
+ Parameters
40
+ ----------
41
+ widget_css : pathlib.Path
42
+ Path to the widget's ``<name>.css`` (widget-specific / layout classes).
43
+
44
+ Returns
45
+ -------
46
+ str
47
+ Combined stylesheet: ``theme.css`` followed by the widget's overrides.
48
+ """
49
+ shared = (_SHARED / "theme.css").read_text(encoding="utf-8")
50
+ return shared + "\n" + widget_css.read_text(encoding="utf-8")
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Shared frontend helpers for MNUtils plotting widgets.
3
+ *
4
+ * This module intentionally uses NO `import`/`export` statements: the Python
5
+ * asset loader (`_shared/__init__.py::load_esm`) concatenates it *ahead* of a
6
+ * widget's own `<name>.js`, so both end up in one module scope and every
7
+ * widget can call these helpers directly.
8
+ */
9
+
10
+ /** Decode a base64 string (as produced by Python's base64.b64encode) to bytes. */
11
+ function b64ToBytes(b64) {
12
+ return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
13
+ }
14
+
15
+ /**
16
+ * Inflate a zlib stream produced by Python's `zlib.compress`.
17
+ *
18
+ * Uses the platform's own DecompressionStream, so decompressing the spectra
19
+ * buffer costs no JS dependency. `zlib.compress` emits the zlib wrapper, which
20
+ * is what the "deflate" format expects ("deflate-raw" would be the headerless
21
+ * variant).
22
+ */
23
+ async function inflate(bytes) {
24
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate"));
25
+ return new Uint8Array(await new Response(stream).arrayBuffer());
26
+ }
27
+
28
+ /**
29
+ * Convert one IEEE-754 half-precision bit pattern to a JS number.
30
+ *
31
+ * Spectra travel as float16 to halve the payload. They are decoded a single
32
+ * voxel at a time rather than a whole grid at a time, so this stays cheap and
33
+ * there is no reason to depend on `Float16Array` (only Baseline 2025, and
34
+ * these widgets have to render on whatever browser opens the docs page).
35
+ */
36
+ function halfToFloat(half) {
37
+ const sign = (half & 0x8000) ? -1 : 1;
38
+ const exponent = (half & 0x7c00) >> 10;
39
+ const fraction = half & 0x03ff;
40
+ if (exponent === 0) return sign * Math.pow(2, -14) * (fraction / 1024);
41
+ if (exponent === 0x1f) return fraction ? NaN : sign * Infinity;
42
+ return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
43
+ }
44
+
45
+ /** Clamp `value` into the inclusive [lo, hi] range. */
46
+ function clamp(value, lo, hi) {
47
+ return Math.min(hi, Math.max(lo, value));
48
+ }
49
+
50
+ /** Build a `<div class="mnu-bar">` control row from a list of child elements. */
51
+ function makeBar(...children) {
52
+ const bar = document.createElement("div");
53
+ bar.className = "mnu-bar";
54
+ bar.append(...children);
55
+ return bar;
56
+ }
57
+
58
+ /** Build a `<div class="mnu-grp">` grouping a related set of controls. */
59
+ function makeGroup(...children) {
60
+ const grp = document.createElement("div");
61
+ grp.className = "mnu-grp";
62
+ grp.append(...children);
63
+ return grp;
64
+ }
65
+
66
+ /**
67
+ * Build a labeled slice slider: `<span class="mnu-lbl">` + `<input
68
+ * type="range" class="mnu-slider">` + `<span class="mnu-readout">`, wired so
69
+ * moving the slider calls `onInput(index)` and updates the readout text via
70
+ * `formatReadout(index)`.
71
+ */
72
+ function makeSliceSlider({ min, max, value, step = 1, label, formatReadout, onInput }) {
73
+ const lbl = document.createElement("span");
74
+ lbl.className = "mnu-lbl";
75
+ lbl.textContent = label;
76
+
77
+ const slider = document.createElement("input");
78
+ slider.type = "range";
79
+ slider.className = "mnu-slider";
80
+ slider.min = String(min);
81
+ slider.max = String(max);
82
+ slider.step = String(step);
83
+ slider.value = String(value);
84
+ slider.disabled = max <= min;
85
+
86
+ const readout = document.createElement("span");
87
+ readout.className = "mnu-readout";
88
+ readout.textContent = formatReadout(value);
89
+
90
+ slider.addEventListener("input", () => {
91
+ const idx = Number(slider.value);
92
+ readout.textContent = formatReadout(idx);
93
+ onInput(idx);
94
+ });
95
+
96
+ return { lbl, slider, readout };
97
+ }
98
+
99
+ /**
100
+ * Build a pill-shaped checkbox: a `<label class="mnu-toggle">` wrapping an
101
+ * `<input type="checkbox">` and its text, wired so ticking it calls
102
+ * `onChange(checked)`.
103
+ *
104
+ * A label-wrapped checkbox rather than a styled button: it stays a real
105
+ * checkbox for assistive technology and for keyboard users, and the whole
106
+ * pill becomes the hit target because the input sits inside the label.
107
+ */
108
+ function makeToggle({ label, checked = false, onChange }) {
109
+ const toggle = document.createElement("label");
110
+ toggle.className = "mnu-toggle";
111
+
112
+ const input = document.createElement("input");
113
+ input.type = "checkbox";
114
+ input.checked = checked;
115
+
116
+ const text = document.createElement("span");
117
+ text.textContent = label;
118
+
119
+ input.addEventListener("change", () => onChange(input.checked));
120
+ toggle.append(input, text);
121
+
122
+ return { toggle, input };
123
+ }
124
+
@@ -0,0 +1,140 @@
1
+ /* =========================================================================
2
+ MNUtils shared widget theme.
3
+ Concatenated ahead of each widget's own <name>.css by the Python asset
4
+ loader (_shared/__init__.py::load_css). Holds the `--mnu-*` design tokens
5
+ (light + dark) and the invariant `mnu-*` chrome shared by every widget.
6
+ Widget-specific / layout classes live in the widget's own CSS and override
7
+ these defaults (concatenated afterwards, so they win at equal specificity).
8
+ ========================================================================= */
9
+
10
+ :root {
11
+ --mnu-bg: #ffffff;
12
+ --mnu-panel: #fafafa;
13
+ --mnu-border: #e0e0e0;
14
+ --mnu-border-soft: #eeeeee;
15
+ --mnu-fg: #333333;
16
+ --mnu-fg-strong: #111111;
17
+ --mnu-muted: #666666;
18
+ --mnu-muted-2: #888888;
19
+ --mnu-label: #444444;
20
+ --mnu-accent: #0055aa;
21
+ --mnu-accent-strong: #004488;
22
+ --mnu-shadow: rgba(0, 0, 0, 0.05);
23
+ --mnu-focus-ring: rgba(100, 150, 200, 0.15);
24
+ }
25
+
26
+ @media (prefers-color-scheme: dark) {
27
+ :root {
28
+ --mnu-bg: #1e1e1e;
29
+ --mnu-panel: #252526;
30
+ --mnu-border: #3c3c3c;
31
+ --mnu-border-soft: #333333;
32
+ --mnu-fg: #d4d4d4;
33
+ --mnu-fg-strong: #f0f0f0;
34
+ --mnu-muted: #a0a0a0;
35
+ --mnu-muted-2: #8a8a8a;
36
+ --mnu-label: #c8c8c8;
37
+ --mnu-accent: #4ea1ff;
38
+ --mnu-accent-strong: #3b82d6;
39
+ --mnu-shadow: rgba(0, 0, 0, 0.35);
40
+ --mnu-focus-ring: rgba(120, 170, 220, 0.25);
41
+ }
42
+ }
43
+
44
+ /* =========================================
45
+ Container & control-bar chrome, shared by every widget.
46
+ ========================================= */
47
+ .mnu-viewer {
48
+ display: flex;
49
+ flex-direction: column;
50
+ font-family: system-ui, -apple-system, sans-serif;
51
+ color: var(--mnu-fg);
52
+ background: var(--mnu-bg);
53
+ border: 1px solid var(--mnu-border);
54
+ border-radius: 8px;
55
+ box-shadow: 0 4px 12px var(--mnu-shadow);
56
+ overflow: hidden;
57
+ }
58
+
59
+ .mnu-panel {
60
+ position: relative;
61
+ background: var(--mnu-panel);
62
+ border-bottom: 1px solid var(--mnu-border-soft);
63
+ }
64
+
65
+ .mnu-bar {
66
+ display: flex;
67
+ align-items: center;
68
+ gap: 0.6em;
69
+ padding: 0.5em 0.7em;
70
+ background: var(--mnu-bg);
71
+ }
72
+
73
+ .mnu-grp {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 0.6em;
77
+ }
78
+
79
+ .mnu-lbl {
80
+ font-size: 0.8em;
81
+ font-weight: 600;
82
+ color: var(--mnu-label);
83
+ }
84
+
85
+ .mnu-readout {
86
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
87
+ font-size: 0.8em;
88
+ color: var(--mnu-muted);
89
+ min-width: 5em;
90
+ text-align: right;
91
+ }
92
+
93
+ .mnu-slider {
94
+ flex: 1;
95
+ accent-color: var(--mnu-accent);
96
+ }
97
+
98
+ .mnu-slider:focus-visible {
99
+ outline: 2px solid var(--mnu-focus-ring);
100
+ }
101
+
102
+ /* Pill checkbox for a control bar, built by `dom.js::makeToggle`. */
103
+ .mnu-toggle {
104
+ display: inline-flex;
105
+ align-items: center;
106
+ gap: 0.45em;
107
+ padding: 0.25em 0.7em;
108
+ border: 1px solid var(--mnu-border);
109
+ border-radius: 999px;
110
+ background: var(--mnu-panel);
111
+ font-size: 0.8em;
112
+ font-weight: 600;
113
+ color: var(--mnu-label);
114
+ line-height: 1.4;
115
+ opacity: 0.75;
116
+ cursor: pointer;
117
+ user-select: none;
118
+ transition:
119
+ opacity 0.12s ease,
120
+ border-color 0.12s ease;
121
+ }
122
+
123
+ .mnu-toggle:hover,
124
+ .mnu-toggle:focus-within {
125
+ opacity: 1;
126
+ }
127
+
128
+ .mnu-toggle:has(input:checked) {
129
+ opacity: 1;
130
+ border-color: var(--mnu-accent);
131
+ }
132
+
133
+ .mnu-toggle input {
134
+ width: 1.1em;
135
+ height: 1.1em;
136
+ margin: 0;
137
+ accent-color: var(--mnu-accent);
138
+ cursor: pointer;
139
+ }
140
+