arrayview 0.26.3__py3-none-any.whl → 0.27.2__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.
- arrayview/_analysis.py +19 -4
- arrayview/_launcher.py +2 -1
- arrayview/_platform.py +15 -7
- arrayview/_routes_analysis.py +442 -3
- arrayview/_routes_rendering.py +3 -0
- arrayview/_routes_websocket.py +3 -0
- arrayview/_stdio_server.py +5 -1
- arrayview/_synthetic_mri.py +18 -1
- arrayview/_viewer.html +3839 -668
- arrayview/_vscode_browser.py +16 -0
- arrayview/_vscode_extension.py +1 -1
- arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.26.3.dist-info → arrayview-0.27.2.dist-info}/METADATA +7 -7
- {arrayview-0.26.3.dist-info → arrayview-0.27.2.dist-info}/RECORD +17 -17
- {arrayview-0.26.3.dist-info → arrayview-0.27.2.dist-info}/WHEEL +0 -0
- {arrayview-0.26.3.dist-info → arrayview-0.27.2.dist-info}/entry_points.txt +0 -0
- {arrayview-0.26.3.dist-info → arrayview-0.27.2.dist-info}/licenses/LICENSE +0 -0
arrayview/_analysis.py
CHANGED
|
@@ -67,7 +67,7 @@ def _safe_float(v) -> float | None:
|
|
|
67
67
|
return f if np.isfinite(f) else None
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _histogram_payload(finite: np.ndarray, bins: int) -> dict:
|
|
70
|
+
def _histogram_payload(finite: np.ndarray, bins: int, log_bins: bool = False) -> dict:
|
|
71
71
|
"""Build a histogram response from finite values."""
|
|
72
72
|
if finite.size == 0:
|
|
73
73
|
return {"counts": [], "edges": [], "vmin": 0.0, "vmax": 1.0}
|
|
@@ -81,7 +81,13 @@ def _histogram_payload(finite: np.ndarray, bins: int) -> dict:
|
|
|
81
81
|
"vmax": vmax,
|
|
82
82
|
}
|
|
83
83
|
bins = max(8, min(int(bins), 512))
|
|
84
|
-
|
|
84
|
+
if log_bins and vmin > 0:
|
|
85
|
+
log_data = np.log10(finite)
|
|
86
|
+
_, log_edges = np.histogram(log_data, bins=bins)
|
|
87
|
+
edges = np.power(10.0, log_edges)
|
|
88
|
+
counts, _ = np.histogram(finite, bins=edges)
|
|
89
|
+
else:
|
|
90
|
+
counts, edges = np.histogram(finite, bins=bins)
|
|
85
91
|
return {
|
|
86
92
|
"counts": counts.tolist(),
|
|
87
93
|
"edges": [float(e) for e in edges],
|
|
@@ -113,7 +119,8 @@ def _slice_histogram(
|
|
|
113
119
|
data = apply_complex_mode(raw, complex_mode)
|
|
114
120
|
finite = data.ravel()
|
|
115
121
|
finite = finite[np.isfinite(finite)]
|
|
116
|
-
|
|
122
|
+
log_bins = qmri_role in ("t1", "t2")
|
|
123
|
+
return _histogram_payload(finite, bins, log_bins=log_bins)
|
|
117
124
|
|
|
118
125
|
|
|
119
126
|
def _parse_fixed_indices(fixed_indices: str) -> dict[int, int]:
|
|
@@ -172,6 +179,7 @@ def _volume_histogram(
|
|
|
172
179
|
session, dim_x, dim_y, scroll_dim, scroll_dims
|
|
173
180
|
)
|
|
174
181
|
|
|
182
|
+
log_bins = qmri_role in ("t1", "t2")
|
|
175
183
|
cache_key = (
|
|
176
184
|
dim_x,
|
|
177
185
|
dim_y,
|
|
@@ -179,6 +187,8 @@ def _volume_histogram(
|
|
|
179
187
|
tuple(sorted(fixed.items())),
|
|
180
188
|
complex_mode,
|
|
181
189
|
qmri_role,
|
|
190
|
+
bins,
|
|
191
|
+
log_bins,
|
|
182
192
|
)
|
|
183
193
|
if not hasattr(session, "_volume_hist_cache"):
|
|
184
194
|
session._volume_hist_cache = {}
|
|
@@ -224,7 +234,7 @@ def _volume_histogram(
|
|
|
224
234
|
pixels.append(finite)
|
|
225
235
|
|
|
226
236
|
if pixels:
|
|
227
|
-
result = _histogram_payload(np.concatenate(pixels), bins)
|
|
237
|
+
result = _histogram_payload(np.concatenate(pixels), bins, log_bins=log_bins)
|
|
228
238
|
else:
|
|
229
239
|
result = {"counts": [], "edges": [], "vmin": 0.0, "vmax": 1.0}
|
|
230
240
|
session._volume_hist_cache[cache_key] = {
|
|
@@ -241,14 +251,19 @@ def _lebesgue_slice(
|
|
|
241
251
|
indices,
|
|
242
252
|
complex_mode: int = 0,
|
|
243
253
|
log_scale: bool = False,
|
|
254
|
+
qmri_role: str = "",
|
|
244
255
|
) -> np.ndarray:
|
|
245
256
|
"""Return float32 slice data for Lebesgue mode."""
|
|
257
|
+
from arrayview._synthetic_mri import normalize_relaxation_ms
|
|
258
|
+
|
|
246
259
|
idx_tuple = (
|
|
247
260
|
tuple(int(v) for v in indices.split(","))
|
|
248
261
|
if isinstance(indices, str)
|
|
249
262
|
else tuple(indices)
|
|
250
263
|
)
|
|
251
264
|
raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
|
|
265
|
+
if qmri_role:
|
|
266
|
+
raw = normalize_relaxation_ms(raw, qmri_role)
|
|
252
267
|
data = apply_complex_mode(raw, complex_mode).astype(np.float32)
|
|
253
268
|
if log_scale:
|
|
254
269
|
data = np.log10(np.abs(data) + 1).astype(np.float32)
|
arrayview/_launcher.py
CHANGED
|
@@ -1148,6 +1148,7 @@ def _handle_cli_spawned_daemon(
|
|
|
1148
1148
|
stdout=subprocess.DEVNULL,
|
|
1149
1149
|
stderr=subprocess.DEVNULL,
|
|
1150
1150
|
close_fds=True,
|
|
1151
|
+
start_new_session=(sys.platform != "win32"),
|
|
1151
1152
|
)
|
|
1152
1153
|
|
|
1153
1154
|
early_webview_opened = False
|
|
@@ -1245,7 +1246,7 @@ def _open_cli_spawned_view(
|
|
|
1245
1246
|
_print_viewer_location(url)
|
|
1246
1247
|
_open_browser(
|
|
1247
1248
|
url,
|
|
1248
|
-
blocking=
|
|
1249
|
+
blocking=(window_mode == "vscode"),
|
|
1249
1250
|
force_vscode=(window_mode == "vscode"),
|
|
1250
1251
|
title=f"ArrayView: {name}",
|
|
1251
1252
|
floating=floating,
|
arrayview/_platform.py
CHANGED
|
@@ -419,19 +419,27 @@ def _can_native_window() -> bool:
|
|
|
419
419
|
|
|
420
420
|
|
|
421
421
|
def _native_window_gui() -> str | None:
|
|
422
|
-
"""Return the pywebview GUI backend name to use, or None if unavailable.
|
|
422
|
+
"""Return the pywebview GUI backend name to use, or None if unavailable.
|
|
423
|
+
|
|
424
|
+
On macOS/Windows and Linux we return ``""`` so pywebview uses its own
|
|
425
|
+
backend auto-detection, which is more robust than forcing a specific
|
|
426
|
+
backend: it probes each installed binding and falls back across them.
|
|
427
|
+
Forcing ``"qt"`` because ``find_spec("qtpy")`` succeeds is a bad probe —
|
|
428
|
+
qtpy being importable does not mean QtWebEngine can actually initialise
|
|
429
|
+
(missing libnss/libxkbcommon/QtWebEngineProcess is common on Linux), and
|
|
430
|
+
a failed forced backend hangs for ~10s before the watchdog falls back to a
|
|
431
|
+
browser. Letting pywebview pick avoids that. We still require a display
|
|
432
|
+
server on Linux and the webview package itself everywhere.
|
|
433
|
+
"""
|
|
423
434
|
if importlib.util.find_spec("webview") is None:
|
|
424
435
|
return None
|
|
425
436
|
if sys.platform in ("darwin", "win32"):
|
|
426
437
|
return ""
|
|
427
|
-
# Linux/BSD: need a display server
|
|
438
|
+
# Linux/BSD: need a display server. pywebview probes Qt/GTK/Tk backends
|
|
439
|
+
# itself, so we don't pin one — we only confirm a display is reachable.
|
|
428
440
|
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
|
429
441
|
return None
|
|
430
|
-
|
|
431
|
-
return "qt"
|
|
432
|
-
if importlib.util.find_spec("gi") is not None:
|
|
433
|
-
return "gtk"
|
|
434
|
-
return None
|
|
442
|
+
return ""
|
|
435
443
|
|
|
436
444
|
|
|
437
445
|
# ---------------------------------------------------------------------------
|
arrayview/_routes_analysis.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import io
|
|
1
3
|
import itertools
|
|
2
4
|
import math
|
|
3
5
|
|
|
@@ -250,11 +252,24 @@ def register_analysis_routes(app, get_session_or_404) -> None:
|
|
|
250
252
|
py: int,
|
|
251
253
|
tolerance: float = 0.1,
|
|
252
254
|
complex_mode: int = 0,
|
|
255
|
+
scope_dim: int | None = None,
|
|
253
256
|
session=Depends(get_session_or_404),
|
|
254
257
|
):
|
|
255
258
|
if session.rgb_axis is not None:
|
|
256
259
|
return {"error": "not supported for RGB sessions"}
|
|
257
260
|
idx_tuple = tuple(int(v) for v in indices.split(","))
|
|
261
|
+
if scope_dim is not None:
|
|
262
|
+
return _scoped_floodfill_response(
|
|
263
|
+
session,
|
|
264
|
+
dim_x,
|
|
265
|
+
dim_y,
|
|
266
|
+
list(idx_tuple),
|
|
267
|
+
px,
|
|
268
|
+
py,
|
|
269
|
+
tolerance,
|
|
270
|
+
complex_mode,
|
|
271
|
+
scope_dim,
|
|
272
|
+
)
|
|
258
273
|
raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
|
|
259
274
|
data = apply_complex_mode(raw, complex_mode)
|
|
260
275
|
h, w = data.shape
|
|
@@ -313,6 +328,85 @@ def register_analysis_routes(app, get_session_or_404) -> None:
|
|
|
313
328
|
"mask_b64": _encode_mask_b64(component, bbox),
|
|
314
329
|
}
|
|
315
330
|
|
|
331
|
+
@app.post("/roi_stats/{sid}")
|
|
332
|
+
def get_roi_stats_structured(
|
|
333
|
+
sid: str,
|
|
334
|
+
body: dict = Body(...),
|
|
335
|
+
session=Depends(get_session_or_404),
|
|
336
|
+
):
|
|
337
|
+
if session.rgb_axis is not None:
|
|
338
|
+
return {"error": "not supported for RGB sessions"}
|
|
339
|
+
dim_x = int(body.get("dim_x", 1 if session.data.ndim > 1 else 0))
|
|
340
|
+
dim_y = int(body.get("dim_y", 0))
|
|
341
|
+
indices = _roi_indices_from_body(body, session.data.ndim, session.shape)
|
|
342
|
+
complex_mode = int(body.get("complex_mode", 0))
|
|
343
|
+
rois = body.get("rois", [])
|
|
344
|
+
if not isinstance(rois, list):
|
|
345
|
+
return {"error": "rois must be a list"}
|
|
346
|
+
|
|
347
|
+
results = []
|
|
348
|
+
for roi_idx, roi in enumerate(rois):
|
|
349
|
+
rows = []
|
|
350
|
+
for idx in _roi_scope_indices(roi, indices, session.shape, {dim_x, dim_y}):
|
|
351
|
+
raw = extract_slice(session, dim_x, dim_y, idx)
|
|
352
|
+
data = apply_complex_mode(raw, complex_mode)
|
|
353
|
+
mask = _roi_mask_for_shape(roi, data.shape, idx)
|
|
354
|
+
finite = _roi_finite_values(data, mask)
|
|
355
|
+
if finite.size:
|
|
356
|
+
rows.append(
|
|
357
|
+
{
|
|
358
|
+
"indices": list(idx),
|
|
359
|
+
"dims": _roi_scope_dims_string(idx, indices, {dim_x, dim_y}),
|
|
360
|
+
"min": _safe_float(finite.min()),
|
|
361
|
+
"max": _safe_float(finite.max()),
|
|
362
|
+
"mean": _safe_float(finite.mean()),
|
|
363
|
+
"std": _safe_float(finite.std()),
|
|
364
|
+
"n": int(finite.size),
|
|
365
|
+
}
|
|
366
|
+
)
|
|
367
|
+
combined = _combine_roi_rows(rows)
|
|
368
|
+
results.append(
|
|
369
|
+
{
|
|
370
|
+
"roi": roi.get("id", roi_idx),
|
|
371
|
+
"name": roi.get("name", f"ROI {roi_idx + 1}"),
|
|
372
|
+
"shape": roi.get("type", "rect"),
|
|
373
|
+
"rows": rows,
|
|
374
|
+
"stats": combined,
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
return {"results": results}
|
|
378
|
+
|
|
379
|
+
@app.post("/roi_mask/{sid}")
|
|
380
|
+
def export_roi_mask_structured(
|
|
381
|
+
sid: str,
|
|
382
|
+
body: dict = Body(...),
|
|
383
|
+
session=Depends(get_session_or_404),
|
|
384
|
+
):
|
|
385
|
+
if session.rgb_axis is not None:
|
|
386
|
+
return Response(status_code=400, content="not supported for RGB sessions")
|
|
387
|
+
dim_x = int(body.get("dim_x", 1 if session.data.ndim > 1 else 0))
|
|
388
|
+
dim_y = int(body.get("dim_y", 0))
|
|
389
|
+
indices = _roi_indices_from_body(body, session.data.ndim, session.shape)
|
|
390
|
+
rois = body.get("rois", [])
|
|
391
|
+
if not isinstance(rois, list):
|
|
392
|
+
return Response(status_code=400, content="rois must be a list")
|
|
393
|
+
|
|
394
|
+
labels = np.zeros(session.shape, dtype=np.uint16)
|
|
395
|
+
for roi_idx, roi in enumerate(rois):
|
|
396
|
+
label = roi_idx + 1
|
|
397
|
+
for idx in _roi_scope_indices(roi, indices, session.shape, {dim_x, dim_y}):
|
|
398
|
+
raw = extract_slice(session, dim_x, dim_y, idx)
|
|
399
|
+
mask = _roi_mask_for_shape(roi, raw.shape, idx)
|
|
400
|
+
_write_roi_mask(labels, mask, idx, dim_x, dim_y, label)
|
|
401
|
+
|
|
402
|
+
buf = io.BytesIO()
|
|
403
|
+
np.save(buf, labels)
|
|
404
|
+
return Response(
|
|
405
|
+
content=buf.getvalue(),
|
|
406
|
+
media_type="application/octet-stream",
|
|
407
|
+
headers={"Content-Disposition": "attachment; filename=roi_mask.npy"},
|
|
408
|
+
)
|
|
409
|
+
|
|
316
410
|
@app.get("/line_profile/{sid}")
|
|
317
411
|
def get_line_profile(
|
|
318
412
|
sid: str,
|
|
@@ -453,9 +547,10 @@ def register_analysis_routes(app, get_session_or_404) -> None:
|
|
|
453
547
|
indices: str,
|
|
454
548
|
complex_mode: int = 0,
|
|
455
549
|
log_scale: bool = False,
|
|
550
|
+
qmri_role: str = "",
|
|
456
551
|
session=Depends(get_session_or_404),
|
|
457
552
|
):
|
|
458
|
-
data = _lebesgue_slice(session, dim_x, dim_y, indices, complex_mode, log_scale)
|
|
553
|
+
data = _lebesgue_slice(session, dim_x, dim_y, indices, complex_mode, log_scale, qmri_role)
|
|
459
554
|
return Response(
|
|
460
555
|
content=data.tobytes(),
|
|
461
556
|
media_type="application/octet-stream",
|
|
@@ -468,9 +563,353 @@ def register_analysis_routes(app, get_session_or_404) -> None:
|
|
|
468
563
|
|
|
469
564
|
|
|
470
565
|
def _encode_mask_b64(mask: np.ndarray, bbox: dict) -> str:
|
|
471
|
-
import base64
|
|
472
|
-
|
|
473
566
|
x0, y0 = bbox["x0"], bbox["y0"]
|
|
474
567
|
x1, y1 = bbox["x1"], bbox["y1"]
|
|
475
568
|
sub = mask[y0 : y1 + 1, x0 : x1 + 1].astype(np.uint8)
|
|
476
569
|
return base64.b64encode(sub.tobytes()).decode("ascii")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _scoped_floodfill_response(
|
|
573
|
+
session,
|
|
574
|
+
dim_x: int,
|
|
575
|
+
dim_y: int,
|
|
576
|
+
indices: list[int],
|
|
577
|
+
px: int,
|
|
578
|
+
py: int,
|
|
579
|
+
tolerance: float,
|
|
580
|
+
complex_mode: int,
|
|
581
|
+
scope_dim: int,
|
|
582
|
+
) -> dict:
|
|
583
|
+
shape = session.shape
|
|
584
|
+
if len(indices) < len(shape):
|
|
585
|
+
indices.extend(s // 2 for s in shape[len(indices) :])
|
|
586
|
+
indices = [
|
|
587
|
+
min(max(int(v), 0), shape[i] - 1)
|
|
588
|
+
for i, v in enumerate(indices[: len(shape)])
|
|
589
|
+
]
|
|
590
|
+
scope_dim = int(scope_dim)
|
|
591
|
+
if scope_dim in {dim_x, dim_y} or not (0 <= scope_dim < len(shape)):
|
|
592
|
+
return {"error": "scope_dim must be exactly one non-display dimension"}
|
|
593
|
+
|
|
594
|
+
values = list(range(shape[scope_dim]))
|
|
595
|
+
planes = []
|
|
596
|
+
for value in values:
|
|
597
|
+
idx = list(indices)
|
|
598
|
+
idx[scope_dim] = value
|
|
599
|
+
planes.append(
|
|
600
|
+
apply_complex_mode(
|
|
601
|
+
extract_slice(session, dim_x, dim_y, idx), complex_mode
|
|
602
|
+
)
|
|
603
|
+
)
|
|
604
|
+
volume = np.stack(planes, axis=0)
|
|
605
|
+
depth, h, w = volume.shape
|
|
606
|
+
if not (0 <= py < h and 0 <= px < w):
|
|
607
|
+
return {"error": "seed out of bounds"}
|
|
608
|
+
|
|
609
|
+
seed_z = values.index(indices[scope_dim])
|
|
610
|
+
seed_val = float(volume[seed_z, py, px])
|
|
611
|
+
abs_tol = tolerance * (
|
|
612
|
+
np.nanmax(np.abs(volume)) - np.nanmin(np.abs(volume)) + 1e-10
|
|
613
|
+
)
|
|
614
|
+
mask = np.abs(volume - seed_val) <= abs_tol
|
|
615
|
+
component = _connected_component(mask, (seed_z, py, px))
|
|
616
|
+
roi_values = volume[component]
|
|
617
|
+
finite = roi_values[np.isfinite(roi_values)]
|
|
618
|
+
|
|
619
|
+
slices = []
|
|
620
|
+
scoped_values = []
|
|
621
|
+
for z in range(depth):
|
|
622
|
+
plane_mask = component[z]
|
|
623
|
+
if not plane_mask.any():
|
|
624
|
+
continue
|
|
625
|
+
ys, xs = np.where(plane_mask)
|
|
626
|
+
bbox = {
|
|
627
|
+
"x0": int(xs.min()),
|
|
628
|
+
"y0": int(ys.min()),
|
|
629
|
+
"x1": int(xs.max()),
|
|
630
|
+
"y1": int(ys.max()),
|
|
631
|
+
}
|
|
632
|
+
scope_value = values[z]
|
|
633
|
+
scoped_values.append(scope_value)
|
|
634
|
+
slices.append(
|
|
635
|
+
{
|
|
636
|
+
"index": int(scope_value),
|
|
637
|
+
"bbox": bbox,
|
|
638
|
+
"mask_b64": _encode_mask_b64(plane_mask, bbox),
|
|
639
|
+
}
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
roi = {
|
|
643
|
+
"type": "floodfill",
|
|
644
|
+
"scope_dim": int(scope_dim),
|
|
645
|
+
"scope": {
|
|
646
|
+
"indices": list(indices),
|
|
647
|
+
"broadcast_dims": [int(scope_dim)],
|
|
648
|
+
"values": {str(scope_dim): scoped_values},
|
|
649
|
+
},
|
|
650
|
+
"slices": slices,
|
|
651
|
+
# Full 3D component so the frontend can render the grown region in
|
|
652
|
+
# every multiview pane, not just the plane it was seeded on. Shape
|
|
653
|
+
# axes are [scope_dim, dim_y, dim_x] (the grow axes); grow_dim_x /
|
|
654
|
+
# grow_dim_y record which data dims those are.
|
|
655
|
+
"mask3d_b64": base64.b64encode(component.astype(np.uint8).tobytes()).decode("ascii"),
|
|
656
|
+
"mask3d_shape": [int(s) for s in component.shape],
|
|
657
|
+
"grow_dim_x": int(dim_x),
|
|
658
|
+
"grow_dim_y": int(dim_y),
|
|
659
|
+
}
|
|
660
|
+
current_slice = next(
|
|
661
|
+
(entry for entry in slices if int(entry["index"]) == int(indices[scope_dim])),
|
|
662
|
+
None,
|
|
663
|
+
)
|
|
664
|
+
return {
|
|
665
|
+
"min": _safe_float(finite.min()) if finite.size else None,
|
|
666
|
+
"max": _safe_float(finite.max()) if finite.size else None,
|
|
667
|
+
"mean": _safe_float(finite.mean()) if finite.size else None,
|
|
668
|
+
"std": _safe_float(finite.std()) if finite.size else None,
|
|
669
|
+
"n": int(finite.size),
|
|
670
|
+
"seed_value": _safe_float(seed_val),
|
|
671
|
+
"tolerance": tolerance,
|
|
672
|
+
"bbox": current_slice["bbox"] if current_slice else {"x0": 0, "y0": 0, "x1": 0, "y1": 0},
|
|
673
|
+
"mask_b64": current_slice["mask_b64"] if current_slice else "",
|
|
674
|
+
"scope": roi["scope"],
|
|
675
|
+
"slices": slices,
|
|
676
|
+
"roi": roi,
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _connected_component(mask: np.ndarray, seed: tuple[int, int, int]) -> np.ndarray:
|
|
681
|
+
try:
|
|
682
|
+
from scipy.ndimage import label
|
|
683
|
+
|
|
684
|
+
labeled, _n_features = label(mask)
|
|
685
|
+
seed_label = labeled[seed]
|
|
686
|
+
if seed_label == 0:
|
|
687
|
+
return np.zeros(mask.shape, dtype=bool)
|
|
688
|
+
return labeled == seed_label
|
|
689
|
+
except ImportError:
|
|
690
|
+
component = np.zeros(mask.shape, dtype=bool)
|
|
691
|
+
if not mask[seed]:
|
|
692
|
+
return component
|
|
693
|
+
stack = [seed]
|
|
694
|
+
component[seed] = True
|
|
695
|
+
while stack:
|
|
696
|
+
cz, cy, cx = stack.pop()
|
|
697
|
+
for dz, dy, dx in [
|
|
698
|
+
(-1, 0, 0),
|
|
699
|
+
(1, 0, 0),
|
|
700
|
+
(0, -1, 0),
|
|
701
|
+
(0, 1, 0),
|
|
702
|
+
(0, 0, -1),
|
|
703
|
+
(0, 0, 1),
|
|
704
|
+
]:
|
|
705
|
+
nz, ny, nx = cz + dz, cy + dy, cx + dx
|
|
706
|
+
if (
|
|
707
|
+
0 <= nz < mask.shape[0]
|
|
708
|
+
and 0 <= ny < mask.shape[1]
|
|
709
|
+
and 0 <= nx < mask.shape[2]
|
|
710
|
+
and mask[nz, ny, nx]
|
|
711
|
+
and not component[nz, ny, nx]
|
|
712
|
+
):
|
|
713
|
+
component[nz, ny, nx] = True
|
|
714
|
+
stack.append((nz, ny, nx))
|
|
715
|
+
return component
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _roi_indices_from_body(body: dict, ndim: int, shape: tuple[int, ...]) -> list[int]:
|
|
719
|
+
raw = body.get("indices", "")
|
|
720
|
+
if isinstance(raw, str):
|
|
721
|
+
vals = [int(v) for v in raw.split(",") if v != ""]
|
|
722
|
+
else:
|
|
723
|
+
vals = [int(v) for v in raw]
|
|
724
|
+
if len(vals) < ndim:
|
|
725
|
+
vals.extend(s // 2 for s in shape[len(vals) :])
|
|
726
|
+
return [min(max(v, 0), shape[i] - 1) for i, v in enumerate(vals[:ndim])]
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _roi_scope_indices(
|
|
730
|
+
roi: dict,
|
|
731
|
+
base_indices: list[int],
|
|
732
|
+
shape: tuple[int, ...],
|
|
733
|
+
spatial_dims: set[int],
|
|
734
|
+
):
|
|
735
|
+
scope = roi.get("scope") or {}
|
|
736
|
+
fixed = scope.get("indices") or base_indices
|
|
737
|
+
if isinstance(fixed, str):
|
|
738
|
+
fixed = [int(v) for v in fixed.split(",") if v != ""]
|
|
739
|
+
fixed = list(fixed)
|
|
740
|
+
if len(fixed) < len(shape):
|
|
741
|
+
fixed.extend(base_indices[len(fixed) :])
|
|
742
|
+
fixed = [min(max(int(v), 0), shape[i] - 1) for i, v in enumerate(fixed[: len(shape)])]
|
|
743
|
+
|
|
744
|
+
raw_dims = scope.get("broadcast_dims", [])
|
|
745
|
+
broadcast_dims = []
|
|
746
|
+
for d in raw_dims:
|
|
747
|
+
d = int(d)
|
|
748
|
+
if 0 <= d < len(shape) and d not in spatial_dims:
|
|
749
|
+
broadcast_dims.append(d)
|
|
750
|
+
|
|
751
|
+
ranges = scope.get("ranges") or {}
|
|
752
|
+
values = scope.get("values") or {}
|
|
753
|
+
choices = []
|
|
754
|
+
for d in broadcast_dims:
|
|
755
|
+
key = str(d)
|
|
756
|
+
if key in values:
|
|
757
|
+
vals = [int(v) for v in values[key]]
|
|
758
|
+
elif d in values:
|
|
759
|
+
vals = [int(v) for v in values[d]]
|
|
760
|
+
elif key in ranges:
|
|
761
|
+
lo, hi = ranges[key]
|
|
762
|
+
vals = list(range(int(lo), int(hi) + 1))
|
|
763
|
+
elif d in ranges:
|
|
764
|
+
lo, hi = ranges[d]
|
|
765
|
+
vals = list(range(int(lo), int(hi) + 1))
|
|
766
|
+
else:
|
|
767
|
+
vals = list(range(shape[d]))
|
|
768
|
+
vals = [v for v in vals if 0 <= v < shape[d]]
|
|
769
|
+
choices.append((d, vals or [fixed[d]]))
|
|
770
|
+
|
|
771
|
+
if not choices:
|
|
772
|
+
yield fixed
|
|
773
|
+
return
|
|
774
|
+
for combo in itertools.product(*(vals for _d, vals in choices)):
|
|
775
|
+
idx = list(fixed)
|
|
776
|
+
for (d, _vals), v in zip(choices, combo):
|
|
777
|
+
idx[d] = v
|
|
778
|
+
yield idx
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _roi_scope_dims_string(idx: list[int], base_idx: list[int], spatial_dims: set[int]) -> str:
|
|
782
|
+
bits = []
|
|
783
|
+
for d, val in enumerate(idx):
|
|
784
|
+
bits.append("1" if d in spatial_dims or val != base_idx[d] else "0")
|
|
785
|
+
return "".join(bits)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _roi_mask_for_shape(
|
|
789
|
+
roi: dict, data_shape: tuple[int, int], indices: list[int] | None = None
|
|
790
|
+
) -> np.ndarray:
|
|
791
|
+
h, w = data_shape
|
|
792
|
+
roi_type = roi.get("type", "rect")
|
|
793
|
+
if roi_type == "circle":
|
|
794
|
+
cx = float(roi.get("cx", 0))
|
|
795
|
+
cy = float(roi.get("cy", 0))
|
|
796
|
+
r = max(0.0, float(roi.get("r", 0)))
|
|
797
|
+
ys, xs = np.ogrid[:h, :w]
|
|
798
|
+
return (xs - cx) ** 2 + (ys - cy) ** 2 <= r**2
|
|
799
|
+
if roi_type == "freehand":
|
|
800
|
+
points = roi.get("points", [])
|
|
801
|
+
if len(points) < 3:
|
|
802
|
+
return np.zeros((h, w), dtype=bool)
|
|
803
|
+
from PIL import Image as _PILImage, ImageDraw as _PILDraw
|
|
804
|
+
|
|
805
|
+
mask_img = _PILImage.new("L", (w, h), 0)
|
|
806
|
+
_PILDraw.Draw(mask_img).polygon([(p[0], p[1]) for p in points], fill=255)
|
|
807
|
+
return np.array(mask_img) > 0
|
|
808
|
+
if roi_type == "floodfill":
|
|
809
|
+
return _floodfill_roi_mask(roi, data_shape, indices)
|
|
810
|
+
|
|
811
|
+
x0 = int(round(float(roi.get("x0", 0))))
|
|
812
|
+
y0 = int(round(float(roi.get("y0", 0))))
|
|
813
|
+
x1 = int(round(float(roi.get("x1", x0))))
|
|
814
|
+
y1 = int(round(float(roi.get("y1", y0))))
|
|
815
|
+
xa = max(0, min(x0, x1, w - 1))
|
|
816
|
+
xb = min(w, max(x0, x1) + 1)
|
|
817
|
+
ya = max(0, min(y0, y1, h - 1))
|
|
818
|
+
yb = min(h, max(y0, y1) + 1)
|
|
819
|
+
mask = np.zeros((h, w), dtype=bool)
|
|
820
|
+
if xa < xb and ya < yb:
|
|
821
|
+
mask[ya:yb, xa:xb] = True
|
|
822
|
+
return mask
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _floodfill_roi_mask(
|
|
826
|
+
roi: dict, data_shape: tuple[int, int], indices: list[int] | None = None
|
|
827
|
+
) -> np.ndarray:
|
|
828
|
+
h, w = data_shape
|
|
829
|
+
mask = np.zeros((h, w), dtype=bool)
|
|
830
|
+
if indices is not None and roi.get("slices"):
|
|
831
|
+
scope_dim = roi.get("scope_dim")
|
|
832
|
+
if scope_dim is None:
|
|
833
|
+
scope = roi.get("scope") or {}
|
|
834
|
+
raw_dims = scope.get("broadcast_dims") or []
|
|
835
|
+
scope_dim = raw_dims[0] if len(raw_dims) == 1 else None
|
|
836
|
+
if scope_dim is None:
|
|
837
|
+
return mask
|
|
838
|
+
scope_dim = int(scope_dim)
|
|
839
|
+
if not (0 <= scope_dim < len(indices)):
|
|
840
|
+
return mask
|
|
841
|
+
target = int(indices[scope_dim])
|
|
842
|
+
for entry in roi.get("slices") or []:
|
|
843
|
+
if int(entry.get("index", -1)) == target:
|
|
844
|
+
roi = entry
|
|
845
|
+
break
|
|
846
|
+
else:
|
|
847
|
+
return mask
|
|
848
|
+
bbox = roi.get("bbox") or {}
|
|
849
|
+
encoded = roi.get("mask_b64")
|
|
850
|
+
if not encoded:
|
|
851
|
+
return mask
|
|
852
|
+
try:
|
|
853
|
+
x0 = int(bbox.get("x0", 0))
|
|
854
|
+
y0 = int(bbox.get("y0", 0))
|
|
855
|
+
x1 = int(bbox.get("x1", x0))
|
|
856
|
+
y1 = int(bbox.get("y1", y0))
|
|
857
|
+
bw = x1 - x0 + 1
|
|
858
|
+
bh = y1 - y0 + 1
|
|
859
|
+
if bw <= 0 or bh <= 0:
|
|
860
|
+
return mask
|
|
861
|
+
sub = np.frombuffer(base64.b64decode(encoded), dtype=np.uint8)
|
|
862
|
+
sub = sub.reshape((bh, bw)).astype(bool)
|
|
863
|
+
except Exception:
|
|
864
|
+
return mask
|
|
865
|
+
xa0, ya0 = max(0, x0), max(0, y0)
|
|
866
|
+
xa1, ya1 = min(w, x1 + 1), min(h, y1 + 1)
|
|
867
|
+
if xa0 >= xa1 or ya0 >= ya1:
|
|
868
|
+
return mask
|
|
869
|
+
sx0, sy0 = xa0 - x0, ya0 - y0
|
|
870
|
+
sx1, sy1 = sx0 + (xa1 - xa0), sy0 + (ya1 - ya0)
|
|
871
|
+
mask[ya0:ya1, xa0:xa1] = sub[sy0:sy1, sx0:sx1]
|
|
872
|
+
return mask
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _roi_finite_values(data: np.ndarray, mask: np.ndarray) -> np.ndarray:
|
|
876
|
+
if mask.shape != data.shape:
|
|
877
|
+
return np.array([])
|
|
878
|
+
roi = data[mask]
|
|
879
|
+
return roi[np.isfinite(roi)]
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _combine_roi_rows(rows: list[dict]) -> dict | None:
|
|
883
|
+
if not rows:
|
|
884
|
+
return None
|
|
885
|
+
n = sum(int(r["n"]) for r in rows)
|
|
886
|
+
if n <= 0:
|
|
887
|
+
return None
|
|
888
|
+
means = np.array([float(r["mean"]) for r in rows], dtype=np.float64)
|
|
889
|
+
stds = np.array([float(r["std"]) for r in rows], dtype=np.float64)
|
|
890
|
+
counts = np.array([int(r["n"]) for r in rows], dtype=np.float64)
|
|
891
|
+
mean = float(np.sum(means * counts) / np.sum(counts))
|
|
892
|
+
variance = float(np.sum((stds**2 + (means - mean) ** 2) * counts) / np.sum(counts))
|
|
893
|
+
return {
|
|
894
|
+
"min": min(r["min"] for r in rows),
|
|
895
|
+
"max": max(r["max"] for r in rows),
|
|
896
|
+
"mean": _safe_float(mean),
|
|
897
|
+
"std": _safe_float(math.sqrt(max(0.0, variance))),
|
|
898
|
+
"n": int(n),
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _write_roi_mask(
|
|
903
|
+
labels: np.ndarray,
|
|
904
|
+
mask_2d: np.ndarray,
|
|
905
|
+
indices: list[int],
|
|
906
|
+
dim_x: int,
|
|
907
|
+
dim_y: int,
|
|
908
|
+
label: int,
|
|
909
|
+
) -> None:
|
|
910
|
+
ys, xs = np.nonzero(mask_2d)
|
|
911
|
+
for y, x in zip(ys, xs):
|
|
912
|
+
idx = list(indices)
|
|
913
|
+
idx[dim_y] = int(y)
|
|
914
|
+
idx[dim_x] = int(x)
|
|
915
|
+
labels[tuple(idx)] = label
|
arrayview/_routes_rendering.py
CHANGED
|
@@ -25,6 +25,7 @@ from arrayview._render import (
|
|
|
25
25
|
)
|
|
26
26
|
from arrayview._session import HEAVY_OP_LIMIT_BYTES, SESSIONS
|
|
27
27
|
from arrayview._synthetic_mri import (
|
|
28
|
+
_qmri_adjust_vmin_vmax,
|
|
28
29
|
qmri_display_slice,
|
|
29
30
|
render_qmri_mosaic_rgba,
|
|
30
31
|
synthetic_qmri_slice,
|
|
@@ -256,6 +257,8 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
256
257
|
vmin_override=vmin_override,
|
|
257
258
|
vmax_override=vmax_override,
|
|
258
259
|
)
|
|
260
|
+
if qmri_role:
|
|
261
|
+
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
259
262
|
render_ms = (time.perf_counter() - render_t0) * 1000.0
|
|
260
263
|
encode_t0 = time.perf_counter()
|
|
261
264
|
img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
|
arrayview/_routes_websocket.py
CHANGED
|
@@ -31,6 +31,7 @@ from arrayview._session import (
|
|
|
31
31
|
)
|
|
32
32
|
from arrayview._vectorfield import _compute_vfield_arrows
|
|
33
33
|
from arrayview._synthetic_mri import (
|
|
34
|
+
_qmri_adjust_vmin_vmax,
|
|
34
35
|
qmri_display_slice,
|
|
35
36
|
render_qmri_mosaic_rgba,
|
|
36
37
|
synthetic_qmri_slice,
|
|
@@ -319,6 +320,8 @@ def register_websocket_routes(app) -> None:
|
|
|
319
320
|
vmin_override=vmin_override,
|
|
320
321
|
vmax_override=vmax_override,
|
|
321
322
|
)
|
|
323
|
+
if qmri_role:
|
|
324
|
+
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
322
325
|
|
|
323
326
|
overlay_sid = msg.get("overlay_sid")
|
|
324
327
|
overlay_colors = msg.get("overlay_colors")
|
arrayview/_stdio_server.py
CHANGED
|
@@ -52,6 +52,7 @@ from arrayview._render import (
|
|
|
52
52
|
)
|
|
53
53
|
from arrayview._session import SESSIONS, Session
|
|
54
54
|
from arrayview._synthetic_mri import (
|
|
55
|
+
_qmri_adjust_vmin_vmax,
|
|
55
56
|
qmri_display_slice,
|
|
56
57
|
render_qmri_mosaic_rgba,
|
|
57
58
|
synthetic_qmri_slice,
|
|
@@ -290,8 +291,9 @@ def _handle_lebesgue(sid: str, params: dict[str, str]) -> None:
|
|
|
290
291
|
indices_str = params.get("indices", "")
|
|
291
292
|
complex_mode = int(params.get("complex_mode", "0"))
|
|
292
293
|
log_scale = params.get("log_scale", "false").lower() == "true"
|
|
294
|
+
qmri_role = params.get("qmri_role", "")
|
|
293
295
|
|
|
294
|
-
data = _lebesgue_slice(session, dim_x, dim_y, indices_str, complex_mode, log_scale)
|
|
296
|
+
data = _lebesgue_slice(session, dim_x, dim_y, indices_str, complex_mode, log_scale, qmri_role)
|
|
295
297
|
import base64
|
|
296
298
|
_write_json({
|
|
297
299
|
"_binary": True,
|
|
@@ -637,6 +639,8 @@ def _build_slice_payload(msg: dict) -> bytes:
|
|
|
637
639
|
session, raw, complex_mode, dr, log_scale,
|
|
638
640
|
vmin_override=vmin_override, vmax_override=vmax_override,
|
|
639
641
|
)
|
|
642
|
+
if qmri_role:
|
|
643
|
+
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
640
644
|
|
|
641
645
|
# Overlay compositing
|
|
642
646
|
overlay_sid = msg.get("overlay_sid")
|