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,202 @@
|
|
|
1
|
+
/* Every size that decides how big the plot area gets, in one place and in
|
|
2
|
+
rem so a reader's font size scales the whole widget together. Override any
|
|
3
|
+
of them on the widget element to retune a page.
|
|
4
|
+
|
|
5
|
+
Nothing here may exceed the pane the widget lands in: `.mnu-viewer` clips
|
|
6
|
+
its overflow, so a layout that is too wide doesn't scroll, it disappears.
|
|
7
|
+
Widths are therefore percentages of the host, with the rem values acting as
|
|
8
|
+
caps on top of them. */
|
|
9
|
+
.mnutils-image-grid {
|
|
10
|
+
--mnu-grid-max-w: 100%; /* the plot area stops growing here */
|
|
11
|
+
--mnu-grid-max-h: 50vh; /* taller than this, the panel box scrolls */
|
|
12
|
+
--mnu-panel-max-h: 17rem; /* no single panel taller than this */
|
|
13
|
+
--mnu-panel-min-h: 8rem; /* ...nor shorter than this */
|
|
14
|
+
--mnu-panel-min-w: 9rem; /* narrower than this, the row wraps instead */
|
|
15
|
+
--mnu-grid-gap: 0.7rem;
|
|
16
|
+
--mnu-panel-pad: 0.7rem;
|
|
17
|
+
|
|
18
|
+
/* How wide the panels would like to be: their height cap times their aspect
|
|
19
|
+
ratio (--mnu-aspect is set per grid by the JS once the first frame has
|
|
20
|
+
loaded), times the nominal column count, plus gaps and padding. This cap
|
|
21
|
+
is what stops a wide window stretching four 64x64 frames across the whole
|
|
22
|
+
screen -- the columns below would otherwise share out every pixel going. */
|
|
23
|
+
--mnu-panel-w-cap: calc(var(--mnu-panel-max-h) * var(--mnu-aspect, 1));
|
|
24
|
+
|
|
25
|
+
/* The floor is the same trade read from the other end. A panel is as tall as
|
|
26
|
+
its width and aspect ratio make it, so "never shorter than
|
|
27
|
+
--mnu-panel-min-h" means "never narrower than that height times the aspect
|
|
28
|
+
ratio" -- for a 5x20 frame that is four times --mnu-panel-min-h. Columns
|
|
29
|
+
take the wider of that and --mnu-panel-min-w, so a row of wide frames wraps
|
|
30
|
+
sooner rather than flattening into strips. */
|
|
31
|
+
--mnu-panel-min-w-eff: max(
|
|
32
|
+
var(--mnu-panel-min-w),
|
|
33
|
+
calc(var(--mnu-panel-min-h) * var(--mnu-aspect, 1))
|
|
34
|
+
);
|
|
35
|
+
--mnu-grid-w-cap: calc(
|
|
36
|
+
var(--mnu-grid-cols, 1) * var(--mnu-panel-w-cap) +
|
|
37
|
+
(var(--mnu-grid-cols, 1) - 1) * var(--mnu-grid-gap) + 2 *
|
|
38
|
+
var(--mnu-panel-pad)
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
margin: 1em 0 0 1em;
|
|
42
|
+
width: 100%;
|
|
43
|
+
max-width: min(calc(var(--mnu-grid-max-w) - 1em), var(--mnu-grid-w-cap));
|
|
44
|
+
/* The panel grid alone can size the widget narrower than its own control
|
|
45
|
+
bar needs (a single small panel, say) -- floor it at the bar's worst-case
|
|
46
|
+
width (label + a 3-digit readout + a usable slider + the border toggle)
|
|
47
|
+
so the slider stays on the bar's first line instead of wrapping under it. */
|
|
48
|
+
min-width: min(100%, 16rem);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.mnutils-image-grid-title {
|
|
52
|
+
padding: 0.6em 0.7em 0.2em;
|
|
53
|
+
font-size: 1.05em;
|
|
54
|
+
font-weight: 700;
|
|
55
|
+
text-align: left;
|
|
56
|
+
color: var(--mnu-fg-strong);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* The bar wraps instead of pushing the slider past the clipped edge: in a
|
|
60
|
+
narrow pane the label and readout take the first line and the slider drops
|
|
61
|
+
to the second, at whatever width is left. */
|
|
62
|
+
.mnutils-image-grid-bar {
|
|
63
|
+
flex-wrap: wrap;
|
|
64
|
+
justify-content: flex-start;
|
|
65
|
+
max-width: 100%;
|
|
66
|
+
border-bottom: 1px solid var(--mnu-border-soft);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.mnutils-image-grid-bar .mnu-lbl,
|
|
70
|
+
.mnutils-image-grid-bar .mnu-readout {
|
|
71
|
+
font-size: 1.1em;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.mnutils-image-grid-bar .mnu-readout {
|
|
75
|
+
font-family: inherit;
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
line-height: inherit;
|
|
78
|
+
min-width: 0;
|
|
79
|
+
color: var(--mnu-fg-strong);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.mnutils-image-grid-bar .mnu-slider {
|
|
83
|
+
flex: 1 1 8rem;
|
|
84
|
+
width: auto;
|
|
85
|
+
min-width: 0;
|
|
86
|
+
max-width: 20rem;
|
|
87
|
+
height: 1.3em;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* --mnu-grid-max-h bounds *this box*, not the panels inside it: past that
|
|
91
|
+
height the grid scrolls rather than shrinking its images. Twelve 32x32
|
|
92
|
+
panels are then twelve full-size panels behind a scrollbar, instead of
|
|
93
|
+
twelve stamps -- past a handful of rows, dividing a fixed budget between
|
|
94
|
+
them makes every panel unreadable at once.
|
|
95
|
+
`scrollbar-gutter: stable` reserves the scrollbar's width whether or not it
|
|
96
|
+
is showing, so the panels don't jump sideways when a slice change is the
|
|
97
|
+
thing that makes the content overflow. */
|
|
98
|
+
.mnutils-image-grid-panel {
|
|
99
|
+
padding: var(--mnu-panel-pad);
|
|
100
|
+
border-bottom: none;
|
|
101
|
+
max-height: var(--mnu-grid-max-h);
|
|
102
|
+
overflow-y: auto;
|
|
103
|
+
overscroll-behavior: auto;
|
|
104
|
+
scrollbar-gutter: stable;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* `auto-fit` + `minmax(--mnu-panel-min-w-eff, 1fr)`: as many columns as fit at
|
|
108
|
+
a readable size, wrapping to a new row when they don't -- where "readable"
|
|
109
|
+
is both floors above, whichever binds. The nominal column count
|
|
110
|
+
(`GRID_MAX_COLS`, five) is enforced from above by the widget's max-width
|
|
111
|
+
rather than by a fixed track list -- a fixed list is what cut the fourth
|
|
112
|
+
panel off in a narrow editor pane, since grid columns never wrap.
|
|
113
|
+
`min(..., 100%)` keeps the minimum itself from overflowing a pane narrower
|
|
114
|
+
than a single panel. */
|
|
115
|
+
.mnutils-image-grid-cells {
|
|
116
|
+
display: grid;
|
|
117
|
+
grid-template-columns: repeat(
|
|
118
|
+
auto-fit,
|
|
119
|
+
minmax(min(var(--mnu-panel-min-w-eff), 100%), 1fr)
|
|
120
|
+
);
|
|
121
|
+
gap: var(--mnu-grid-gap);
|
|
122
|
+
align-items: start;
|
|
123
|
+
justify-items: start;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.mnutils-image-grid-cell {
|
|
127
|
+
display: flex;
|
|
128
|
+
flex-direction: column;
|
|
129
|
+
gap: 0.35em;
|
|
130
|
+
margin: 0;
|
|
131
|
+
min-width: 0;
|
|
132
|
+
width: 100%;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* Width-driven, not height-driven: a panel fills its column, and the column is
|
|
136
|
+
what shrinks when the pane narrows or the grid gains images -- so more
|
|
137
|
+
images means smaller images rather than clipped ones. Height follows from
|
|
138
|
+
the aspect ratio, capped by --mnu-panel-max-h so that a single row can't
|
|
139
|
+
render a 512-row volume at full size. How many rows there are no longer
|
|
140
|
+
enters into it: the box above scrolls instead.
|
|
141
|
+
|
|
142
|
+
The floor is enforced by the column width above rather than here, so that
|
|
143
|
+
the panel grows *wider* to reach --mnu-panel-min-h instead of being
|
|
144
|
+
letterboxed into it. `min-height` stays as a backstop for the one case the
|
|
145
|
+
columns can't fix -- a pane too narrow for even a single minimum-width
|
|
146
|
+
column -- and, with `object-fit: contain`, for a frame so tall that the
|
|
147
|
+
height cap bites before the width one. Either way the frame keeps its
|
|
148
|
+
aspect ratio; only the padding around it changes. */
|
|
149
|
+
.mnutils-image-grid-image {
|
|
150
|
+
display: block;
|
|
151
|
+
width: 100%;
|
|
152
|
+
height: auto;
|
|
153
|
+
min-height: var(--mnu-panel-min-h);
|
|
154
|
+
max-height: var(--mnu-panel-max-h);
|
|
155
|
+
object-fit: contain;
|
|
156
|
+
/* Set per image by the JS: `pixelated` whenever the frame is being scaled
|
|
157
|
+
up, so a 20x20 array reads as 400 big square pixels instead of a blur,
|
|
158
|
+
and `auto` when it is being scaled down, where nearest-neighbour would
|
|
159
|
+
just alias. */
|
|
160
|
+
image-rendering: var(--mnu-image-rendering, auto);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/* Toggled by the bar's "Border" checkbox. `border-box` keeps the outline
|
|
164
|
+
inside the column the panel already occupies, so ticking it doesn't reflow
|
|
165
|
+
the grid. */
|
|
166
|
+
.mnutils-image-grid-cells.is-bordered .mnutils-image-grid-image {
|
|
167
|
+
box-sizing: border-box;
|
|
168
|
+
border: 1px solid var(--mnu-border);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.mnutils-image-grid-caption {
|
|
172
|
+
font-size: 0.85em;
|
|
173
|
+
text-align: center;
|
|
174
|
+
color: var(--mnu-fg);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.mnutils-image-grid-cbar {
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: center;
|
|
180
|
+
gap: 0.4em;
|
|
181
|
+
width: 100%;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.mnutils-image-grid-cbar-shared {
|
|
185
|
+
margin: 0.7rem auto 0;
|
|
186
|
+
max-width: min(100%, 26rem);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.mnutils-image-grid-cbar-strip {
|
|
190
|
+
flex: 1;
|
|
191
|
+
min-width: 0;
|
|
192
|
+
height: 0.55em;
|
|
193
|
+
border: 1px solid var(--mnu-border);
|
|
194
|
+
border-radius: 2px;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.mnutils-image-grid-cbar-tick {
|
|
198
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
199
|
+
font-size: 0.7em;
|
|
200
|
+
color: var(--mnu-muted);
|
|
201
|
+
white-space: nowrap;
|
|
202
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid of pre-rendered image panels driven by one shared slice slider.
|
|
3
|
+
*
|
|
4
|
+
* The frames are bare rasters straight from PIL; the figure title, panel
|
|
5
|
+
* captions and colorbar are built here instead of being baked into the pixels
|
|
6
|
+
* (see `image_grid.py`). Frames are slice-major -- `frames[slice][panel]` --
|
|
7
|
+
* so moving the slider swaps every panel's `src` in one pass.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Format a colorbar tick the way a reader expects to see a data value. */
|
|
11
|
+
function formatTick(value) {
|
|
12
|
+
if (!Number.isFinite(value)) return String(value);
|
|
13
|
+
const magnitude = Math.abs(value);
|
|
14
|
+
if (value === 0) return "0";
|
|
15
|
+
if (magnitude >= 1e4 || magnitude < 1e-2) return value.toExponential(1);
|
|
16
|
+
return String(Number(value.toPrecision(3)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a colorbar: a `linear-gradient` strip flanked by its two bounds.
|
|
21
|
+
*
|
|
22
|
+
* The gradient is the matplotlib colormap sampled to hex stops in Python, so
|
|
23
|
+
* the bar and the pixels it explains come from one colormap and can't drift.
|
|
24
|
+
* Returns the wrapper plus a `setBounds` that rewrites only the tick text --
|
|
25
|
+
* `colorbar_mode="each"` autoscales per slice, so those two nodes change on
|
|
26
|
+
* every slider move while the gradient never does.
|
|
27
|
+
*/
|
|
28
|
+
function makeColorbar(stops) {
|
|
29
|
+
const wrap = document.createElement("div");
|
|
30
|
+
wrap.className = "mnutils-image-grid-cbar";
|
|
31
|
+
|
|
32
|
+
const lo = document.createElement("span");
|
|
33
|
+
lo.className = "mnutils-image-grid-cbar-tick";
|
|
34
|
+
|
|
35
|
+
const strip = document.createElement("div");
|
|
36
|
+
strip.className = "mnutils-image-grid-cbar-strip";
|
|
37
|
+
strip.style.background = `linear-gradient(to right, ${stops.join(", ")})`;
|
|
38
|
+
|
|
39
|
+
const hi = document.createElement("span");
|
|
40
|
+
hi.className = "mnutils-image-grid-cbar-tick";
|
|
41
|
+
|
|
42
|
+
wrap.append(lo, strip, hi);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
wrap,
|
|
46
|
+
setBounds: ([low, high]) => {
|
|
47
|
+
lo.textContent = formatTick(low);
|
|
48
|
+
hi.textContent = formatTick(high);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderWidget(data, el) {
|
|
54
|
+
const {
|
|
55
|
+
frames,
|
|
56
|
+
bounds,
|
|
57
|
+
colormap_stops: stops,
|
|
58
|
+
n_slices: nSlices,
|
|
59
|
+
num_panels: numPanels,
|
|
60
|
+
num_cols: numCols,
|
|
61
|
+
titles,
|
|
62
|
+
fig_title: figTitle,
|
|
63
|
+
colorbar_mode: colorbarMode,
|
|
64
|
+
initial_index: initialIndex,
|
|
65
|
+
slice_label: sliceLabel,
|
|
66
|
+
} = data;
|
|
67
|
+
|
|
68
|
+
// WebP with an alpha channel: values the colormap marks "bad" (NaN, and so
|
|
69
|
+
// anything masked out by `zeros_as_nan`) are transparent rather than a
|
|
70
|
+
// colour, so the panel background shows through in both light and dark.
|
|
71
|
+
const urls = frames.map((slice) =>
|
|
72
|
+
slice.map((b64) => URL.createObjectURL(new Blob([b64ToBytes(b64)], { type: "image/webp" }))),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const viewer = document.createElement("div");
|
|
76
|
+
viewer.className = "mnu-viewer mnutils-image-grid";
|
|
77
|
+
|
|
78
|
+
if (figTitle) {
|
|
79
|
+
const heading = document.createElement("div");
|
|
80
|
+
heading.className = "mnutils-image-grid-title";
|
|
81
|
+
heading.textContent = figTitle;
|
|
82
|
+
viewer.append(heading);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const panel = document.createElement("div");
|
|
86
|
+
panel.className = "mnu-panel mnutils-image-grid-panel";
|
|
87
|
+
|
|
88
|
+
const grid = document.createElement("div");
|
|
89
|
+
grid.className = "mnutils-image-grid-cells";
|
|
90
|
+
// The column count is nominal: the CSS wraps to fewer columns when the pane
|
|
91
|
+
// is too narrow for them, and caps the widget's width so a wide pane doesn't
|
|
92
|
+
// fit more. Rows are not counted -- however many they come to, the panel box
|
|
93
|
+
// scrolls once they outgrow it.
|
|
94
|
+
const cols = Math.min(numCols, numPanels);
|
|
95
|
+
viewer.style.setProperty("--mnu-grid-cols", String(cols));
|
|
96
|
+
|
|
97
|
+
const images = [];
|
|
98
|
+
const colorbars = [];
|
|
99
|
+
for (let p = 0; p < numPanels; p++) {
|
|
100
|
+
const cell = document.createElement("figure");
|
|
101
|
+
cell.className = "mnutils-image-grid-cell";
|
|
102
|
+
|
|
103
|
+
const img = document.createElement("img");
|
|
104
|
+
img.className = "mnutils-image-grid-image";
|
|
105
|
+
img.src = urls[initialIndex][p];
|
|
106
|
+
img.alt = titles[p] ? `Panel: ${titles[p]}` : `Panel ${p + 1}`;
|
|
107
|
+
images.push(img);
|
|
108
|
+
cell.append(img);
|
|
109
|
+
|
|
110
|
+
if (titles[p]) {
|
|
111
|
+
const caption = document.createElement("figcaption");
|
|
112
|
+
caption.className = "mnutils-image-grid-caption";
|
|
113
|
+
caption.textContent = titles[p];
|
|
114
|
+
cell.append(caption);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (colorbarMode === "each") {
|
|
118
|
+
const cbar = makeColorbar(stops);
|
|
119
|
+
colorbars.push(cbar);
|
|
120
|
+
cell.append(cbar.wrap);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
grid.append(cell);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
panel.append(grid);
|
|
127
|
+
|
|
128
|
+
if (colorbarMode === "single") {
|
|
129
|
+
const cbar = makeColorbar(stops);
|
|
130
|
+
cbar.wrap.classList.add("mnutils-image-grid-cbar-shared");
|
|
131
|
+
colorbars.push(cbar);
|
|
132
|
+
panel.append(cbar.wrap);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Low-resolution data should look like the grid of values it is. Once a
|
|
136
|
+
// frame is drawn wider than its own pixel count the browser's smooth
|
|
137
|
+
// upscaling turns a 20x20 array into a blur, so those get `pixelated`;
|
|
138
|
+
// frames being scaled *down* keep the smooth default, where nearest
|
|
139
|
+
// -neighbour would only alias. Rechecked on resize, since which side of
|
|
140
|
+
// 1:1 a panel falls on depends on the width it ends up with.
|
|
141
|
+
const syncPixelation = () => {
|
|
142
|
+
for (const img of images) {
|
|
143
|
+
if (!img.naturalWidth) continue;
|
|
144
|
+
img.style.setProperty(
|
|
145
|
+
"--mnu-image-rendering",
|
|
146
|
+
img.getBoundingClientRect().width > img.naturalWidth ? "pixelated" : "auto",
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// The frames' aspect ratio decides how wide the panels want to be, and so
|
|
152
|
+
// how wide the whole widget is allowed to grow (see --mnu-panel-w-cap).
|
|
153
|
+
// Only measurable once a frame has decoded, hence the load handler.
|
|
154
|
+
const syncAspect = () => {
|
|
155
|
+
const img = images.find((candidate) => candidate.naturalWidth);
|
|
156
|
+
if (img) viewer.style.setProperty("--mnu-aspect", String(img.naturalWidth / img.naturalHeight));
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
for (const img of images) {
|
|
160
|
+
img.addEventListener("load", () => {
|
|
161
|
+
syncAspect();
|
|
162
|
+
syncPixelation();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (typeof ResizeObserver === "function") new ResizeObserver(syncPixelation).observe(grid);
|
|
166
|
+
|
|
167
|
+
const showSlice = (idx) => {
|
|
168
|
+
for (let p = 0; p < numPanels; p++) images[p].src = urls[idx][p];
|
|
169
|
+
// "single" has one bar for the whole grid, so panel 0's bounds are the
|
|
170
|
+
// grid's bounds -- with a shared colorbar every panel shares them anyway.
|
|
171
|
+
colorbars.forEach((cbar, i) => cbar.setBounds(bounds[idx][colorbarMode === "each" ? i : 0]));
|
|
172
|
+
};
|
|
173
|
+
showSlice(initialIndex);
|
|
174
|
+
|
|
175
|
+
// A panel's edge is invisible wherever the data is NaN, since those pixels
|
|
176
|
+
// are transparent -- which makes a mostly-masked image look like it has an
|
|
177
|
+
// arbitrary shape. The border draws the frame's real extent back in, off by
|
|
178
|
+
// default because it is clutter on an image that fills its own bounds.
|
|
179
|
+
const { toggle } = makeToggle({
|
|
180
|
+
label: "Border",
|
|
181
|
+
checked: false,
|
|
182
|
+
onChange: (on) => grid.classList.toggle("is-bordered", on),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// A single-slice stack has nothing to scrub, so it gets a bar with just the
|
|
186
|
+
// toggle rather than a disabled slider whose only honest readout is "0 of 0".
|
|
187
|
+
const controls = [];
|
|
188
|
+
if (nSlices > 1) {
|
|
189
|
+
const { lbl, slider, readout } = makeSliceSlider({
|
|
190
|
+
min: 0,
|
|
191
|
+
max: nSlices - 1,
|
|
192
|
+
value: initialIndex,
|
|
193
|
+
label: sliceLabel,
|
|
194
|
+
formatReadout: (idx) => String(idx),
|
|
195
|
+
onInput: showSlice,
|
|
196
|
+
});
|
|
197
|
+
// Reserve the readout's full width up front, sized to the largest index
|
|
198
|
+
// it will ever show -- otherwise a 3-digit slice count only grows the bar
|
|
199
|
+
// past its wrap threshold once the slider is dragged there, so the layout
|
|
200
|
+
// shifts mid-scrub instead of on load.
|
|
201
|
+
readout.style.minWidth = `${String(nSlices - 1).length}ch`;
|
|
202
|
+
controls.push(makeGroup(lbl, readout), slider);
|
|
203
|
+
}
|
|
204
|
+
const bar = makeBar(...controls, toggle);
|
|
205
|
+
bar.classList.add("mnutils-image-grid-bar");
|
|
206
|
+
viewer.append(bar);
|
|
207
|
+
|
|
208
|
+
viewer.append(panel);
|
|
209
|
+
el.append(viewer);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export default renderWidget;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ._html import render_html
|
|
6
|
+
from ._shared import load_css, load_esm
|
|
7
|
+
|
|
8
|
+
_DIR = Path(__file__).parent
|
|
9
|
+
_JS = load_esm(_DIR / "image_grid.js")
|
|
10
|
+
_CSS = load_css(_DIR / "image_grid.css")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImageGridWidget:
|
|
14
|
+
"""Grid of pre-rendered image panels with one shared slice slider.
|
|
15
|
+
|
|
16
|
+
The panels arrive as bare rasters -- no titles, no colorbar, no axes --
|
|
17
|
+
because they were produced by `plotting.images` straight from PIL rather
|
|
18
|
+
than from a matplotlib `Figure`. Everything that used to be drawn *around*
|
|
19
|
+
the pixels is rebuilt here as DOM: the figure title as a heading, each
|
|
20
|
+
panel's title as a `<figcaption>`, and the colorbar as a CSS gradient
|
|
21
|
+
strip with text ticks. That keeps the text sharp at any zoom and lets a
|
|
22
|
+
slice change rewrite a couple of text nodes instead of a whole raster.
|
|
23
|
+
|
|
24
|
+
Like every widget in this package the payload is baked in at construction,
|
|
25
|
+
so the slider keeps working in a statically built docs page with no kernel
|
|
26
|
+
behind it.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
frames : list[list[bytes]]
|
|
31
|
+
Encoded panel images, slice-major: ``frames[slice_idx][panel_idx]``.
|
|
32
|
+
bounds : list[list[tuple[float, float]]]
|
|
33
|
+
The ``(vmin, vmax)`` the matching frame was normalised with, same
|
|
34
|
+
indexing as `frames`. Only read when a colorbar is shown, but always
|
|
35
|
+
per slice, since ``colorbar_mode="each"`` autoscales every panel on
|
|
36
|
+
every slice.
|
|
37
|
+
colormap_stops : list[str]
|
|
38
|
+
The colormap sampled to CSS hex colors, low to high; interpolated into
|
|
39
|
+
a `linear-gradient` for the colorbar strip.
|
|
40
|
+
num_cols : int
|
|
41
|
+
Panels per row.
|
|
42
|
+
titles : list[str]
|
|
43
|
+
Per-panel captions. May be shorter than the number of panels (a panel
|
|
44
|
+
without a title simply gets none).
|
|
45
|
+
fig_title : str
|
|
46
|
+
Heading above the whole grid; omitted when empty.
|
|
47
|
+
colorbar_mode : str
|
|
48
|
+
``"single"`` for one strip under the grid, ``"each"`` for a strip per
|
|
49
|
+
panel, ``""`` for none.
|
|
50
|
+
initial_index : int, optional
|
|
51
|
+
Slice shown on first display, by default 0.
|
|
52
|
+
slice_label : str, optional
|
|
53
|
+
Label next to the slider, by default ``"Slice"``.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
frames: list[list[bytes]],
|
|
59
|
+
bounds: list[list[tuple[float, float]]],
|
|
60
|
+
colormap_stops: list[str],
|
|
61
|
+
num_cols: int,
|
|
62
|
+
titles: list[str] | None = None,
|
|
63
|
+
fig_title: str = "",
|
|
64
|
+
colorbar_mode: str = "",
|
|
65
|
+
initial_index: int = 0,
|
|
66
|
+
slice_label: str = "Slice",
|
|
67
|
+
) -> None:
|
|
68
|
+
self._data: dict[str, Any] = {
|
|
69
|
+
"frames": [
|
|
70
|
+
[base64.b64encode(panel).decode("ascii") for panel in slice_frames]
|
|
71
|
+
for slice_frames in frames
|
|
72
|
+
],
|
|
73
|
+
"bounds": [[list(panel) for panel in slice_bounds] for slice_bounds in bounds],
|
|
74
|
+
"colormap_stops": colormap_stops,
|
|
75
|
+
"n_slices": len(frames),
|
|
76
|
+
"num_panels": len(frames[0]) if frames else 0,
|
|
77
|
+
"num_cols": num_cols,
|
|
78
|
+
"titles": titles if titles else [],
|
|
79
|
+
"fig_title": fig_title,
|
|
80
|
+
"colorbar_mode": colorbar_mode,
|
|
81
|
+
"initial_index": initial_index,
|
|
82
|
+
"slice_label": slice_label,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def _repr_html_(self) -> str:
|
|
86
|
+
return render_html(_JS, _CSS, self._data)
|