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 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
- counts, edges = np.histogram(finite, bins=bins)
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
- return _histogram_payload(finite, bins)
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=False,
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 AND pywebview's GUI bindings.
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
- if importlib.util.find_spec("qtpy") is not None:
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
  # ---------------------------------------------------------------------------
@@ -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
@@ -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")
@@ -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")
@@ -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")