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.
Files changed (87) hide show
  1. {arrayview-0.48.1 → arrayview-0.48.3}/PKG-INFO +1 -1
  2. {arrayview-0.48.1 → arrayview-0.48.3}/pyproject.toml +1 -1
  3. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_diff.py +2 -1
  4. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_render.py +129 -35
  5. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_rendering.py +32 -20
  6. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_websocket.py +43 -15
  7. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_session.py +172 -26
  8. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_synthetic_mri.py +2 -2
  9. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_viewer.html +306 -30
  10. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_api.py +94 -0
  11. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_browser.py +40 -0
  12. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_cli.py +7 -3
  13. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_nifti_series.py +129 -4
  14. {arrayview-0.48.1 → arrayview-0.48.3}/.gitignore +0 -0
  15. {arrayview-0.48.1 → arrayview-0.48.3}/LICENSE +0 -0
  16. {arrayview-0.48.1 → arrayview-0.48.3}/README.md +0 -0
  17. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/ARCHITECTURE.md +0 -0
  18. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/__init__.py +0 -0
  19. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/__main__.py +0 -0
  20. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_analysis.py +0 -0
  21. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_app.py +0 -0
  22. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_bootstrap_app.py +0 -0
  23. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_config.py +0 -0
  24. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_diagnostics.py +0 -0
  25. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_dicom.py +0 -0
  26. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_extra_ports.py +0 -0
  27. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_icon.png +0 -0
  28. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_imaging.py +0 -0
  29. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_instance_registry.py +0 -0
  30. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_io.py +0 -0
  31. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_launch_plan.py +0 -0
  32. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_launch_trace.py +0 -0
  33. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_launcher.py +0 -0
  34. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_lifecycle.py +0 -0
  35. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_overlays.py +0 -0
  36. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_platform.py +0 -0
  37. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_analysis.py +0 -0
  38. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_drop.py +0 -0
  39. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_export.py +0 -0
  40. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_loading.py +0 -0
  41. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_persistence.py +0 -0
  42. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_preferences.py +0 -0
  43. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_preload.py +0 -0
  44. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_query.py +0 -0
  45. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_segmentation.py +0 -0
  46. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_state.py +0 -0
  47. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_routes_vectorfield.py +0 -0
  48. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_segmentation.py +0 -0
  49. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_server.py +0 -0
  50. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_session_spec.py +0 -0
  51. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_source_safety.py +0 -0
  53. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_torch.py +0 -0
  54. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_tutorial.py +0 -0
  55. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vectorfield.py +0 -0
  56. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode.py +0 -0
  57. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode_browser.py +0 -0
  58. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode_extension.py +0 -0
  59. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_vscode_signal.py +0 -0
  60. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/_watch.py +0 -0
  61. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/arrayview-opener.vsix +0 -0
  62. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/gsap.min.js +0 -0
  63. {arrayview-0.48.1 → arrayview-0.48.3}/src/arrayview/todo.md +0 -0
  64. {arrayview-0.48.1 → arrayview-0.48.3}/tests/capture_patient_loading.py +0 -0
  65. {arrayview-0.48.1 → arrayview-0.48.3}/tests/capture_v_animation.py +0 -0
  66. {arrayview-0.48.1 → arrayview-0.48.3}/tests/conftest.py +0 -0
  67. {arrayview-0.48.1 → arrayview-0.48.3}/tests/make_vectorfield_test_arrays.py +0 -0
  68. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_1d_arrays.py +0 -0
  69. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_backend_shared.py +0 -0
  70. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_config.py +0 -0
  71. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_dicom.py +0 -0
  72. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_drop_import.py +0 -0
  73. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_instance_registry.py +0 -0
  74. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_io.py +0 -0
  75. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_large_arrays.py +0 -0
  76. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_launch_blackbox.py +0 -0
  77. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_mode_roundtrip.py +0 -0
  78. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_nifti_meta.py +0 -0
  79. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_rgb_pixel_art.py +0 -0
  80. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_safe_server_stop.py +0 -0
  81. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_source_safety.py +0 -0
  82. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_torch.py +0 -0
  83. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_tutorial.py +0 -0
  84. {arrayview-0.48.1 → arrayview-0.48.3}/tests/test_vsix_bundle.py +0 -0
  85. {arrayview-0.48.1 → arrayview-0.48.3}/tests/ui_audit.py +0 -0
  86. {arrayview-0.48.1 → arrayview-0.48.3}/tests/v_anim_frames/.gitkeep +0 -0
  87. {arrayview-0.48.1 → arrayview-0.48.3}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.48.1
3
+ Version: 0.48.3
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.48.1"
7
+ version = "0.48.3"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -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[(normalized * 255).astype(np.uint8)]
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
- with session._raw_lock:
194
- if key in session.raw_cache:
195
- session.raw_cache.move_to_end(key)
196
- return session.raw_cache[key]
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
- with session._raw_lock:
278
- if key in session.raw_cache:
279
- session.raw_cache.move_to_end(key)
280
- return session.raw_cache[key]
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[(normalized * 255).astype(np.uint8)]
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
- vmin = float(np.percentile(all_data, 1))
877
- vmax = float(np.percentile(all_data, 99))
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
- normalized = np.clip((filled - vmin) / (vmax - vmin), 0, 1)
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
- normalized = np.zeros_like(filled)
988
+ grid[:] = 0
890
989
 
891
990
  _ensure_lut(colormap)
892
991
  lut = LUTS.get(colormap, LUTS["gray"])
893
- rgba = lut[(normalized * 255).astype(np.uint8)]
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 HEAVY_OP_LIMIT_BYTES, SESSIONS, _schedule_prefetch
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[(normalized * 255).astype(np.uint8)]
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 mode polls per scroll step (the live
280
- # WS path has its own prefetch call). Without this, a second array
281
- # open puts every pane on the request-then-wait path with nothing
282
- # warmed ahead, which is a much bigger hit than the extra data alone.
283
- # No per-request direction field here (stateless HTTP), so infer it
284
- # from the index this session last rendered, mirroring the WS guard.
285
- if slice_dim >= 0 and dim_z < 0:
286
- prev_idx = getattr(session, "_http_prefetch_prev_idx", None)
287
- direction = 1
288
- if prev_idx is not None and len(prev_idx) == len(idx_tuple):
289
- delta = idx_tuple[slice_dim] - prev_idx[slice_dim]
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[(normalized * 255).astype(np.uint8)]
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[(normalized * 255).astype(np.uint8)]
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[(normalized * 255).astype(np.uint8)]
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[(normalized * 255).astype(np.uint8)]
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 unadjusted window (matching the previous
271
- synchronous path); the returned window is the qMRI-role-adjusted value sent
272
- in the frame header.
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[(normalized * 255).astype(np.uint8)]
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
- pil = _pil_image().fromarray(
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 slice_dim >= 0 and not (dim_z >= 0):
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, direction
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: