arrayview 0.48.1__tar.gz → 0.48.3__tar.gz
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-0.48.1 → arrayview-0.48.3}/PKG-INFO +1 -1
- {arrayview-0.48.1 → arrayview-0.48.3}/pyproject.toml +1 -1
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_diff.py +2 -1
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_render.py +129 -35
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_rendering.py +32 -20
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_websocket.py +43 -15
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_session.py +172 -26
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_synthetic_mri.py +2 -2
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_viewer.html +306 -30
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_api.py +94 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_browser.py +40 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_cli.py +7 -3
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_nifti_series.py +129 -4
- {arrayview-0.48.1 → arrayview-0.48.3}/.gitignore +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/LICENSE +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/README.md +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/__init__.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/__main__.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_app.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_config.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_icon.png +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_io.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_launcher.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_platform.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_server.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_shell.html +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_torch.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode_extension.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_watch.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/todo.md +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/capture_v_animation.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/conftest.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_backend_shared.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_config.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_dicom.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_drop_import.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_instance_registry.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_io.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_large_arrays.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_source_safety.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_torch.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_tutorial.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/ui_audit.py +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.48.1 → arrayview-0.48.3}/tests/visual_smoke.py +0 -0
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
|
|
7
7
|
from arrayview._render import (
|
|
8
|
+
lut_rgba,
|
|
8
9
|
LUTS,
|
|
9
10
|
_build_mosaic_grid,
|
|
10
11
|
_compute_vmin_vmax,
|
|
@@ -192,7 +193,7 @@ def _render_diff_rgba(
|
|
|
192
193
|
normalized = np.zeros_like(raw)
|
|
193
194
|
_ensure_lut(colormap)
|
|
194
195
|
lut = LUTS.get(colormap, LUTS["gray"])
|
|
195
|
-
rgba = lut
|
|
196
|
+
rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
196
197
|
if nan_mask is not None and nan_mask.shape == rgba.shape[:2]:
|
|
197
198
|
rgba[nan_mask] = [22, 22, 22, 255]
|
|
198
199
|
return rgba
|
|
@@ -190,11 +190,13 @@ def extract_slice(session, dim_x, dim_y, idx_list, progress=None):
|
|
|
190
190
|
# slicer below, so the extracted data doesn't depend on them.
|
|
191
191
|
key_idx = tuple(None if i in (dim_x, dim_y) else idx_list[i] for i in range(len(idx_list)))
|
|
192
192
|
key = (dim_x, dim_y, key_idx)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
193
|
+
return _cache_once(
|
|
194
|
+
session, session._raw_lock, "raw_cache", "_raw_bytes", "RAW_CACHE_BYTES",
|
|
195
|
+
key, lambda: _read_slice(session, dim_x, dim_y, idx_list, progress),
|
|
196
|
+
)
|
|
197
|
+
|
|
197
198
|
|
|
199
|
+
def _read_slice(session, dim_x, dim_y, idx_list, progress):
|
|
198
200
|
slicer = [
|
|
199
201
|
slice(None) if i in (dim_x, dim_y) else idx_list[i]
|
|
200
202
|
for i in range(len(session.shape))
|
|
@@ -244,12 +246,6 @@ def extract_slice(session, dim_x, dim_y, idx_list, progress=None):
|
|
|
244
246
|
# buffer as too small. Present the single axis as one row so that
|
|
245
247
|
# invariant holds everywhere instead of at each call site.
|
|
246
248
|
result = result.reshape(1, -1)
|
|
247
|
-
|
|
248
|
-
with session._raw_lock:
|
|
249
|
-
if key not in session.raw_cache: # double-check: another worker may have populated it
|
|
250
|
-
session.raw_cache[key] = result
|
|
251
|
-
session._raw_bytes += result.nbytes
|
|
252
|
-
_evict_lru(session, "_raw_bytes", "raw_cache", "RAW_CACHE_BYTES")
|
|
253
249
|
return result
|
|
254
250
|
|
|
255
251
|
|
|
@@ -274,11 +270,13 @@ def extract_projection(session, dim_x, dim_y, idx_list, proj_dim, proj_mode):
|
|
|
274
270
|
for i in range(len(idx_list))
|
|
275
271
|
)
|
|
276
272
|
key = ("proj", dim_x, dim_y, key_idx, proj_dim, proj_mode)
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
273
|
+
return _cache_once(
|
|
274
|
+
session, session._raw_lock, "raw_cache", "_raw_bytes", "RAW_CACHE_BYTES",
|
|
275
|
+
key, lambda: _compute_projection(session, dim_x, dim_y, idx_list, proj_dim, proj_mode),
|
|
276
|
+
)
|
|
281
277
|
|
|
278
|
+
|
|
279
|
+
def _compute_projection(session, dim_x, dim_y, idx_list, proj_dim, proj_mode):
|
|
282
280
|
# Build slicer: display dims + projection dim get slice(None), rest use idx
|
|
283
281
|
slicer = []
|
|
284
282
|
for i in range(len(session.shape)):
|
|
@@ -317,11 +315,6 @@ def extract_projection(session, dim_x, dim_y, idx_list, proj_dim, proj_mode):
|
|
|
317
315
|
result = np.nan_to_num(result).astype(np.complex64)
|
|
318
316
|
else:
|
|
319
317
|
result = np.nan_to_num(result).astype(np.float32)
|
|
320
|
-
with session._raw_lock:
|
|
321
|
-
if key not in session.raw_cache:
|
|
322
|
-
session.raw_cache[key] = result
|
|
323
|
-
session._raw_bytes += result.nbytes
|
|
324
|
-
_evict_lru(session, "_raw_bytes", "raw_cache", "RAW_CACHE_BYTES")
|
|
325
318
|
return result
|
|
326
319
|
|
|
327
320
|
|
|
@@ -358,6 +351,20 @@ def _prepare_display(
|
|
|
358
351
|
return data, vmin, vmax
|
|
359
352
|
|
|
360
353
|
|
|
354
|
+
def lut_rgba(lut: np.ndarray, indices: np.ndarray) -> np.ndarray:
|
|
355
|
+
"""Return ``lut[indices]`` as (..., 4) uint8, gathering whole pixels.
|
|
356
|
+
|
|
357
|
+
Indexing the (256, 4) table copies four bytes one channel at a time; the
|
|
358
|
+
same table viewed as 256 uint32 gathers each pixel in one step, ~4x
|
|
359
|
+
faster on a large mosaic and byte-for-byte identical. The gather keeps
|
|
360
|
+
the index array's memory order, so a transposed slice is made C-order
|
|
361
|
+
first or the uint8 view cannot be taken.
|
|
362
|
+
"""
|
|
363
|
+
table = np.ascontiguousarray(lut, dtype=np.uint8).view(np.uint32).reshape(-1)
|
|
364
|
+
indices = np.ascontiguousarray(indices)
|
|
365
|
+
return table[indices].view(np.uint8).reshape(*indices.shape, 4)
|
|
366
|
+
|
|
367
|
+
|
|
361
368
|
def _ensure_lut(name: str) -> bool:
|
|
362
369
|
"""Ensure name is in LUTS. Returns True if valid."""
|
|
363
370
|
_init_luts()
|
|
@@ -404,7 +411,7 @@ def apply_colormap_rgba(
|
|
|
404
411
|
normalized = np.zeros_like(data)
|
|
405
412
|
_ensure_lut(colormap)
|
|
406
413
|
lut = LUTS.get(colormap, LUTS["gray"])
|
|
407
|
-
rgba = lut
|
|
414
|
+
rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
408
415
|
alpha_on = getattr(session, "alpha_level", 0) > 0
|
|
409
416
|
if alpha_on:
|
|
410
417
|
if vmax > vmin:
|
|
@@ -835,15 +842,102 @@ def render_mosaic(
|
|
|
835
842
|
vmin_override=None,
|
|
836
843
|
vmax_override=None,
|
|
837
844
|
progress=None,
|
|
845
|
+
fit=None,
|
|
838
846
|
):
|
|
847
|
+
"""Render the mosaic grid of every slice along *dim_z*.
|
|
848
|
+
|
|
849
|
+
``fit=(w, h)`` returns the grid already shrunk to the viewer's canvas
|
|
850
|
+
(as the live socket would before sending) and caches it that way, so a
|
|
851
|
+
grid warmed ahead of time is ready to send: the shrink of a 240-slice
|
|
852
|
+
grid costs as much as rendering it.
|
|
853
|
+
"""
|
|
839
854
|
idx_norm = list(idx_tuple)
|
|
840
855
|
idx_norm[dim_z] = 0
|
|
841
856
|
key = (dim_x, dim_y, dim_z, tuple(idx_norm), colormap, dr, complex_mode, log_scale, mosaic_cols, vmin_override, vmax_override)
|
|
842
|
-
with session._mosaic_lock:
|
|
843
|
-
if key in session.mosaic_cache:
|
|
844
|
-
session.mosaic_cache.move_to_end(key)
|
|
845
|
-
return session.mosaic_cache[key]
|
|
846
857
|
|
|
858
|
+
def build_full():
|
|
859
|
+
return _build_mosaic_rgba(
|
|
860
|
+
session, dim_x, dim_y, dim_z, idx_tuple, colormap, dr, complex_mode,
|
|
861
|
+
log_scale, mosaic_cols, vmin_override, vmax_override, progress,
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
full = lambda: _mosaic_once(session, key, build_full)
|
|
865
|
+
if not fit:
|
|
866
|
+
return full()
|
|
867
|
+
fit_w, fit_h = int(fit[0]), int(fit[1])
|
|
868
|
+
return _mosaic_once(session, key + (("fit", fit_w, fit_h),), lambda: fit_rgba(full(), fit_w, fit_h))
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _mosaic_once(session, key, build):
|
|
872
|
+
return _cache_once(
|
|
873
|
+
session, session._mosaic_lock, "mosaic_cache", "_mosaic_bytes",
|
|
874
|
+
"MOSAIC_CACHE_BYTES", key, build,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _cache_once(session, lock, cache_attr, bytes_attr, budget_attr, key, build):
|
|
879
|
+
"""Return session.<cache_attr>[key], building it once under concurrent asks.
|
|
880
|
+
|
|
881
|
+
Lookahead and the request it anticipated, a hedged duplicate request, or
|
|
882
|
+
several panes can ask for the same entry at the same moment; without this
|
|
883
|
+
each rebuilt (re-read from disk) what another was half-way through,
|
|
884
|
+
doubling the wait it was meant to hide. Later askers wait for the builder
|
|
885
|
+
and take its result; if the builder fails, the next one builds it itself.
|
|
886
|
+
"""
|
|
887
|
+
cache = getattr(session, cache_attr)
|
|
888
|
+
while True:
|
|
889
|
+
with lock:
|
|
890
|
+
if key in cache:
|
|
891
|
+
cache.move_to_end(key)
|
|
892
|
+
return cache[key]
|
|
893
|
+
inflight_attr = f"_{cache_attr}_inflight"
|
|
894
|
+
inflight = getattr(session, inflight_attr, None)
|
|
895
|
+
if inflight is None:
|
|
896
|
+
inflight = {}
|
|
897
|
+
setattr(session, inflight_attr, inflight)
|
|
898
|
+
event = inflight.get(key)
|
|
899
|
+
owner = event is None
|
|
900
|
+
if owner:
|
|
901
|
+
event = inflight[key] = threading.Event()
|
|
902
|
+
if not owner:
|
|
903
|
+
event.wait()
|
|
904
|
+
if hasattr(event, "value"):
|
|
905
|
+
return event.value # even if too big to have stayed cached
|
|
906
|
+
continue # builder failed: build it here
|
|
907
|
+
try:
|
|
908
|
+
value = build()
|
|
909
|
+
event.value = value
|
|
910
|
+
with lock:
|
|
911
|
+
if key not in cache:
|
|
912
|
+
cache[key] = value
|
|
913
|
+
setattr(session, bytes_attr, getattr(session, bytes_attr) + value.nbytes)
|
|
914
|
+
_evict_lru(session, bytes_attr, cache_attr, budget_attr)
|
|
915
|
+
return value
|
|
916
|
+
finally:
|
|
917
|
+
with lock:
|
|
918
|
+
inflight.pop(key, None)
|
|
919
|
+
event.set()
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def fit_rgba(rgba, canvas_w, canvas_h):
|
|
923
|
+
"""Shrink an RGBA frame to fit the viewer's canvas; unchanged if it fits."""
|
|
924
|
+
h, w = rgba.shape[:2]
|
|
925
|
+
if not (canvas_w and canvas_h and (w > canvas_w or h > canvas_h)):
|
|
926
|
+
return rgba
|
|
927
|
+
from ._imaging import ensure_image
|
|
928
|
+
|
|
929
|
+
image_cls = ensure_image()
|
|
930
|
+
pil = image_cls.fromarray(
|
|
931
|
+
rgba.astype(np.uint8) if rgba.dtype != np.uint8 else rgba, mode="RGBA"
|
|
932
|
+
)
|
|
933
|
+
pil.thumbnail((canvas_w, canvas_h), image_cls.LANCZOS)
|
|
934
|
+
return np.array(pil)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _build_mosaic_rgba(
|
|
938
|
+
session, dim_x, dim_y, dim_z, idx_tuple, colormap, dr, complex_mode,
|
|
939
|
+
log_scale, mosaic_cols, vmin_override, vmax_override, progress,
|
|
940
|
+
):
|
|
847
941
|
n = session.shape[dim_z]
|
|
848
942
|
# Reported per slice rather than estimated: each slice faults in roughly the
|
|
849
943
|
# same number of pages, so slice i of n is an honest fraction of the wait.
|
|
@@ -873,30 +967,30 @@ def render_mosaic(
|
|
|
873
967
|
elif complex_mode == 1 and np.iscomplexobj(session.data):
|
|
874
968
|
vmin, vmax = -float(np.pi), float(np.pi)
|
|
875
969
|
else:
|
|
876
|
-
|
|
877
|
-
|
|
970
|
+
# One call partitions once for both ends; two calls sorted the whole
|
|
971
|
+
# stack twice (~0.14 s of a 240-slice mosaic step).
|
|
972
|
+
vmin, vmax = (float(v) for v in np.percentile(all_data, [1, 99]))
|
|
878
973
|
|
|
879
974
|
if mosaic_cols is not None:
|
|
880
975
|
grid, rows, cols = _build_mosaic_grid(all_data, n, cols=mosaic_cols)
|
|
881
976
|
else:
|
|
882
977
|
grid, rows, cols = _build_mosaic_grid(all_data, n)
|
|
883
978
|
|
|
979
|
+
# Map to LUT indices in place: a mosaic is tens of millions of pixels, and
|
|
980
|
+
# each full-size temporary of the where/clip/divide chain cost ~50 ms.
|
|
884
981
|
nan_mask = np.isnan(grid)
|
|
885
|
-
filled = np.where(nan_mask, vmin, grid)
|
|
886
982
|
if vmax > vmin:
|
|
887
|
-
|
|
983
|
+
grid -= vmin
|
|
984
|
+
grid *= 255.0 / (vmax - vmin)
|
|
985
|
+
np.clip(grid, 0, 255, out=grid)
|
|
986
|
+
grid[nan_mask] = 0
|
|
888
987
|
else:
|
|
889
|
-
|
|
988
|
+
grid[:] = 0
|
|
890
989
|
|
|
891
990
|
_ensure_lut(colormap)
|
|
892
991
|
lut = LUTS.get(colormap, LUTS["gray"])
|
|
893
|
-
rgba = lut
|
|
992
|
+
rgba = lut_rgba(lut, grid.astype(np.uint8))
|
|
894
993
|
rgba[nan_mask] = [22, 22, 22, 255]
|
|
895
|
-
with session._mosaic_lock:
|
|
896
|
-
if key not in session.mosaic_cache:
|
|
897
|
-
session.mosaic_cache[key] = rgba
|
|
898
|
-
session._mosaic_bytes += rgba.nbytes
|
|
899
|
-
_evict_lru(session, "_mosaic_bytes", "mosaic_cache", "MOSAIC_CACHE_BYTES")
|
|
900
994
|
return rgba
|
|
901
995
|
|
|
902
996
|
|
|
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
|
|
|
8
8
|
from arrayview._diff import _compute_diff, _diff_histogram, _render_diff_rgba
|
|
9
9
|
from arrayview._overlays import _composite_mosaic_overlays, _composite_overlays
|
|
10
10
|
from arrayview._render import (
|
|
11
|
+
lut_rgba,
|
|
11
12
|
LUTS,
|
|
12
13
|
_build_mosaic_grid,
|
|
13
14
|
_compute_vmin_vmax,
|
|
@@ -23,7 +24,12 @@ from arrayview._render import (
|
|
|
23
24
|
render_rgb_rgba,
|
|
24
25
|
render_rgba,
|
|
25
26
|
)
|
|
26
|
-
from arrayview._session import
|
|
27
|
+
from arrayview._session import (
|
|
28
|
+
HEAVY_OP_LIMIT_BYTES,
|
|
29
|
+
SESSIONS,
|
|
30
|
+
_schedule_prefetch,
|
|
31
|
+
_schedule_projection_prefetch,
|
|
32
|
+
)
|
|
27
33
|
from arrayview._synthetic_mri import (
|
|
28
34
|
_qmri_adjust_vmin_vmax,
|
|
29
35
|
qmri_display_slice,
|
|
@@ -65,7 +71,7 @@ def render_rgba_from_data(data, colormap, vmin, vmax):
|
|
|
65
71
|
normalized = np.zeros_like(data)
|
|
66
72
|
_ensure_lut(colormap)
|
|
67
73
|
lut = LUTS.get(colormap, LUTS["gray"])
|
|
68
|
-
return lut
|
|
74
|
+
return lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
69
75
|
|
|
70
76
|
|
|
71
77
|
def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
@@ -220,6 +226,16 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
220
226
|
if qmri_role
|
|
221
227
|
else extract_slice(session, dim_x, dim_y, list(idx_tuple))
|
|
222
228
|
)
|
|
229
|
+
if qmri_role and (vmin_override is None or vmax_override is None):
|
|
230
|
+
# Colour with the role-adjusted window reported back to the
|
|
231
|
+
# viewer, which locks it for later slices; otherwise the
|
|
232
|
+
# first scroll step visibly changes contrast.
|
|
233
|
+
_, _lo, _hi = _prepare_display(
|
|
234
|
+
session, raw, complex_mode, dr, log_scale
|
|
235
|
+
)
|
|
236
|
+
vmin_override, vmax_override = _qmri_adjust_vmin_vmax(
|
|
237
|
+
_lo, _hi, qmri_role
|
|
238
|
+
)
|
|
223
239
|
rgba = (
|
|
224
240
|
render_rgba_from_prepared(
|
|
225
241
|
session,
|
|
@@ -276,21 +292,17 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
276
292
|
mosaic_cols,
|
|
277
293
|
)
|
|
278
294
|
render_ms = (time.perf_counter() - render_t0) * 1000.0
|
|
279
|
-
# This endpoint is what compare
|
|
280
|
-
# WS path has its own prefetch call).
|
|
281
|
-
#
|
|
282
|
-
#
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if delta:
|
|
291
|
-
direction = 1 if delta > 0 else -1
|
|
292
|
-
session._http_prefetch_prev_idx = idx_tuple
|
|
293
|
-
_schedule_prefetch(session, dim_x, dim_y, list(idx_tuple), slice_dim, direction)
|
|
295
|
+
# This endpoint is what compare, qMRI and multiview panes poll per
|
|
296
|
+
# scroll step (the live WS path has its own prefetch call). No
|
|
297
|
+
# per-request direction field here (stateless HTTP), so the prefetch
|
|
298
|
+
# lane infers it from the position it last saw.
|
|
299
|
+
if projection_mode > 0 and projection_dim >= 0 and not synthetic_mri and session.rgb_axis is None:
|
|
300
|
+
_schedule_projection_prefetch(
|
|
301
|
+
session, dim_x, dim_y, list(idx_tuple), slice_dim,
|
|
302
|
+
projection_dim, projection_mode,
|
|
303
|
+
)
|
|
304
|
+
elif slice_dim >= 0 and dim_z < 0:
|
|
305
|
+
_schedule_prefetch(session, dim_x, dim_y, list(idx_tuple), slice_dim)
|
|
294
306
|
encode_t0 = time.perf_counter()
|
|
295
307
|
img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
|
|
296
308
|
buf = io.BytesIO()
|
|
@@ -504,7 +516,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
504
516
|
normalized = np.clip((sampled - vmin) / (vmax - vmin), 0, 1)
|
|
505
517
|
else:
|
|
506
518
|
normalized = np.zeros_like(sampled)
|
|
507
|
-
rgba = lut
|
|
519
|
+
rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
508
520
|
|
|
509
521
|
img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
|
|
510
522
|
if draft and (grid_w != size_w or grid_h != size_h):
|
|
@@ -578,7 +590,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
578
590
|
|
|
579
591
|
_init_luts()
|
|
580
592
|
lut = LUTS.get(colormap if colormap in LUTS else "gray", LUTS["gray"])
|
|
581
|
-
rgba = lut
|
|
593
|
+
rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
582
594
|
rgba[nan_mask] = [22, 22, 22, 255]
|
|
583
595
|
img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
|
|
584
596
|
buf = io.BytesIO()
|
|
@@ -639,7 +651,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
|
|
|
639
651
|
normalized = np.clip((frame - vmin) / (vmax - vmin), 0, 1)
|
|
640
652
|
else:
|
|
641
653
|
normalized = np.zeros_like(frame)
|
|
642
|
-
rgba = lut
|
|
654
|
+
rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
643
655
|
gif_frames.append(_pil_image().fromarray(rgba[:, :, :3], mode="RGB"))
|
|
644
656
|
|
|
645
657
|
buf = io.BytesIO()
|
|
@@ -11,6 +11,8 @@ from arrayview._analysis import _build_metadata
|
|
|
11
11
|
from arrayview._lifecycle import release_session
|
|
12
12
|
from arrayview._overlays import _composite_mosaic_overlays, _composite_overlays
|
|
13
13
|
from arrayview._render import (
|
|
14
|
+
fit_rgba,
|
|
15
|
+
lut_rgba,
|
|
14
16
|
LUTS,
|
|
15
17
|
_prepare_display,
|
|
16
18
|
_ensure_lut,
|
|
@@ -26,7 +28,9 @@ from arrayview._session import (
|
|
|
26
28
|
SHELL_SOCKETS,
|
|
27
29
|
_render,
|
|
28
30
|
_schedule_overlay_prefetch,
|
|
31
|
+
_schedule_mosaic_prefetch,
|
|
29
32
|
_schedule_prefetch,
|
|
33
|
+
_schedule_projection_prefetch,
|
|
30
34
|
_vprint,
|
|
31
35
|
wait_for_session_ready,
|
|
32
36
|
)
|
|
@@ -246,7 +250,7 @@ def _render_rgba_from_data(data, colormap, vmin, vmax):
|
|
|
246
250
|
normalized = np.zeros_like(data)
|
|
247
251
|
_ensure_lut(colormap)
|
|
248
252
|
lut = LUTS.get(colormap, LUTS["gray"])
|
|
249
|
-
return lut
|
|
253
|
+
return lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
250
254
|
|
|
251
255
|
|
|
252
256
|
def _render_qmri_rgba(
|
|
@@ -267,9 +271,10 @@ def _render_qmri_rgba(
|
|
|
267
271
|
Kept separate from the generic path so the slice read and the window
|
|
268
272
|
computation happen exactly once, and so multi-pane qMRI/ortho renders run in
|
|
269
273
|
the render thread pool instead of blocking the event loop serially. The
|
|
270
|
-
colour map is applied with the
|
|
271
|
-
|
|
272
|
-
|
|
274
|
+
colour map is applied with the qMRI-role-adjusted window, the same one sent
|
|
275
|
+
in the frame header: the viewer locks that window and sends it back on the
|
|
276
|
+
next slice, so colouring with anything else made the first scroll step
|
|
277
|
+
visibly change the contrast while the colorbar labels stayed put.
|
|
273
278
|
"""
|
|
274
279
|
raw = qmri_display_slice(session, dim_x, dim_y, list(idx_tuple), qmri_role)
|
|
275
280
|
data, vmin, vmax = _prepare_display(
|
|
@@ -281,13 +286,14 @@ def _render_qmri_rgba(
|
|
|
281
286
|
vmin_override=vmin_override,
|
|
282
287
|
vmax_override=vmax_override,
|
|
283
288
|
)
|
|
289
|
+
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
284
290
|
if vmax > vmin:
|
|
285
291
|
normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
|
|
286
292
|
else:
|
|
287
293
|
normalized = np.zeros_like(data)
|
|
288
294
|
_ensure_lut(colormap)
|
|
289
295
|
lut = LUTS.get(colormap, LUTS["gray"])
|
|
290
|
-
rgba = lut
|
|
296
|
+
rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
|
|
291
297
|
alpha_on = getattr(session, "alpha_level", 0) > 0
|
|
292
298
|
if alpha_on:
|
|
293
299
|
if vmax > vmin:
|
|
@@ -297,7 +303,6 @@ def _render_qmri_rgba(
|
|
|
297
303
|
if transparent.any():
|
|
298
304
|
rgba = rgba.copy()
|
|
299
305
|
rgba[transparent, 3] = 0
|
|
300
|
-
vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
|
|
301
306
|
return rgba, vmin, vmax
|
|
302
307
|
|
|
303
308
|
|
|
@@ -485,7 +490,6 @@ def register_websocket_routes(app) -> None:
|
|
|
485
490
|
_vmax_ov = msg.get("vmax_override")
|
|
486
491
|
vmin_override = float(_vmin_ov) if _vmin_ov is not None else None
|
|
487
492
|
vmax_override = float(_vmax_ov) if _vmax_ov is not None else None
|
|
488
|
-
direction = int(msg.get("direction", 1))
|
|
489
493
|
slice_dim = int(msg.get("slice_dim", -1))
|
|
490
494
|
canvas_w = int(msg.get("canvas_w", 0))
|
|
491
495
|
canvas_h = int(msg.get("canvas_h", 0))
|
|
@@ -560,6 +564,13 @@ def register_websocket_routes(app) -> None:
|
|
|
560
564
|
)
|
|
561
565
|
h, w = rgba.shape[:2]
|
|
562
566
|
else:
|
|
567
|
+
# Overlays are drawn onto the full-size grid, so only a
|
|
568
|
+
# bare mosaic can come back already fitted to the canvas.
|
|
569
|
+
mosaic_fit = (
|
|
570
|
+
(canvas_w, canvas_h)
|
|
571
|
+
if canvas_w and canvas_h and not msg.get("overlay_sid")
|
|
572
|
+
else None
|
|
573
|
+
)
|
|
563
574
|
rgba = await _render(
|
|
564
575
|
loop,
|
|
565
576
|
lambda: render_mosaic(
|
|
@@ -576,6 +587,7 @@ def register_websocket_routes(app) -> None:
|
|
|
576
587
|
vmin_override=vmin_override,
|
|
577
588
|
vmax_override=vmax_override,
|
|
578
589
|
progress=_render_progress_reporter(loop, ws, label='Building mosaic'),
|
|
590
|
+
fit=mosaic_fit,
|
|
579
591
|
),
|
|
580
592
|
)
|
|
581
593
|
rgba = _composite_mosaic_overlays(
|
|
@@ -718,12 +730,7 @@ def register_websocket_routes(app) -> None:
|
|
|
718
730
|
header = np.array([seq, w, h], dtype=np.uint32).tobytes()
|
|
719
731
|
vminmax = np.array([vmin, vmax], dtype=np.float32).tobytes()
|
|
720
732
|
if canvas_w and canvas_h and (w > canvas_w or h > canvas_h):
|
|
721
|
-
|
|
722
|
-
rgba.astype(np.uint8) if rgba.dtype != np.uint8 else rgba,
|
|
723
|
-
mode="RGBA",
|
|
724
|
-
)
|
|
725
|
-
pil.thumbnail((canvas_w, canvas_h), _pil_image().LANCZOS)
|
|
726
|
-
rgba = np.array(pil)
|
|
733
|
+
rgba = fit_rgba(rgba, canvas_w, canvas_h)
|
|
727
734
|
h, w = rgba.shape[:2]
|
|
728
735
|
header = np.array([seq, w, h], dtype=np.uint32).tobytes()
|
|
729
736
|
payload = header + vminmax + rgba.tobytes()
|
|
@@ -764,9 +771,30 @@ def register_websocket_routes(app) -> None:
|
|
|
764
771
|
}
|
|
765
772
|
)
|
|
766
773
|
|
|
767
|
-
if
|
|
774
|
+
if (
|
|
775
|
+
dim_z < 0 and projection_mode > 0 and projection_dim >= 0
|
|
776
|
+
and not synthetic_mri and session.rgb_axis is None
|
|
777
|
+
):
|
|
778
|
+
_schedule_projection_prefetch(
|
|
779
|
+
session, dim_x, dim_y, list(idx_tuple), slice_dim,
|
|
780
|
+
projection_dim, projection_mode,
|
|
781
|
+
)
|
|
782
|
+
elif slice_dim >= 0 and not (dim_z >= 0):
|
|
783
|
+
# The lane infers direction from where it last was. Panes
|
|
784
|
+
# that hard-code the message's direction (qMRI, compare
|
|
785
|
+
# qMRI) would otherwise always read ahead forward.
|
|
768
786
|
_schedule_prefetch(
|
|
769
|
-
session, dim_x, dim_y, list(idx_tuple), slice_dim
|
|
787
|
+
session, dim_x, dim_y, list(idx_tuple), slice_dim
|
|
788
|
+
)
|
|
789
|
+
elif dim_z >= 0 and not qmri_role and not synthetic_mri and session.rgb_axis is None:
|
|
790
|
+
_schedule_mosaic_prefetch(
|
|
791
|
+
session, dim_x, dim_y, dim_z, list(idx_tuple), slice_dim,
|
|
792
|
+
dict(
|
|
793
|
+
colormap=colormap, dr=dr, complex_mode=complex_mode,
|
|
794
|
+
log_scale=log_scale, mosaic_cols=mosaic_cols,
|
|
795
|
+
vmin_override=vmin_override, vmax_override=vmax_override,
|
|
796
|
+
fit=mosaic_fit,
|
|
797
|
+
),
|
|
770
798
|
)
|
|
771
799
|
stack_axes = tuple(getattr(session.data, "_stack_axes", ()))
|
|
772
800
|
if stack_axes:
|