arrayview 0.48.1__py3-none-any.whl → 0.48.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/_diff.py CHANGED
@@ -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
arrayview/_render.py CHANGED
@@ -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:
@@ -276,21 +282,17 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
276
282
  mosaic_cols,
277
283
  )
278
284
  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)
285
+ # This endpoint is what compare, qMRI and multiview panes poll per
286
+ # scroll step (the live WS path has its own prefetch call). No
287
+ # per-request direction field here (stateless HTTP), so the prefetch
288
+ # lane infers it from the position it last saw.
289
+ if projection_mode > 0 and projection_dim >= 0 and not synthetic_mri and session.rgb_axis is None:
290
+ _schedule_projection_prefetch(
291
+ session, dim_x, dim_y, list(idx_tuple), slice_dim,
292
+ projection_dim, projection_mode,
293
+ )
294
+ elif slice_dim >= 0 and dim_z < 0:
295
+ _schedule_prefetch(session, dim_x, dim_y, list(idx_tuple), slice_dim)
294
296
  encode_t0 = time.perf_counter()
295
297
  img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
296
298
  buf = io.BytesIO()
@@ -504,7 +506,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
504
506
  normalized = np.clip((sampled - vmin) / (vmax - vmin), 0, 1)
505
507
  else:
506
508
  normalized = np.zeros_like(sampled)
507
- rgba = lut[(normalized * 255).astype(np.uint8)]
509
+ rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
508
510
 
509
511
  img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
510
512
  if draft and (grid_w != size_w or grid_h != size_h):
@@ -578,7 +580,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
578
580
 
579
581
  _init_luts()
580
582
  lut = LUTS.get(colormap if colormap in LUTS else "gray", LUTS["gray"])
581
- rgba = lut[(normalized * 255).astype(np.uint8)]
583
+ rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
582
584
  rgba[nan_mask] = [22, 22, 22, 255]
583
585
  img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
584
586
  buf = io.BytesIO()
@@ -639,7 +641,7 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
639
641
  normalized = np.clip((frame - vmin) / (vmax - vmin), 0, 1)
640
642
  else:
641
643
  normalized = np.zeros_like(frame)
642
- rgba = lut[(normalized * 255).astype(np.uint8)]
644
+ rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
643
645
  gif_frames.append(_pil_image().fromarray(rgba[:, :, :3], mode="RGB"))
644
646
 
645
647
  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(
@@ -287,7 +291,7 @@ def _render_qmri_rgba(
287
291
  normalized = np.zeros_like(data)
288
292
  _ensure_lut(colormap)
289
293
  lut = LUTS.get(colormap, LUTS["gray"])
290
- rgba = lut[(normalized * 255).astype(np.uint8)]
294
+ rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
291
295
  alpha_on = getattr(session, "alpha_level", 0) > 0
292
296
  if alpha_on:
293
297
  if vmax > vmin:
@@ -485,7 +489,6 @@ def register_websocket_routes(app) -> None:
485
489
  _vmax_ov = msg.get("vmax_override")
486
490
  vmin_override = float(_vmin_ov) if _vmin_ov is not None else None
487
491
  vmax_override = float(_vmax_ov) if _vmax_ov is not None else None
488
- direction = int(msg.get("direction", 1))
489
492
  slice_dim = int(msg.get("slice_dim", -1))
490
493
  canvas_w = int(msg.get("canvas_w", 0))
491
494
  canvas_h = int(msg.get("canvas_h", 0))
@@ -560,6 +563,13 @@ def register_websocket_routes(app) -> None:
560
563
  )
561
564
  h, w = rgba.shape[:2]
562
565
  else:
566
+ # Overlays are drawn onto the full-size grid, so only a
567
+ # bare mosaic can come back already fitted to the canvas.
568
+ mosaic_fit = (
569
+ (canvas_w, canvas_h)
570
+ if canvas_w and canvas_h and not msg.get("overlay_sid")
571
+ else None
572
+ )
563
573
  rgba = await _render(
564
574
  loop,
565
575
  lambda: render_mosaic(
@@ -576,6 +586,7 @@ def register_websocket_routes(app) -> None:
576
586
  vmin_override=vmin_override,
577
587
  vmax_override=vmax_override,
578
588
  progress=_render_progress_reporter(loop, ws, label='Building mosaic'),
589
+ fit=mosaic_fit,
579
590
  ),
580
591
  )
581
592
  rgba = _composite_mosaic_overlays(
@@ -718,12 +729,7 @@ def register_websocket_routes(app) -> None:
718
729
  header = np.array([seq, w, h], dtype=np.uint32).tobytes()
719
730
  vminmax = np.array([vmin, vmax], dtype=np.float32).tobytes()
720
731
  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)
732
+ rgba = fit_rgba(rgba, canvas_w, canvas_h)
727
733
  h, w = rgba.shape[:2]
728
734
  header = np.array([seq, w, h], dtype=np.uint32).tobytes()
729
735
  payload = header + vminmax + rgba.tobytes()
@@ -764,9 +770,30 @@ def register_websocket_routes(app) -> None:
764
770
  }
765
771
  )
766
772
 
767
- if slice_dim >= 0 and not (dim_z >= 0):
773
+ if (
774
+ dim_z < 0 and projection_mode > 0 and projection_dim >= 0
775
+ and not synthetic_mri and session.rgb_axis is None
776
+ ):
777
+ _schedule_projection_prefetch(
778
+ session, dim_x, dim_y, list(idx_tuple), slice_dim,
779
+ projection_dim, projection_mode,
780
+ )
781
+ elif slice_dim >= 0 and not (dim_z >= 0):
782
+ # The lane infers direction from where it last was. Panes
783
+ # that hard-code the message's direction (qMRI, compare
784
+ # qMRI) would otherwise always read ahead forward.
768
785
  _schedule_prefetch(
769
- session, dim_x, dim_y, list(idx_tuple), slice_dim, direction
786
+ session, dim_x, dim_y, list(idx_tuple), slice_dim
787
+ )
788
+ elif dim_z >= 0 and not qmri_role and not synthetic_mri and session.rgb_axis is None:
789
+ _schedule_mosaic_prefetch(
790
+ session, dim_x, dim_y, dim_z, list(idx_tuple), slice_dim,
791
+ dict(
792
+ colormap=colormap, dr=dr, complex_mode=complex_mode,
793
+ log_scale=log_scale, mosaic_cols=mosaic_cols,
794
+ vmin_override=vmin_override, vmax_override=vmax_override,
795
+ fit=mosaic_fit,
796
+ ),
770
797
  )
771
798
  stack_axes = tuple(getattr(session.data, "_stack_axes", ()))
772
799
  if stack_axes:
arrayview/_session.py CHANGED
@@ -294,53 +294,197 @@ def _get_overlay_prefetch_pool():
294
294
  return _OVERLAY_PREFETCH_POOL
295
295
 
296
296
 
297
- def _schedule_prefetch(session, dim_x, dim_y, idx_list, slice_dim, direction):
297
+ def _prefetch_lane_step(session, lane, current, direction):
298
+ """Record that *lane* is now at *current*; return (lanes, generation, direction).
299
+
300
+ Panes at the same position in a lane share one generation; moving the
301
+ lane to a new position starts a new one, which stale queued jobs check.
302
+ ``direction=None`` infers it from the previous position, keeping the last
303
+ known direction when sibling panes re-request the same position.
304
+ """
305
+ lanes = getattr(session, "_prefetch_lanes", None)
306
+ if lanes is None:
307
+ lanes = {}
308
+ session._prefetch_lanes = lanes
309
+ state = lanes.get(lane)
310
+ if direction is None:
311
+ if state is None:
312
+ direction = 1
313
+ elif current != state["pos"]:
314
+ direction = 1 if current > state["pos"] else -1
315
+ else:
316
+ direction = state["dir"]
317
+ if state is None or current != state["pos"]:
318
+ now = _time.monotonic()
319
+ state = {
320
+ "gen": (state["gen"] + 1) if state else 1,
321
+ "pos": current,
322
+ "moved_at": now,
323
+ "interval": (now - state["moved_at"]) if state else None,
324
+ "queued": state["queued"] if state else set(),
325
+ }
326
+ lanes[lane] = state
327
+ state["dir"] = direction
328
+ return lanes, state["gen"], direction
329
+
330
+
331
+ def _schedule_prefetch(session, dim_x, dim_y, idx_list, slice_dim, direction=None):
298
332
  """Warm raw_cache for the next PREFETCH_NEIGHBORS slices in *direction*.
299
333
 
300
334
  Runs in a low-priority background thread so it never blocks WS responses.
301
335
  Skips prefetch when the estimated byte cost exceeds PREFETCH_BUDGET_BYTES.
336
+
337
+ Lookahead is tracked per lane (display dims + scrolled dim), not per
338
+ session. qMRI maps and multiview planes are several panes of one session;
339
+ a single session-wide generation let each pane's request cancel the
340
+ lookahead its siblings had just queued, so only the last pane was ever
341
+ warmed. Panes at the same position in a lane now share one generation,
342
+ and only moving the lane to a new position supersedes queued work.
343
+ ``direction=None`` infers it from the lane's previous position (stateless
344
+ HTTP callers), keeping the last known direction when sibling panes
345
+ re-request the same position.
302
346
  """
303
347
  # Import here to avoid circular dependency — extract_slice lives in _render
304
348
  from arrayview._render import extract_slice
305
349
 
350
+ current = idx_list[slice_dim]
351
+ lane = (dim_x, dim_y, slice_dim)
352
+ lanes, _generation, direction = _prefetch_lane_step(session, lane, current, direction)
353
+ state = lanes[lane]
354
+
306
355
  n = session.shape[slice_dim]
307
356
  # A neighboring collection index can mean decompressing an entire .nii.gz
308
357
  # volume, not reading another small slice. Keep that work conservative.
309
358
  stack_axes = tuple(getattr(session.data, "_stack_axes", ()))
310
- neighbor_count = 1 if slice_dim in stack_axes else PREFETCH_NEIGHBORS
311
- slice_bytes = session.shape[dim_y] * session.shape[dim_x] * 4 # float32
312
- if slice_bytes * neighbor_count > PREFETCH_BUDGET_BYTES:
313
- return # data too large; skip prefetch to avoid memory pressure
314
-
315
- current = idx_list[slice_dim]
316
- targets = []
317
- for step in range(1, neighbor_count + 1):
318
- nxt = current + direction * step
319
- if 0 <= nxt < n:
320
- targets.append(nxt)
321
-
322
- if not targets:
359
+ if slice_dim in stack_axes:
360
+ neighbor_count = 1
361
+ elif state.get("interval") is not None and state["interval"] < FAST_SCROLL_INTERVAL_S:
362
+ # A held key outruns a 3-slice window on a slow drive: every step
363
+ # landed on a slice whose read had only just started.
364
+ neighbor_count = FAST_PREFETCH_NEIGHBORS
365
+ else:
366
+ neighbor_count = PREFETCH_NEIGHBORS
367
+ itemsize = 8 if np.iscomplexobj(np.empty(0, getattr(session.data, "dtype", np.float32))) else 4
368
+ slice_bytes = session.shape[dim_y] * session.shape[dim_x] * itemsize
369
+ # Big images look fewer slices ahead rather than none: switching lookahead
370
+ # off left every step of a 2k x 2k stack waiting on its own read. Only a
371
+ # slice that would crowd the raw cache is never warmed.
372
+ neighbor_count = min(neighbor_count, max(1, PREFETCH_BUDGET_BYTES // slice_bytes))
373
+ if slice_bytes > getattr(session, "RAW_CACHE_BYTES", 4 * PREFETCH_BUDGET_BYTES) // 4:
323
374
  return
324
375
 
325
- # Supersede queued work from older scroll positions. A gzip operation that
326
- # is already running cannot be cancelled safely, but stale queued jobs now
327
- # return without touching disk.
328
- generation = getattr(session, "_prefetch_generation", 0) + 1
329
- session._prefetch_generation = generation
376
+ def _key(t):
377
+ idx = list(idx_list)
378
+ idx[slice_dim] = t
379
+ return idx, (dim_x, dim_y, tuple(None if i in (dim_x, dim_y) else idx[i] for i in range(len(idx))))
330
380
 
331
- def _warm():
332
- for t in targets:
333
- if generation != getattr(session, "_prefetch_generation", 0):
381
+ # One job per slice so several reads are in flight (a network drive
382
+ # serves parallel readers ~2x faster). A queued job is dropped once the
383
+ # lane has moved past it or turned around; jobs still ahead survive new
384
+ # steps instead of being cancelled and requeued on every key repeat.
385
+ def _warm(t, key, idx):
386
+ try:
387
+ live = lanes[lane]
388
+ if (t - live["pos"]) * live["dir"] <= 0:
389
+ return
390
+ if abs(t - live["pos"]) > FAST_PREFETCH_NEIGHBORS:
334
391
  return
335
- idx = list(idx_list)
336
- idx[slice_dim] = t
337
- key_idx = tuple(None if i in (dim_x, dim_y) else idx[i] for i in range(len(idx)))
338
- key = (dim_x, dim_y, key_idx)
339
392
  if key not in session.raw_cache:
340
393
  try:
341
394
  extract_slice(session, dim_x, dim_y, idx)
342
395
  except Exception:
343
396
  pass # prefetch errors are silent
397
+ finally:
398
+ state["queued"].discard(key)
399
+
400
+ pool = None
401
+ for step in range(1, neighbor_count + 1):
402
+ t = current + direction * step
403
+ if not 0 <= t < n:
404
+ break
405
+ idx, key = _key(t)
406
+ if key in session.raw_cache or key in state["queued"]:
407
+ continue
408
+ state["queued"].add(key)
409
+ try:
410
+ pool = pool or _get_prefetch_pool()
411
+ pool.submit(_warm, t, key, idx)
412
+ except RuntimeError:
413
+ state["queued"].discard(key)
414
+ return # pool shutting down
415
+
416
+
417
+ def _schedule_mosaic_prefetch(session, dim_x, dim_y, dim_z, idx_list, slice_dim, render_args):
418
+ """Render the next mosaic along *slice_dim* into mosaic_cache.
419
+
420
+ A mosaic step re-reads and re-colours every slice along *dim_z*, which
421
+ for a large volume is far slower than the hand scrolling it, and the
422
+ slice lookahead does not apply (it warms single slices, not grids).
423
+ One step ahead only: each grid is tens of MB and a CPU-heavy render.
424
+ ``render_args`` are render_mosaic's remaining positional/keyword inputs
425
+ (colormap, dr, complex_mode, log_scale, mosaic_cols, vmin/vmax overrides)
426
+ so the warmed grid hits the same cache key as the real request.
427
+ """
428
+ if slice_dim < 0 or slice_dim == dim_z:
429
+ return
430
+ current = idx_list[slice_dim]
431
+ lane = ("mosaic", dim_x, dim_y, dim_z, slice_dim)
432
+ lanes, generation, direction = _prefetch_lane_step(session, lane, current, None)
433
+ target = current + direction
434
+ if not 0 <= target < session.shape[slice_dim]:
435
+ return
436
+ frames = session.shape[dim_z]
437
+ grid_bytes = frames * session.shape[dim_x] * session.shape[dim_y] * 4
438
+ # Never let the warmed grid push the one on screen out of the cache.
439
+ if 2 * grid_bytes > getattr(session, "MOSAIC_CACHE_BYTES", 0):
440
+ return
441
+
442
+ def _warm():
443
+ if generation != lanes[lane]["gen"]:
444
+ return
445
+ idx = list(idx_list)
446
+ idx[slice_dim] = target
447
+ try:
448
+ from arrayview._render import render_mosaic
449
+
450
+ render_mosaic(session, dim_x, dim_y, dim_z, tuple(idx), **render_args)
451
+ except Exception:
452
+ pass # prefetch errors are silent
453
+
454
+ try:
455
+ _get_prefetch_pool().submit(_warm)
456
+ except RuntimeError:
457
+ pass # pool shutting down
458
+
459
+
460
+ def _schedule_projection_prefetch(session, dim_x, dim_y, idx_list, slice_dim, proj_dim, proj_mode):
461
+ """Compute the next projection along *slice_dim* into raw_cache.
462
+
463
+ A projection collapses a whole sub-volume, so on a lazily read file each
464
+ step waits for a volume read; single-slice lookahead neither predicts it
465
+ nor helps (scrolling the projected dim does not change the picture).
466
+ One step ahead: each costs a full sub-volume read.
467
+ """
468
+ if slice_dim < 0 or slice_dim in (dim_x, dim_y, proj_dim):
469
+ return
470
+ current = idx_list[slice_dim]
471
+ lane = ("proj", dim_x, dim_y, slice_dim, proj_dim, proj_mode)
472
+ lanes, generation, direction = _prefetch_lane_step(session, lane, current, None)
473
+ target = current + direction
474
+ if not 0 <= target < session.shape[slice_dim]:
475
+ return
476
+
477
+ def _warm():
478
+ if generation != lanes[lane]["gen"]:
479
+ return
480
+ idx = list(idx_list)
481
+ idx[slice_dim] = target
482
+ try:
483
+ from arrayview._render import extract_projection
484
+
485
+ extract_projection(session, dim_x, dim_y, idx, proj_dim, proj_mode)
486
+ except Exception:
487
+ pass # prefetch errors are silent
344
488
 
345
489
  try:
346
490
  _get_prefetch_pool().submit(_warm)
@@ -814,6 +958,8 @@ ZARR_T_DEPTH = 2 # T-frames per chunk for 4D+ arrays
814
958
  # Neighbor prefetch (Phase 3)
815
959
  # ---------------------------------------------------------------------------
816
960
  PREFETCH_NEIGHBORS = 3 # slices to prefetch in the scroll direction
961
+ FAST_PREFETCH_NEIGHBORS = 8 # ... while steps come faster than the interval below
962
+ FAST_SCROLL_INTERVAL_S = 0.15
817
963
  PREFETCH_BUDGET_BYTES = 16 * 1024 * 1024 # 16 MB max prefetch per request
818
964
 
819
965
  # ---------------------------------------------------------------------------
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import numpy as np
6
6
 
7
- from arrayview._render import LUTS, _build_mosaic_grid, _ensure_lut, apply_complex_mode, extract_slice
7
+ from arrayview._render import LUTS, lut_rgba, _build_mosaic_grid, _ensure_lut, apply_complex_mode, extract_slice
8
8
 
9
9
 
10
10
  QMRI_ROLE_ORDER: dict[int, list[str]] = {
@@ -202,6 +202,6 @@ def render_qmri_mosaic_rgba(
202
202
  normalized = np.zeros_like(filled)
203
203
  _ensure_lut(colormap)
204
204
  lut = LUTS.get(colormap, LUTS["gray"])
205
- rgba = lut[(normalized * 255).astype(np.uint8)]
205
+ rgba = lut_rgba(lut, (normalized * 255).astype(np.uint8))
206
206
  rgba[nan_mask] = [22, 22, 22, 255]
207
207
  return rgba, vmin, vmax
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.48.1
3
+ Version: 0.48.2
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
@@ -7,7 +7,7 @@ arrayview/_bootstrap_app.py,sha256=ljiZ0o5D3nkJqEGcyyVF06dFWxXujBufQjbcn50QzXM,4
7
7
  arrayview/_config.py,sha256=5ovQTO2I-j3fxCxEKcZKE2pqiahLESGTGS24nQdP61I,13493
8
8
  arrayview/_diagnostics.py,sha256=XB0rCoDn3cpfhz4H-ayLktOgCMIoVJ0wM9N0ep_kKkQ,11018
9
9
  arrayview/_dicom.py,sha256=tNY353MNWTvcXavHeIkqj3242dbgfH_ctwcx9NDee-E,19607
10
- arrayview/_diff.py,sha256=oOwomSxq_xtPEyH1j31N2V-ukUY4CA6ywOQ3iKmOoP4,5802
10
+ arrayview/_diff.py,sha256=10Uqvccr7Ocouphl14dMJzLMUHPhsD81LkM1IbsmLqg,5826
11
11
  arrayview/_extra_ports.py,sha256=MZcx04NI9Eg4coydb-yPIHp7uJbE6UscyYGnw8fxid0,11125
12
12
  arrayview/_icon.png,sha256=a8RTAbvweLhh3hzD5eGg76Peq-KSDcS6YrCphRb_HEs,2748
13
13
  arrayview/_imaging.py,sha256=MQX1uy4m1lofOeoLDhCp6cHO5p_V5fXm4qlSu9Ykn-c,534
@@ -19,7 +19,7 @@ arrayview/_launcher.py,sha256=Hljfeq4AmxVTDiDgYyMtHT5eiatgFdrvcr6bq75onZY,243267
19
19
  arrayview/_lifecycle.py,sha256=bqG0oKr-e-5S1ssCTlGmF-yBE47ilrpO1S_Owfjs9Z8,5365
20
20
  arrayview/_overlays.py,sha256=CksypG9rWfCz_3RRGIeyaKeXse17AFxvkEjmYyXnIIE,5399
21
21
  arrayview/_platform.py,sha256=qSWjRcx9rLIdLvKrRArSueFnbyn5GPKfTEMOc5mCIUs,29970
22
- arrayview/_render.py,sha256=aymnz7v9VgfLRRaGrsLpuqjTbP5nzOXzxyFa1ybP5ME,34845
22
+ arrayview/_render.py,sha256=W2FyrBYNt2dwim_CjYMM5U_Dq_vL5DEU4PL6_ybxIZ8,38564
23
23
  arrayview/_routes_analysis.py,sha256=Xz9Ysr04u07pzSRB4Fzmcl6XwVxyaG6xCWxGwYZi_80,33085
24
24
  arrayview/_routes_drop.py,sha256=FC4e7STdCo3hYIA0C4Yy9Ka2UKFx7nLDU5dkujmc1Vo,12477
25
25
  arrayview/_routes_export.py,sha256=Ha8mMO2E9TlRL5x25-ADV8_8xUiC-jRz9N1oWwgKOsg,3898
@@ -28,18 +28,18 @@ arrayview/_routes_persistence.py,sha256=T03csHb7ppGT-8yMDf227XkqhnhjgSLYj0RW2wmg
28
28
  arrayview/_routes_preferences.py,sha256=xoMHbPKliYspgjaclhx2r8HbP9mFo9Td-nTF76eWVMg,4680
29
29
  arrayview/_routes_preload.py,sha256=KOsankNguxdIWmFW9iZcY9p2SvUN1mOKY7KkW1qMGcc,1729
30
30
  arrayview/_routes_query.py,sha256=HEEmMKuFycUTBNVUSWtUFXCnWB7ZrM3hv9IZWbXz11o,25012
31
- arrayview/_routes_rendering.py,sha256=EQq2QzdtUS_pv35k7prLdlRdO5nByIlQ_Eu0nhVsn1Y,22970
31
+ arrayview/_routes_rendering.py,sha256=HEA0okhZeiOADOEMdntOYe-mPMP2b_hfBBoAgiyQ-M4,22793
32
32
  arrayview/_routes_segmentation.py,sha256=x2bBjQffZryeTMmOH38YdBWethw1o3KpA8V0chJcZHI,23599
33
33
  arrayview/_routes_state.py,sha256=T_xoYcRlv7L47ydTxxiXb0Fp5M5x9KR2U-wkxaVeuMY,12462
34
34
  arrayview/_routes_vectorfield.py,sha256=OWZtAONwRKQHdYnbDAMbOVf0q6pH7kqV8fa1icDhKHM,9734
35
- arrayview/_routes_websocket.py,sha256=CADgPfAsYWfB7IA75_qJ4voL8dQ3y4RjfzPO0vahIkc,42093
35
+ arrayview/_routes_websocket.py,sha256=78XmMyuD_-KjG5dHpzbhtN4dOa5m5BeyHfASAFYg85c,43552
36
36
  arrayview/_segmentation.py,sha256=aENWHmefrKDScXhcZL1X3KgYXE784dpMkmFFo83qnrw,7377
37
37
  arrayview/_server.py,sha256=Ejh-6rWpWo0luhBhtsVq1Z4RFIyOPVv5nzExHtXb7iA,23042
38
- arrayview/_session.py,sha256=jDOERph1hRzqLr9_uzy4Yi0AlLYLD4Y8JJD8bM8r56M,35867
38
+ arrayview/_session.py,sha256=D4I6FLazLEXbo0IdkyRrzhvMNhvAI6dSyYu5I3tMCaM,42137
39
39
  arrayview/_session_spec.py,sha256=mKDfMRsPYQdEmN9hLXmDU_9N7etiDyBXr8mz-fB8QBo,6825
40
40
  arrayview/_shell.html,sha256=vNa2Kiwx_eoB5H329jolYafLhwJDCH9DsJafBqvVMWU,13194
41
41
  arrayview/_source_safety.py,sha256=cI7JAL7DWYuxuB_LnYExvJZaICX2R2qJEJWD1M0z_gg,27664
42
- arrayview/_synthetic_mri.py,sha256=CNL-2WC835i6WCqkvmWBCO6WQ5w7HE5trNb8OyEfe64,7365
42
+ arrayview/_synthetic_mri.py,sha256=KcSYC26dgjBH25bUoqHT0IgJIzOxgXCSLkWr-ZnFq08,7385
43
43
  arrayview/_torch.py,sha256=13sqIMCUu_-b9SQ9KfDCExDz2UMStL87EDVjnbJxAEo,7536
44
44
  arrayview/_tutorial.py,sha256=2zZHJGJDa4n30GE1-9ADjKFAifGBH9vvHXP4uDt7BaA,19547
45
45
  arrayview/_vectorfield.py,sha256=eDqMCMEGjO8Jdt-6Auh01cz3WD9dbF7xYUWD9xHAf1E,8115
@@ -52,8 +52,8 @@ arrayview/_watch.py,sha256=PxxS_5hcj_S6xQf9Ep4p1HLRkrI5iDMh1aRa4olV7ew,1873
52
52
  arrayview/arrayview-opener.vsix,sha256=44uiTM39qj_RXji3pFQaR8TA1jhNB3LYwVU_dGFbP4c,62284
53
53
  arrayview/gsap.min.js,sha256=VGihm4idI0E1uqrWezWsiScB9F_jMosn7qs6pPSkeac,72304
54
54
  arrayview/todo.md,sha256=KhnRzpn6_jezR0nDDB61-zhox-rbOmEGW-A7Z0_7FnU,622
55
- arrayview-0.48.1.dist-info/METADATA,sha256=CFJ0u2vmhoO0Aha0UjUmknepEdM3A1GiEd6D7aRaJhE,2288
56
- arrayview-0.48.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
- arrayview-0.48.1.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
- arrayview-0.48.1.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
- arrayview-0.48.1.dist-info/RECORD,,
55
+ arrayview-0.48.2.dist-info/METADATA,sha256=iF32fWHFv__muQOvQPW_UDhkozFyTaaj4yGhot_ps3U,2288
56
+ arrayview-0.48.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
+ arrayview-0.48.2.dist-info/entry_points.txt,sha256=-0s08axutXkTbINiKs0Q7iv-33aWuTaamUg2q2-NyQ4,60
58
+ arrayview-0.48.2.dist-info/licenses/LICENSE,sha256=34OivjM9faB2QaPjZpMRTIiSx31V3U9cEyQ6F2k-bH0,1062
59
+ arrayview-0.48.2.dist-info/RECORD,,