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.
- mnutils/GEExam.py +495 -0
- mnutils/GESeries.py +1578 -0
- mnutils/__init__.py +54 -0
- mnutils/_scripts.py +177 -0
- mnutils/fitting/AMARES.py +831 -0
- mnutils/fitting/__init__.py +5 -0
- mnutils/matlab/__init__.py +13 -0
- mnutils/matlab/setup_matlab.py +116 -0
- mnutils/plotting/__init__.py +38 -0
- mnutils/plotting/_widgets/__init__.py +5 -0
- mnutils/plotting/_widgets/_html.py +34 -0
- mnutils/plotting/_widgets/_shared/__init__.py +50 -0
- mnutils/plotting/_widgets/_shared/dom.js +124 -0
- mnutils/plotting/_widgets/_shared/theme.css +140 -0
- mnutils/plotting/_widgets/image_grid.css +202 -0
- mnutils/plotting/_widgets/image_grid.js +212 -0
- mnutils/plotting/_widgets/image_grid.py +86 -0
- mnutils/plotting/_widgets/mrsi_inspector.css +376 -0
- mnutils/plotting/_widgets/mrsi_inspector.js +681 -0
- mnutils/plotting/_widgets/mrsi_inspector.py +70 -0
- mnutils/plotting/_widgets/slice_viewer.css +38 -0
- mnutils/plotting/_widgets/slice_viewer.js +41 -0
- mnutils/plotting/_widgets/slice_viewer.py +35 -0
- mnutils/plotting/images.py +1399 -0
- mnutils/plotting/saving.py +138 -0
- mnutils/plotting/spectra.py +357 -0
- mnutils/py.typed +0 -0
- mnutils/testing/__init__.py +3 -0
- mnutils/testing/_spectra.py +338 -0
- mnutils/testing/synthetic.py +373 -0
- mnutils/utils/__init__.py +25 -0
- mnutils/utils/data_loaders.py +218 -0
- mnutils/utils/file_helpers.py +708 -0
- mnutils/utils/images.py +202 -0
- mnutils/utils/nifti.py +379 -0
- mnutils/utils/spectra.py +149 -0
- mnutils-1.2.0.dist-info/METADATA +161 -0
- mnutils-1.2.0.dist-info/RECORD +41 -0
- mnutils-1.2.0.dist-info/WHEEL +4 -0
- mnutils-1.2.0.dist-info/entry_points.txt +7 -0
- mnutils-1.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
// Preact + htm (JSX-free templating) + Chart.js, loaded straight from a CDN:
|
|
2
|
+
// no bundler in this project (see _shared/__init__.py), and this widget's
|
|
3
|
+
// interaction (dual-range zoom, per-voxel redraw) is enough state to want a
|
|
4
|
+
// real component model rather than hand-rolled DOM diffing.
|
|
5
|
+
import Chart from 'https://esm.sh/chart.js@4.4.1/auto'
|
|
6
|
+
import htm from 'https://esm.sh/htm@3.1.1'
|
|
7
|
+
import noUiSlider from 'https://esm.sh/nouislider@15.8.1'
|
|
8
|
+
import { h, render } from 'https://esm.sh/preact@10.19.6'
|
|
9
|
+
import {
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'https://esm.sh/preact@10.19.6/hooks'
|
|
15
|
+
|
|
16
|
+
const html = htm.bind(h)
|
|
17
|
+
|
|
18
|
+
// Plot width (px) under which the spectrum drops its y-axis labels.
|
|
19
|
+
const Y_AXIS_MIN_WIDTH = 480
|
|
20
|
+
|
|
21
|
+
const NOUISLIDER_CSS_HREF =
|
|
22
|
+
'https://cdn.jsdelivr.net/npm/nouislider@15.8.1/dist/nouislider.min.css'
|
|
23
|
+
|
|
24
|
+
// nouislider ships its own stylesheet rather than being themeable via JS
|
|
25
|
+
// options; inject it once per document (widgets render into plain divs, not
|
|
26
|
+
// shadow roots, so a single document-level <link> covers every instance).
|
|
27
|
+
function ensureNoUiSliderCss() {
|
|
28
|
+
if (document.querySelector(`link[href="${NOUISLIDER_CSS_HREF}"]`)) return
|
|
29
|
+
const link = document.createElement('link')
|
|
30
|
+
link.rel = 'stylesheet'
|
|
31
|
+
link.href = NOUISLIDER_CSS_HREF
|
|
32
|
+
document.head.appendChild(link)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Apply a row-major 4x4 affine (flattened to 16 floats) to a 3-vector.
|
|
36
|
+
// MRSI-specific (the affine and its inverse are baked into this widget's
|
|
37
|
+
// data payload), so kept local rather than promoted to _shared/dom.js.
|
|
38
|
+
function applyAffine(affine, point) {
|
|
39
|
+
const [x, y, z] = point
|
|
40
|
+
return [
|
|
41
|
+
affine[0] * x + affine[1] * y + affine[2] * z + affine[3],
|
|
42
|
+
affine[4] * x + affine[5] * y + affine[6] * z + affine[7],
|
|
43
|
+
affine[8] * x + affine[9] * y + affine[10] * z + affine[11],
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Dual-thumb range slider, backed by nouislider (dedicated dual-handle range
|
|
48
|
+
// library) instead of the two-overlaid-native-inputs hack. Used twice: the
|
|
49
|
+
// horizontal ppm window under the chart, and the vertical y-limit slider
|
|
50
|
+
// beside it. The instance is created once and driven imperatively thereafter:
|
|
51
|
+
// external prop changes go through `.set()`, while user gestures come back out
|
|
52
|
+
// through nouislider's 'slide' event (see the listener below for why that one).
|
|
53
|
+
// `onStart` fires once per gesture, before any value moves -- the y slider uses
|
|
54
|
+
// it to claim the axis on the first touch.
|
|
55
|
+
function DualRangeSlider({
|
|
56
|
+
min,
|
|
57
|
+
max,
|
|
58
|
+
step,
|
|
59
|
+
valueMin,
|
|
60
|
+
valueMax,
|
|
61
|
+
onChange,
|
|
62
|
+
onStart,
|
|
63
|
+
orientation = 'horizontal',
|
|
64
|
+
className = 'mnutils-mrsi-ppm-slider',
|
|
65
|
+
}) {
|
|
66
|
+
const containerRef = useRef(null)
|
|
67
|
+
const sliderRef = useRef(null)
|
|
68
|
+
const draggingRef = useRef(false)
|
|
69
|
+
const onChangeRef = useRef(onChange)
|
|
70
|
+
onChangeRef.current = onChange
|
|
71
|
+
const onStartRef = useRef(onStart)
|
|
72
|
+
onStartRef.current = onStart
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
ensureNoUiSliderCss()
|
|
76
|
+
if (!containerRef.current) return undefined
|
|
77
|
+
|
|
78
|
+
const slider = noUiSlider.create(containerRef.current, {
|
|
79
|
+
start: [valueMin, valueMax],
|
|
80
|
+
connect: true,
|
|
81
|
+
// nouislider throws on a zero-width range (a degenerate dataset: one ppm
|
|
82
|
+
// point, or a grid of constant amplitude). Widen it here and let the
|
|
83
|
+
// disable effect below make the control inert instead.
|
|
84
|
+
range: { min, max: max > min ? max : min + 1 },
|
|
85
|
+
step,
|
|
86
|
+
orientation,
|
|
87
|
+
behaviour: 'drag',
|
|
88
|
+
})
|
|
89
|
+
sliderRef.current = slider
|
|
90
|
+
|
|
91
|
+
// 'slide', not 'update': nouislider fires 'update' on programmatic `set`
|
|
92
|
+
// as well (unconditionally, ahead of the `fireSetEvent` guard), and once
|
|
93
|
+
// more on create -- so listening to it turns the sync effect below into a
|
|
94
|
+
// feedback loop that writes every externally-driven value straight back
|
|
95
|
+
// into `onChange`. 'slide' fires only for drags, taps and keyboard steps.
|
|
96
|
+
//
|
|
97
|
+
// It still fires on every mousemove rather than once per step, so the same
|
|
98
|
+
// snapped pair arrives dozens of times per drag. Forwarding each one cost
|
|
99
|
+
// a re-render, a Chart.js repaint and (via the sync effect below) a
|
|
100
|
+
// nouislider `set` that reads geometry back -- i.e. a forced reflow of the
|
|
101
|
+
// whole panel per mouse event, which is what made dragging feel sticky.
|
|
102
|
+
let lastLo = null
|
|
103
|
+
let lastHi = null
|
|
104
|
+
slider.on('slide', (values) => {
|
|
105
|
+
const [lo, hi] = values.map(Number)
|
|
106
|
+
if (lo === lastLo && hi === lastHi) return
|
|
107
|
+
lastLo = lo
|
|
108
|
+
lastHi = hi
|
|
109
|
+
onChangeRef.current(lo, hi)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// While the user drags, nouislider's own handle positions are already
|
|
113
|
+
// authoritative; writing them back mid-gesture only thrashes layout.
|
|
114
|
+
slider.on('start', () => {
|
|
115
|
+
draggingRef.current = true
|
|
116
|
+
onStartRef.current?.()
|
|
117
|
+
})
|
|
118
|
+
slider.on('end', () => {
|
|
119
|
+
draggingRef.current = false
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
slider.destroy()
|
|
124
|
+
sliderRef.current = null
|
|
125
|
+
}
|
|
126
|
+
// Range bounds/step come from the dataset and never change after mount;
|
|
127
|
+
// only valueMin/valueMax move thereafter, synced imperatively below.
|
|
128
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
129
|
+
}, [min, max, step, orientation])
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (draggingRef.current) return
|
|
133
|
+
sliderRef.current?.set([valueMin, valueMax], false)
|
|
134
|
+
}, [valueMin, valueMax])
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const slider = sliderRef.current
|
|
138
|
+
if (!slider) return
|
|
139
|
+
if (max <= min) slider.disable()
|
|
140
|
+
else slider.enable()
|
|
141
|
+
}, [min, max])
|
|
142
|
+
|
|
143
|
+
return html`<div
|
|
144
|
+
ref=${containerRef}
|
|
145
|
+
className="mnutils-mrsi-slider ${className}"></div>`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Chart.js spectrum plot. The chart instance is created once (empty deps
|
|
149
|
+
// effect) and mutated in place on every voxel/range change rather than
|
|
150
|
+
// recreated, so panning/zooming/voxel-picking stays cheap.
|
|
151
|
+
function SpectrumPlot({ ppm, spectrumData, ppmMin, ppmMax, label, yBounds }) {
|
|
152
|
+
const canvasRef = useRef(null)
|
|
153
|
+
const chartRef = useRef(null)
|
|
154
|
+
|
|
155
|
+
// {x, y} points against a linear x scale, NOT scalars against `labels`.
|
|
156
|
+
// With `labels` + scalar data Chart.js picks a *category* scale, where
|
|
157
|
+
// `scales.x.min/max` are category indices rather than data values -- so a
|
|
158
|
+
// ppm window of [-2, 10] silently rendered array indices 0..10 instead
|
|
159
|
+
// (11 points near -122 ppm, out of 700), while the slider readout beside
|
|
160
|
+
// the chart still showed the ppm the user asked for.
|
|
161
|
+
const points = useMemo(
|
|
162
|
+
() => ppm.map((p, i) => ({ x: p, y: spectrumData[i] })),
|
|
163
|
+
[ppm, spectrumData],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// Below this the y ticks and their title are dropped: on a phone they eat
|
|
167
|
+
// most of the plot to label an axis whose units are arbitrary anyway, and
|
|
168
|
+
// the shape of the spectrum is what the reader came for. Measured on the
|
|
169
|
+
// plot box rather than the viewport, so a narrow VS Code panel gets the same
|
|
170
|
+
// treatment as a narrow screen.
|
|
171
|
+
const [compactY, setCompactY] = useState(false)
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const box = canvasRef.current?.parentElement
|
|
174
|
+
if (!box || typeof ResizeObserver === 'undefined') return undefined
|
|
175
|
+
const observer = new ResizeObserver(([entry]) =>
|
|
176
|
+
setCompactY(entry.contentRect.width < Y_AXIS_MIN_WIDTH),
|
|
177
|
+
)
|
|
178
|
+
observer.observe(box)
|
|
179
|
+
return () => observer.disconnect()
|
|
180
|
+
}, [])
|
|
181
|
+
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (!canvasRef.current) return undefined
|
|
184
|
+
const ctx = canvasRef.current.getContext('2d')
|
|
185
|
+
// Canvas 2D strokeStyle doesn't resolve CSS var() the way an element's
|
|
186
|
+
// style does -- read the theme token's computed value instead.
|
|
187
|
+
const accentColor =
|
|
188
|
+
getComputedStyle(canvasRef.current)
|
|
189
|
+
.getPropertyValue('--mnu-accent')
|
|
190
|
+
.trim() || '#0055aa'
|
|
191
|
+
|
|
192
|
+
chartRef.current = new Chart(ctx, {
|
|
193
|
+
type: 'line',
|
|
194
|
+
data: {
|
|
195
|
+
datasets: [
|
|
196
|
+
{
|
|
197
|
+
label: label || 'Spectrum',
|
|
198
|
+
data: points,
|
|
199
|
+
borderColor: accentColor,
|
|
200
|
+
borderWidth: 1.5,
|
|
201
|
+
pointRadius: 0,
|
|
202
|
+
fill: false,
|
|
203
|
+
tension: 0,
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
options: {
|
|
208
|
+
responsive: true,
|
|
209
|
+
maintainAspectRatio: false,
|
|
210
|
+
animation: false,
|
|
211
|
+
scales: {
|
|
212
|
+
x: {
|
|
213
|
+
type: 'linear', // ppm is a continuous axis, not 700 categories
|
|
214
|
+
title: { display: true, text: 'ppm' },
|
|
215
|
+
reverse: true, // NMR/MRS convention: high ppm on the left
|
|
216
|
+
min: Math.min(ppmMin, ppmMax),
|
|
217
|
+
max: Math.max(ppmMin, ppmMax),
|
|
218
|
+
ticks: {
|
|
219
|
+
maxTicksLimit: 8,
|
|
220
|
+
callback: (val) => Number(val).toFixed(2),
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
y: {
|
|
224
|
+
min: yBounds.min,
|
|
225
|
+
max: yBounds.max,
|
|
226
|
+
title: { display: !compactY, text: 'Intensity (a.u.)' },
|
|
227
|
+
ticks: {
|
|
228
|
+
display: !compactY,
|
|
229
|
+
callback: (val) => val.toExponential(1),
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
plugins: {
|
|
234
|
+
legend: { display: false },
|
|
235
|
+
tooltip: {
|
|
236
|
+
callbacks: {
|
|
237
|
+
title: (items) => `${Number(items[0].parsed.x).toFixed(3)} ppm`,
|
|
238
|
+
label: (ctx) => `Intensity: ${ctx.parsed.y.toExponential(3)}`,
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
return () => {
|
|
246
|
+
chartRef.current?.destroy()
|
|
247
|
+
chartRef.current = null
|
|
248
|
+
}
|
|
249
|
+
// Chart created once; per-update mutation happens in the effect below.
|
|
250
|
+
}, [])
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const chart = chartRef.current
|
|
254
|
+
if (!chart) return
|
|
255
|
+
chart.data.datasets[0].data = points
|
|
256
|
+
chart.options.scales.x.min = Math.min(ppmMin, ppmMax)
|
|
257
|
+
chart.options.scales.x.max = Math.max(ppmMin, ppmMax)
|
|
258
|
+
chart.options.scales.y.min = yBounds.min
|
|
259
|
+
chart.options.scales.y.max = yBounds.max
|
|
260
|
+
chart.options.scales.y.ticks.display = !compactY
|
|
261
|
+
chart.options.scales.y.title.display = !compactY
|
|
262
|
+
chart.update('none') // instant, no animation overhead
|
|
263
|
+
}, [points, ppmMin, ppmMax, yBounds, compactY])
|
|
264
|
+
|
|
265
|
+
return html`<canvas ref=${canvasRef}></canvas>`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function MRSIInspector({ data }) {
|
|
269
|
+
const containerRef = useRef(null)
|
|
270
|
+
|
|
271
|
+
const [nx, ny, nMrsiSlices] = data.grid_shape
|
|
272
|
+
const [dimsI, dimsJ] = data.mrsi_dims
|
|
273
|
+
|
|
274
|
+
const [sliceIdx, setSliceIdx] = useState(data.initial_slice)
|
|
275
|
+
const [voxel, setVoxel] = useState({
|
|
276
|
+
x: data.initial_voxel[0],
|
|
277
|
+
y: data.initial_voxel[1],
|
|
278
|
+
mrsiSliceIdx: data.initial_voxel[2],
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const ppmDataMin = Math.min(data.ppm[0], data.ppm[data.npts - 1])
|
|
282
|
+
const ppmDataMax = Math.max(data.ppm[0], data.ppm[data.npts - 1])
|
|
283
|
+
|
|
284
|
+
// Whole-ppm steps only: a window is something you read off in ppm ("2 to 8"),
|
|
285
|
+
// and stepping by the sample spacing (~0.02 ppm) gave the slider hundreds of
|
|
286
|
+
// indistinguishable positions. The bounds move inwards to the nearest
|
|
287
|
+
// integers so the steps actually land on round numbers rather than on
|
|
288
|
+
// `ppmDataMin + k`. Data spanning less than 1 ppm has no integer grid to
|
|
289
|
+
// snap to, so it keeps the sample spacing.
|
|
290
|
+
const ppmIntMin = Math.ceil(ppmDataMin)
|
|
291
|
+
const ppmIntMax = Math.floor(ppmDataMax)
|
|
292
|
+
const wholePpmSteps = ppmIntMax > ppmIntMin
|
|
293
|
+
const ppmSliderMin = wholePpmSteps ? ppmIntMin : ppmDataMin
|
|
294
|
+
const ppmSliderMax = wholePpmSteps ? ppmIntMax : ppmDataMax
|
|
295
|
+
const ppmStep = wholePpmSteps
|
|
296
|
+
? 1
|
|
297
|
+
: data.npts > 1
|
|
298
|
+
? Math.abs(data.ppm[1] - data.ppm[0]) || 0.01
|
|
299
|
+
: 0.01
|
|
300
|
+
|
|
301
|
+
// The chart's x axis is reversed (NMR convention: high ppm left), so the
|
|
302
|
+
// slider has to run the same way or dragging the left thumb moves the right
|
|
303
|
+
// edge of the window. nouislider's own `direction: 'rtl'` also reverses
|
|
304
|
+
// handle order in every get/set payload; mirroring the value about the
|
|
305
|
+
// midpoint of the slider range instead keeps the slider a plain ascending
|
|
306
|
+
// ltr one and confines the flip to this single involution (mirror(mirror(p))
|
|
307
|
+
// === p, so the same helper converts both ways). Mirroring about the
|
|
308
|
+
// *slider* bounds, not the data bounds, is what keeps integers integral.
|
|
309
|
+
const mirrorPpm = (p) => ppmSliderMin + ppmSliderMax - p
|
|
310
|
+
const ppmDecimals = wholePpmSteps ? 0 : 2
|
|
311
|
+
|
|
312
|
+
// Default to the conventional 10 to -2 ppm display window (matches
|
|
313
|
+
// plotting/spectra.py's default xlim), clamped to what the data covers.
|
|
314
|
+
const [ppmRange, setPpmRange] = useState({
|
|
315
|
+
min: clamp(-2, ppmSliderMin, ppmSliderMax),
|
|
316
|
+
max: clamp(10, ppmSliderMin, ppmSliderMax),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
// `data.spectra` is a Uint16Array of raw float16 bit patterns and
|
|
320
|
+
// `data.spectra_scale` undoes the range scaling applied before the cast --
|
|
321
|
+
// both prepared by `renderWidget` below, which owns the async decode.
|
|
322
|
+
const currentSpectrum = useMemo(() => {
|
|
323
|
+
const { x, y, mrsiSliceIdx } = voxel
|
|
324
|
+
const offset = ((x * ny + y) * nMrsiSlices + mrsiSliceIdx) * data.npts
|
|
325
|
+
// Only this voxel's points are converted, not the whole grid: a 16x16x16
|
|
326
|
+
// grid at 700 points is 2.9M values, and all but `npts` of them would be
|
|
327
|
+
// thrown away on every voxel change.
|
|
328
|
+
const out = new Array(data.npts)
|
|
329
|
+
for (let i = 0; i < data.npts; i++) {
|
|
330
|
+
out[i] = halfToFloat(data.spectra[offset + i]) * data.spectra_scale
|
|
331
|
+
}
|
|
332
|
+
return out
|
|
333
|
+
}, [voxel, data.spectra, data.spectra_scale, ny, nMrsiSlices, data.npts])
|
|
334
|
+
|
|
335
|
+
// Per-point min/max across *every* voxel, computed once on first use and
|
|
336
|
+
// cached: the fixed-y axis has to cover the loudest voxel anywhere in the
|
|
337
|
+
// grid, and recomputing that on each ppm-slider step (2.9M half-float
|
|
338
|
+
// conversions for a 16^3 grid at 700 points) would stall the drag. Reducing
|
|
339
|
+
// to two length-`npts` envelopes up front makes every later window change an
|
|
340
|
+
// O(npts) scan in `SpectrumPlot`.
|
|
341
|
+
const [fixedY, setFixedY] = useState(false)
|
|
342
|
+
const envelopeRef = useRef(null)
|
|
343
|
+
const yEnvelope = useMemo(() => {
|
|
344
|
+
if (!fixedY) return null
|
|
345
|
+
if (envelopeRef.current?.source === data.spectra) return envelopeRef.current
|
|
346
|
+
const { npts, spectra, spectra_scale } = data
|
|
347
|
+
const min = new Float64Array(npts).fill(Infinity)
|
|
348
|
+
const max = new Float64Array(npts).fill(-Infinity)
|
|
349
|
+
const nVoxels = Math.floor(spectra.length / npts)
|
|
350
|
+
for (let v = 0; v < nVoxels; v++) {
|
|
351
|
+
const offset = v * npts
|
|
352
|
+
for (let i = 0; i < npts; i++) {
|
|
353
|
+
const val = halfToFloat(spectra[offset + i]) * spectra_scale
|
|
354
|
+
if (val < min[i]) min[i] = val
|
|
355
|
+
if (val > max[i]) max[i] = val
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
envelopeRef.current = { min, max, source: spectra }
|
|
359
|
+
return envelopeRef.current
|
|
360
|
+
}, [fixedY, data.spectra, data.spectra_scale, data.npts])
|
|
361
|
+
|
|
362
|
+
// Manual y limits, set by dragging the vertical slider; `null` means the
|
|
363
|
+
// axis is still derived from the data (per voxel, or -- with `fixedY` --
|
|
364
|
+
// from the whole-grid envelope).
|
|
365
|
+
const [yLim, setYLim] = useState(null)
|
|
366
|
+
|
|
367
|
+
// Bounds of whatever is visible in [ppmMin, ppmMax], not of the full sweep:
|
|
368
|
+
// otherwise zooming the ppm window in doesn't reveal any more vertical
|
|
369
|
+
// detail. With `yEnvelope` given (fixed-y mode) they come from the whole
|
|
370
|
+
// grid's per-point min/max instead of this one voxel, so the axis stops
|
|
371
|
+
// moving as you step between voxels and their amplitudes stay comparable.
|
|
372
|
+
// Still restricted to the ppm window: "fixed" means independent of the
|
|
373
|
+
// voxel, not of the zoom -- a window-wide axis would flatten every peak
|
|
374
|
+
// whenever one voxel somewhere has a huge off-window artefact.
|
|
375
|
+
const autoYBounds = useMemo(() => {
|
|
376
|
+
const ppm = data.ppm
|
|
377
|
+
const lo = Math.min(ppmRange.min, ppmRange.max)
|
|
378
|
+
const hi = Math.max(ppmRange.min, ppmRange.max)
|
|
379
|
+
let min = Infinity
|
|
380
|
+
let max = -Infinity
|
|
381
|
+
for (let i = 0; i < ppm.length; i++) {
|
|
382
|
+
if (ppm[i] < lo || ppm[i] > hi) continue
|
|
383
|
+
const vLo = yEnvelope ? yEnvelope.min[i] : currentSpectrum[i]
|
|
384
|
+
const vHi = yEnvelope ? yEnvelope.max[i] : currentSpectrum[i]
|
|
385
|
+
if (vLo < min) min = vLo
|
|
386
|
+
if (vHi > max) max = vHi
|
|
387
|
+
}
|
|
388
|
+
// A window narrower than one ppm step can contain no sample at all, which
|
|
389
|
+
// leaves min/max at +/-Infinity -- widening those by 1 keeps them infinite
|
|
390
|
+
// and hands Chart.js an unrenderable y axis. Fall back to a finite range.
|
|
391
|
+
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
|
392
|
+
return { min: 0, max: 1 }
|
|
393
|
+
}
|
|
394
|
+
if (!(min < max)) {
|
|
395
|
+
min -= 1
|
|
396
|
+
max += 1
|
|
397
|
+
}
|
|
398
|
+
const pad = (max - min) * 0.05
|
|
399
|
+
return { min: min - pad, max: max + pad }
|
|
400
|
+
}, [data.ppm, currentSpectrum, ppmRange.min, ppmRange.max, yEnvelope])
|
|
401
|
+
|
|
402
|
+
// Manual limits win outright -- no 5% padding on top of them, since they are
|
|
403
|
+
// the numbers the user dialled in.
|
|
404
|
+
const yBounds = yLim ?? autoYBounds
|
|
405
|
+
|
|
406
|
+
// The y slider travels over the grid's full amplitude range, which Python
|
|
407
|
+
// measured while encoding the buffer: the browser would otherwise have to
|
|
408
|
+
// scan every voxel before it could draw the control. 400 steps is finer than
|
|
409
|
+
// the track is wide in pixels, so the thumb never feels notched.
|
|
410
|
+
const yDataMin = data.spectra_min
|
|
411
|
+
const yDataMax = data.spectra_max
|
|
412
|
+
const yStep = (yDataMax - yDataMin) / 400 || 1
|
|
413
|
+
|
|
414
|
+
// Vertical nouislider runs top-down (lowest value at the top) under
|
|
415
|
+
// `direction: 'ltr'`, the opposite of a y axis. Same fix as the ppm slider:
|
|
416
|
+
// keep the slider plain-ascending and mirror the values about the range
|
|
417
|
+
// midpoint, an involution that converts in both directions.
|
|
418
|
+
const mirrorY = (v) => yDataMin + yDataMax - v
|
|
419
|
+
|
|
420
|
+
// With no manual limits set the slider sits fully open rather than tracking
|
|
421
|
+
// the automatic bounds. Tracking them looks tidier but is unusable: a quiet
|
|
422
|
+
// voxel's window is a couple of percent of the grid's amplitude range, so
|
|
423
|
+
// both handles land inside the same 16 px and read as a single one. Fully
|
|
424
|
+
// open also says the right thing -- nothing is being clamped yet.
|
|
425
|
+
const ySliderLo = yLim
|
|
426
|
+
? clamp(mirrorY(yLim.max), yDataMin, yDataMax)
|
|
427
|
+
: yDataMin
|
|
428
|
+
const ySliderHi = yLim
|
|
429
|
+
? clamp(mirrorY(yLim.min), yDataMin, yDataMax)
|
|
430
|
+
: yDataMax
|
|
431
|
+
|
|
432
|
+
// Taking hold of the slider claims the axis: hand-set limits only mean
|
|
433
|
+
// anything if it stops rescaling itself. The limits start at what the handles
|
|
434
|
+
// already show (the full range), so the axis matches the control from the
|
|
435
|
+
// first frame of the gesture instead of jumping to it on the first step.
|
|
436
|
+
const claimYAxis = () => {
|
|
437
|
+
setFixedY(true)
|
|
438
|
+
setYLim((prev) => prev ?? { min: yDataMin, max: yDataMax })
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const voxelPolygonPoints = useMemo(() => {
|
|
442
|
+
const cornerOffsets = [
|
|
443
|
+
[-0.5, -0.5, 0],
|
|
444
|
+
[0.5, -0.5, 0],
|
|
445
|
+
[0.5, 0.5, 0],
|
|
446
|
+
[-0.5, 0.5, 0],
|
|
447
|
+
]
|
|
448
|
+
return cornerOffsets
|
|
449
|
+
.map(([dx, dy, dz]) => {
|
|
450
|
+
const [row, col] = applyAffine(data.mrsi_to_display_affine, [
|
|
451
|
+
voxel.x + dx,
|
|
452
|
+
voxel.y + dy,
|
|
453
|
+
voxel.mrsiSliceIdx + dz,
|
|
454
|
+
])
|
|
455
|
+
return `${col},${row}`
|
|
456
|
+
})
|
|
457
|
+
.join(' ')
|
|
458
|
+
}, [voxel, data.mrsi_to_display_affine])
|
|
459
|
+
|
|
460
|
+
const handleSliceChange = (newSliceIdx) => {
|
|
461
|
+
const clampedSlice = clamp(newSliceIdx, 0, data.n_anat_slices - 1)
|
|
462
|
+
setSliceIdx(clampedSlice)
|
|
463
|
+
|
|
464
|
+
const [, , mrsiSlice] = applyAffine(data.display_to_mrsi_affine, [
|
|
465
|
+
0,
|
|
466
|
+
0,
|
|
467
|
+
clampedSlice,
|
|
468
|
+
])
|
|
469
|
+
setVoxel((v) => ({
|
|
470
|
+
...v,
|
|
471
|
+
mrsiSliceIdx: clamp(Math.round(mrsiSlice), 0, nMrsiSlices - 1),
|
|
472
|
+
}))
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const handleImageClick = (e) => {
|
|
476
|
+
containerRef.current?.focus()
|
|
477
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
478
|
+
const px = e.clientX - rect.left
|
|
479
|
+
const py = e.clientY - rect.top
|
|
480
|
+
const col = -0.5 + (px / rect.width) * data.image_width
|
|
481
|
+
const row = -0.5 + (py / rect.height) * data.image_height
|
|
482
|
+
|
|
483
|
+
const [rawX, rawY, rawSlice] = applyAffine(data.display_to_mrsi_affine, [
|
|
484
|
+
row,
|
|
485
|
+
col,
|
|
486
|
+
sliceIdx,
|
|
487
|
+
])
|
|
488
|
+
setVoxel({
|
|
489
|
+
x: clamp(Math.round(rawX), 0, nx - 1),
|
|
490
|
+
y: clamp(Math.round(rawY), 0, ny - 1),
|
|
491
|
+
mrsiSliceIdx: clamp(Math.round(rawSlice), 0, nMrsiSlices - 1),
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// `Capture`-suffixed Preact event props register on the capture phase, not
|
|
496
|
+
// bubble: notebook hosts (VS Code, JupyterLab) bind ArrowUp/ArrowDown at
|
|
497
|
+
// the document level for cell navigation, and a plain bubble-phase
|
|
498
|
+
// listener plus preventDefault() doesn't stop that outer handler from
|
|
499
|
+
// also firing on the same event.
|
|
500
|
+
const handleKeyDownCapture = (e) => {
|
|
501
|
+
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key))
|
|
502
|
+
return
|
|
503
|
+
e.preventDefault()
|
|
504
|
+
e.stopPropagation()
|
|
505
|
+
setVoxel((v) => {
|
|
506
|
+
let nextX = v.x
|
|
507
|
+
let nextY = v.y
|
|
508
|
+
if (e.key === 'ArrowDown') nextY = Math.max(0, v.y - 1)
|
|
509
|
+
if (e.key === 'ArrowUp') nextY = Math.min(ny - 1, v.y + 1)
|
|
510
|
+
if (e.key === 'ArrowRight') nextX = Math.max(0, v.x - 1)
|
|
511
|
+
if (e.key === 'ArrowLeft') nextX = Math.min(nx - 1, v.x + 1)
|
|
512
|
+
return { ...v, x: nextX, y: nextY }
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Image and chart share one fixed-height media row (`--mnu-mrsi-media-h`, set
|
|
517
|
+
// in CSS) so they line up top and bottom regardless of the frame's aspect
|
|
518
|
+
// ratio. That only holds if nothing but the title sits above either of them,
|
|
519
|
+
// which is why the fixed-y toggle renders under the ppm bar rather than
|
|
520
|
+
// between the title and the chart.
|
|
521
|
+
const specI = dimsI - 1 - voxel.y
|
|
522
|
+
const specJ = dimsJ - 1 - voxel.x
|
|
523
|
+
|
|
524
|
+
return html`
|
|
525
|
+
<div
|
|
526
|
+
ref=${containerRef}
|
|
527
|
+
className="mnu-viewer mnutils-mrsi-inspector"
|
|
528
|
+
tabindex="0"
|
|
529
|
+
onMouseEnter=${() => containerRef.current?.focus()}
|
|
530
|
+
onKeyDownCapture=${handleKeyDownCapture}>
|
|
531
|
+
<div className="mnutils-mrsi-left-panel">
|
|
532
|
+
<div className="mnu-lbl mnutils-mrsi-slice-title">
|
|
533
|
+
${data.slice_titles[sliceIdx]}
|
|
534
|
+
</div>
|
|
535
|
+
<div className="mnutils-mrsi-media">
|
|
536
|
+
<div
|
|
537
|
+
className="mnutils-mrsi-image-wrap"
|
|
538
|
+
style="--mnu-mrsi-ar: ${data.image_width / data.image_height}">
|
|
539
|
+
<img
|
|
540
|
+
className="mnutils-mrsi-image"
|
|
541
|
+
src=${data.frame_urls[sliceIdx]}
|
|
542
|
+
onClick=${handleImageClick}
|
|
543
|
+
title="Click to select voxel" />
|
|
544
|
+
<svg
|
|
545
|
+
className="mnutils-mrsi-overlay"
|
|
546
|
+
viewBox="-0.5 -0.5 ${data.image_width} ${data.image_height}">
|
|
547
|
+
<polygon
|
|
548
|
+
className="mnutils-mrsi-voxel-box-halo"
|
|
549
|
+
points=${voxelPolygonPoints} />
|
|
550
|
+
<polygon
|
|
551
|
+
className="mnutils-mrsi-voxel-box"
|
|
552
|
+
points=${voxelPolygonPoints} />
|
|
553
|
+
</svg>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
<div className="mnu-bar mnutils-mrsi-control-bar">
|
|
557
|
+
<span className="mnu-lbl">slice:</span>
|
|
558
|
+
<input
|
|
559
|
+
type="range"
|
|
560
|
+
className="mnu-slider"
|
|
561
|
+
min="0"
|
|
562
|
+
max=${data.n_anat_slices - 1}
|
|
563
|
+
value=${sliceIdx}
|
|
564
|
+
disabled=${data.n_anat_slices <= 1}
|
|
565
|
+
onInput=${(e) => handleSliceChange(Number(e.target.value))} />
|
|
566
|
+
<span className="mnu-readout"
|
|
567
|
+
>${sliceIdx + 1} / ${data.n_anat_slices}</span
|
|
568
|
+
>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<div className="mnutils-mrsi-right-panel">
|
|
573
|
+
<div className="mnu-lbl mnutils-mrsi-spectrum-title">
|
|
574
|
+
Spectrum at voxel (i:${specI}, j:${specJ},
|
|
575
|
+
slice:${voxel.mrsiSliceIdx})
|
|
576
|
+
</div>
|
|
577
|
+
<div className="mnutils-mrsi-media">
|
|
578
|
+
<${DualRangeSlider}
|
|
579
|
+
orientation="vertical"
|
|
580
|
+
className="mnutils-mrsi-ylim-slider"
|
|
581
|
+
min=${yDataMin}
|
|
582
|
+
max=${yDataMax}
|
|
583
|
+
step=${yStep}
|
|
584
|
+
valueMin=${ySliderLo}
|
|
585
|
+
valueMax=${ySliderHi}
|
|
586
|
+
onStart=${claimYAxis}
|
|
587
|
+
onChange=${(lo, hi) => {
|
|
588
|
+
// Also here, not just in `onStart`: a tap on the track moves a
|
|
589
|
+
// handle without a preceding drag gesture on some inputs.
|
|
590
|
+
claimYAxis()
|
|
591
|
+
setYLim({ min: mirrorY(hi), max: mirrorY(lo) })
|
|
592
|
+
}} />
|
|
593
|
+
<div className="mnutils-mrsi-spectrum-container">
|
|
594
|
+
<${SpectrumPlot}
|
|
595
|
+
ppm=${data.ppm}
|
|
596
|
+
spectrumData=${currentSpectrum}
|
|
597
|
+
ppmMin=${ppmRange.min}
|
|
598
|
+
ppmMax=${ppmRange.max}
|
|
599
|
+
label=${data.spectrum_label}
|
|
600
|
+
yBounds=${yBounds} />
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
<div
|
|
604
|
+
className="mnu-bar mnutils-mrsi-control-bar mnutils-mrsi-range-bar">
|
|
605
|
+
<span className="mnu-lbl">ppm:</span>
|
|
606
|
+
<${DualRangeSlider}
|
|
607
|
+
min=${ppmSliderMin}
|
|
608
|
+
max=${ppmSliderMax}
|
|
609
|
+
step=${ppmStep}
|
|
610
|
+
valueMin=${mirrorPpm(ppmRange.max)}
|
|
611
|
+
valueMax=${mirrorPpm(ppmRange.min)}
|
|
612
|
+
onChange=${(lo, hi) =>
|
|
613
|
+
setPpmRange({ min: mirrorPpm(hi), max: mirrorPpm(lo) })} />
|
|
614
|
+
<span className="mnu-readout"
|
|
615
|
+
>${ppmRange.max.toFixed(ppmDecimals)} –
|
|
616
|
+
${ppmRange.min.toFixed(ppmDecimals)}</span
|
|
617
|
+
>
|
|
618
|
+
</div>
|
|
619
|
+
<label className="mnutils-mrsi-ylim-toggle">
|
|
620
|
+
<input
|
|
621
|
+
type="checkbox"
|
|
622
|
+
checked=${fixedY}
|
|
623
|
+
onChange=${(e) => {
|
|
624
|
+
setFixedY(e.target.checked)
|
|
625
|
+
// Unticking is the only way back to the per-voxel autoscale, so
|
|
626
|
+
// it has to drop any manual limits as well -- they would
|
|
627
|
+
// otherwise keep the axis pinned with the box unticked.
|
|
628
|
+
if (!e.target.checked) setYLim(null)
|
|
629
|
+
}} />
|
|
630
|
+
<span
|
|
631
|
+
>Fixed y-axis
|
|
632
|
+
(${yLim ? 'manual limits' : 'max over all voxels'})</span
|
|
633
|
+
>
|
|
634
|
+
</label>
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
`
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Turn the raw payload baked in by `mrsi_inspector.py` into the shape
|
|
642
|
+
* `MRSIInspector` consumes.
|
|
643
|
+
*
|
|
644
|
+
* The two heavy fields arrive base64-encoded: WebP frames, and the spectra as
|
|
645
|
+
* a zlib-compressed block of float16 bit patterns. Frames become blob: URLs
|
|
646
|
+
* rather than data: URLs so the browser holds one copy of each frame instead
|
|
647
|
+
* of a base64 string plus its decoded form.
|
|
648
|
+
*/
|
|
649
|
+
async function decodePayload(data) {
|
|
650
|
+
const frame_urls = data.left_frames.map((frame) =>
|
|
651
|
+
URL.createObjectURL(new Blob([b64ToBytes(frame)], { type: 'image/webp' })),
|
|
652
|
+
)
|
|
653
|
+
const inflated = await inflate(b64ToBytes(data.spectra_bytes))
|
|
654
|
+
// Copy through a fresh buffer: the inflated bytes are not guaranteed to sit
|
|
655
|
+
// at a 2-byte-aligned offset, which a Uint16Array view requires.
|
|
656
|
+
const spectra = new Uint16Array(
|
|
657
|
+
inflated.byteOffset % 2 === 0
|
|
658
|
+
? inflated.buffer.slice(
|
|
659
|
+
inflated.byteOffset,
|
|
660
|
+
inflated.byteOffset + inflated.byteLength,
|
|
661
|
+
)
|
|
662
|
+
: Uint8Array.from(inflated).buffer,
|
|
663
|
+
)
|
|
664
|
+
return { ...data, frame_urls, spectra }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Deliberately not exported: `render_html` calls this directly from the same
|
|
668
|
+
// module scope.
|
|
669
|
+
function renderWidget(data, el) {
|
|
670
|
+
el.textContent = 'Loading MRSI inspector…'
|
|
671
|
+
decodePayload(data).then(
|
|
672
|
+
(decoded) => {
|
|
673
|
+
el.textContent = ''
|
|
674
|
+
render(html`<${MRSIInspector} data=${decoded} />`, el)
|
|
675
|
+
},
|
|
676
|
+
(err) => {
|
|
677
|
+
el.textContent = `Failed to decode MRSI inspector data: ${err}`
|
|
678
|
+
throw err
|
|
679
|
+
},
|
|
680
|
+
)
|
|
681
|
+
}
|