arrayview 0.48.2__py3-none-any.whl → 0.48.4__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
@@ -85,6 +85,43 @@ def _resize_normalized_like(source: np.ndarray, target: np.ndarray) -> np.ndarra
85
85
  return np.array(image, dtype=np.float32) / 255.0
86
86
 
87
87
 
88
+ def _render_values(session, dim_x, dim_y, idx_tuple, complex_mode, log_scale):
89
+ """Extract a slice in the array's own units (complex/log applied, no rescale)."""
90
+ raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
91
+ data = apply_complex_mode(raw, complex_mode)
92
+ if log_scale:
93
+ data = np.log1p(np.abs(data))
94
+ return np.asarray(data, dtype=np.float32)
95
+
96
+
97
+ def _render_values_mosaic(session, dim_x, dim_y, dim_z, idx_tuple, complex_mode, log_scale):
98
+ """Mosaic of every dim_z slice in the array's own units, plus separator mask."""
99
+ idx_list = list(idx_tuple)
100
+ frames = [
101
+ _render_values(
102
+ session,
103
+ dim_x,
104
+ dim_y,
105
+ [i if j == dim_z else idx_list[j] for j in range(len(session.shape))],
106
+ complex_mode,
107
+ log_scale,
108
+ )
109
+ for i in range(session.shape[dim_z])
110
+ ]
111
+ all_data = np.stack(frames)
112
+ grid, _, _ = _build_mosaic_grid(all_data, len(frames))
113
+ return grid.astype(np.float32), np.isnan(grid)
114
+
115
+
116
+ def _resize_values_like(source: np.ndarray, target: np.ndarray) -> np.ndarray:
117
+ """Resize float source data to target shape without quantising it."""
118
+ if source.shape == target.shape:
119
+ return source
120
+ image = _pil_image().fromarray(np.nan_to_num(source).astype(np.float32), mode="F")
121
+ image = image.resize((target.shape[1], target.shape[0]), _pil_image().BILINEAR)
122
+ return np.array(image, dtype=np.float32)
123
+
124
+
88
125
  def _compute_diff(
89
126
  session_a,
90
127
  session_b,
@@ -114,65 +151,41 @@ def _compute_diff(
114
151
  idx_b = _parse_indices(indices_b)[:ndim_b] if indices_b is not None else idx_tuple[:ndim_b]
115
152
  nan_mask = None
116
153
 
154
+ # True A−B in the arrays' own units. Each side used to be rescaled to its
155
+ # own per-slice display range first, which cancelled real differences
156
+ # (B = 0.9·A showed as zero) and made the map flicker while scrolling.
117
157
  if dim_z >= 0:
118
- a, nan_mask_a = _render_normalized_mosaic(
119
- session_a,
120
- dim_x,
121
- dim_y,
122
- dim_z,
123
- idx_a,
124
- dr,
125
- complex_mode,
126
- log_scale,
158
+ a, nan_mask_a = _render_values_mosaic(
159
+ session_a, dim_x, dim_y, dim_z, idx_a, complex_mode, log_scale
127
160
  )
128
- b, nan_mask_b = _render_normalized_mosaic(
129
- session_b,
130
- dim_x,
131
- dim_y,
132
- dim_z,
133
- idx_b,
134
- dr,
135
- complex_mode,
136
- log_scale,
161
+ b, nan_mask_b = _render_values_mosaic(
162
+ session_b, dim_x, dim_y, dim_z, idx_b, complex_mode, log_scale
137
163
  )
138
- nan_mask = nan_mask_a | nan_mask_b
164
+ b = _resize_values_like(b, a)
165
+ if nan_mask_b.shape == nan_mask_a.shape:
166
+ nan_mask = nan_mask_a | nan_mask_b
167
+ else:
168
+ nan_mask = nan_mask_a
139
169
  else:
140
- a = _render_normalized(
141
- session_a,
142
- dim_x,
143
- dim_y,
144
- idx_a,
145
- dr,
146
- complex_mode,
147
- log_scale,
148
- )
149
- b = _render_normalized(
150
- session_b,
151
- dim_x,
152
- dim_y,
153
- idx_b,
154
- dr,
155
- complex_mode,
156
- log_scale,
157
- )
158
-
159
- b = _resize_normalized_like(b, a)
170
+ a = _render_values(session_a, dim_x, dim_y, idx_a, complex_mode, log_scale)
171
+ b = _render_values(session_b, dim_x, dim_y, idx_b, complex_mode, log_scale)
172
+ b = _resize_values_like(b, a)
160
173
  if diff_mode == 1:
161
174
  raw = a - b
162
175
  lo = float(np.nanpercentile(raw, 1))
163
176
  hi = float(np.nanpercentile(raw, 99))
164
177
  vmax = max(abs(lo), abs(hi)) or 1.0
165
178
  vmin = -vmax
166
- colormap = "RdBu_r"
179
+ colormap = "icefire"
167
180
  elif diff_mode == 2:
168
181
  raw = np.abs(a - b)
169
- vmax = float(raw.max()) or 1.0
182
+ vmax = float(np.nanmax(raw)) or 1.0
170
183
  vmin = 0.0
171
184
  colormap = "afmhot"
172
185
  else:
173
186
  raw = np.abs(a - b) / np.maximum(np.abs(a), 1e-6)
174
187
  raw = np.clip(raw, 0.0, 2.0).astype(np.float32)
175
- vmax = float(raw.max()) or 1.0
188
+ vmax = float(np.nanmax(raw)) or 1.0
176
189
  vmin = 0.0
177
190
  colormap = "afmhot"
178
191
 
@@ -188,7 +201,7 @@ def _render_diff_rgba(
188
201
  ) -> np.ndarray:
189
202
  """Map raw diff data to RGBA."""
190
203
  if vmax > vmin:
191
- normalized = np.clip((raw - vmin) / (vmax - vmin), 0, 1)
204
+ normalized = np.clip(np.nan_to_num((raw - vmin) / (vmax - vmin)), 0, 1)
192
205
  else:
193
206
  normalized = np.zeros_like(raw)
194
207
  _ensure_lut(colormap)
@@ -201,9 +214,12 @@ def _render_diff_rgba(
201
214
 
202
215
  def _diff_histogram(raw: np.ndarray, bins: int) -> dict[str, object]:
203
216
  """Return histogram payload for raw diff data."""
217
+ raw = raw[np.isfinite(raw)]
218
+ if raw.size == 0:
219
+ raw = np.zeros(1, dtype=np.float32)
204
220
  vmin = float(raw.min())
205
221
  vmax = float(raw.max())
206
- counts, edges = np.histogram(raw.ravel(), bins=bins)
222
+ counts, edges = np.histogram(raw, bins=bins)
207
223
  return {
208
224
  "counts": counts.tolist(),
209
225
  "edges": edges.tolist(),
arrayview/_render.py CHANGED
@@ -34,6 +34,43 @@ _mpl_colormaps = None
34
34
  _luts_lock = threading.Lock()
35
35
 
36
36
 
37
+ _ICEFIRE_STOPS = [
38
+ (0.7394, 0.9044, 0.8576),
39
+ (0.6645, 0.8497, 0.8387),
40
+ (0.5749, 0.7908, 0.8202),
41
+ (0.4818, 0.734, 0.8084),
42
+ (0.3863, 0.6778, 0.8043),
43
+ (0.3052, 0.6184, 0.8053),
44
+ (0.2418, 0.5555, 0.8106),
45
+ (0.2141, 0.4873, 0.8157),
46
+ (0.2487, 0.4134, 0.7887),
47
+ (0.2831, 0.3444, 0.715),
48
+ (0.2846, 0.2908, 0.6028),
49
+ (0.2577, 0.2497, 0.4821),
50
+ (0.2205, 0.2121, 0.3737),
51
+ (0.182, 0.1769, 0.2809),
52
+ (0.1492, 0.1465, 0.2053),
53
+ (0.1276, 0.1254, 0.1501),
54
+ (0.1213, 0.1183, 0.1188),
55
+ (0.1538, 0.1167, 0.1227),
56
+ (0.2063, 0.1255, 0.1416),
57
+ (0.2734, 0.1407, 0.1703),
58
+ (0.3514, 0.157, 0.2039),
59
+ (0.4375, 0.1703, 0.2357),
60
+ (0.5298, 0.1784, 0.2601),
61
+ (0.6251, 0.1832, 0.2686),
62
+ (0.7138, 0.2028, 0.2533),
63
+ (0.7898, 0.2469, 0.2268),
64
+ (0.85, 0.3142, 0.2058),
65
+ (0.8971, 0.3932, 0.2012),
66
+ (0.9279, 0.482, 0.2415),
67
+ (0.9479, 0.5722, 0.3318),
68
+ (0.9659, 0.659, 0.4438),
69
+ (0.9829, 0.7447, 0.5597),
70
+ (0.9992, 0.831, 0.6764),
71
+ ]
72
+
73
+
37
74
  def _init_luts():
38
75
  """Build colourmap LUTs on first use (defers matplotlib + qmricolors import)."""
39
76
  global _mpl_colormaps
@@ -46,6 +83,12 @@ def _init_luts():
46
83
  from matplotlib import colormaps
47
84
  from matplotlib.colors import LinearSegmentedColormap
48
85
 
86
+ if "icefire" not in colormaps:
87
+ # seaborn's icefire (blue → near-black → red), sampled at 33 stops so
88
+ # we don't depend on seaborn. Default for the A−B compare center.
89
+ colormaps.register(
90
+ LinearSegmentedColormap.from_list("icefire", _ICEFIRE_STOPS)
91
+ )
49
92
  _mpl_colormaps = colormaps
50
93
  for name in COLORMAPS:
51
94
  lut = np.concatenate(
@@ -210,6 +253,12 @@ def _read_slice(session, dim_x, dim_y, idx_list, progress):
210
253
  if progress is None:
211
254
  progress = getattr(session, 'progress_sink', None)
212
255
  data = session.data
256
+ if not getattr(session, "_ram_upgrade_started", False):
257
+ # Not every loading route asks for this (compare arrays, for one), and
258
+ # without it a scattered plane is re-read from the file on every step.
259
+ from ._session import upgrade_memmap_in_background
260
+
261
+ upgrade_memmap_in_background(session)
213
262
  if isinstance(data, np.memmap) and data.filename:
214
263
  # A slice scattered one element per page reads far faster as many
215
264
  # parallel small reads than by faulting through the map (local disks).
@@ -226,6 +226,16 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
226
226
  if qmri_role
227
227
  else extract_slice(session, dim_x, dim_y, list(idx_tuple))
228
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
+ )
229
239
  rgba = (
230
240
  render_rgba_from_prepared(
231
241
  session,
@@ -431,6 +441,54 @@ def register_rendering_routes(app, *, get_session_or_404) -> None:
431
441
  return Response(status_code=422)
432
442
  return _diff_histogram(raw, bins)
433
443
 
444
+ @app.get("/diff-values/{sid_a}/{sid_b}")
445
+ def get_diff_values(
446
+ sid_a: str,
447
+ sid_b: str,
448
+ dim_x: int,
449
+ dim_y: int,
450
+ indices: str,
451
+ indices_a: str | None = None,
452
+ indices_b: str | None = None,
453
+ dim_z: int = -1,
454
+ dr: int = 1,
455
+ complex_mode: int = 0,
456
+ log_scale: bool = False,
457
+ diff_mode: int = 1,
458
+ ):
459
+ """Raw float32 diff values, so hovering a histogram bin can highlight pixels."""
460
+ session_a = SESSIONS.get(sid_a)
461
+ session_b = SESSIONS.get(sid_b)
462
+ if not session_a or not session_b:
463
+ return Response(status_code=404)
464
+ try:
465
+ raw, _, _, _, _ = _compute_diff(
466
+ session_a,
467
+ session_b,
468
+ dim_x,
469
+ dim_y,
470
+ indices,
471
+ dim_z,
472
+ dr,
473
+ complex_mode,
474
+ log_scale,
475
+ diff_mode,
476
+ indices_a=indices_a,
477
+ indices_b=indices_b,
478
+ )
479
+ except Exception:
480
+ return Response(status_code=422)
481
+ raw = np.ascontiguousarray(raw, dtype=np.float32)
482
+ return Response(
483
+ content=raw.tobytes(),
484
+ media_type="application/octet-stream",
485
+ headers={
486
+ "X-ArrayView-Width": str(raw.shape[1]),
487
+ "X-ArrayView-Height": str(raw.shape[0]),
488
+ "Cache-Control": "no-cache",
489
+ },
490
+ )
491
+
434
492
  @app.get("/oblique/{sid}")
435
493
  def get_oblique(
436
494
  sid: str,
@@ -50,6 +50,47 @@ _RECOVERABLE_DISCONNECT_GRACE_SECONDS = float(
50
50
  )
51
51
 
52
52
 
53
+ # Leading word of a compressed frame: b"AVZ1" read as a little-endian uint32.
54
+ # A raw frame starts with its sequence number, which never gets this large.
55
+ _FRAME_Z_MAGIC = b"AVZ1"
56
+ # Below this a frame crosses any link quickly enough as it is.
57
+ _FRAME_Z_MIN_BYTES = 32 * 1024
58
+ _frame_z_remote = None
59
+
60
+
61
+ def _frame_compression_wanted() -> bool:
62
+ """True when frames should be deflated before they are sent.
63
+
64
+ Over a VS Code tunnel or Remote-SSH forward every frame crosses the network
65
+ as raw RGBA: three compared 224x224 slices are 600 KB per scroll step, and
66
+ that transfer, not rendering, is the lag. Locally the bytes are free, so
67
+ frames stay raw. ``ARRAYVIEW_FRAME_COMPRESS=1``/``0`` forces either way.
68
+ """
69
+ global _frame_z_remote
70
+ forced = os.environ.get("ARRAYVIEW_FRAME_COMPRESS")
71
+ if forced in ("0", "1"):
72
+ return forced == "1"
73
+ if _frame_z_remote is None:
74
+ try:
75
+ from arrayview._platform import _is_vscode_remote
76
+
77
+ _frame_z_remote = bool(_is_vscode_remote())
78
+ except Exception:
79
+ _frame_z_remote = False
80
+ return _frame_z_remote
81
+
82
+
83
+ def _pack_frame(payload: bytes) -> bytes:
84
+ """Deflate *payload* behind the magic word, or return it unchanged when
85
+ that does not save at least a third (noise-like images barely shrink)."""
86
+ import zlib
87
+
88
+ packed = zlib.compress(payload, 1)
89
+ if len(packed) * 3 > len(payload) * 2:
90
+ return payload
91
+ return _FRAME_Z_MAGIC + packed
92
+
93
+
53
94
  def _advance_viewer_epoch(sid: str) -> int:
54
95
  epoch = _session_mod.VIEWER_CONNECTION_EPOCHS.get(sid, 0) + 1
55
96
  _session_mod.VIEWER_CONNECTION_EPOCHS[sid] = epoch
@@ -271,9 +312,10 @@ def _render_qmri_rgba(
271
312
  Kept separate from the generic path so the slice read and the window
272
313
  computation happen exactly once, and so multi-pane qMRI/ortho renders run in
273
314
  the render thread pool instead of blocking the event loop serially. The
274
- colour map is applied with the unadjusted window (matching the previous
275
- synchronous path); the returned window is the qMRI-role-adjusted value sent
276
- in the frame header.
315
+ colour map is applied with the qMRI-role-adjusted window, the same one sent
316
+ in the frame header: the viewer locks that window and sends it back on the
317
+ next slice, so colouring with anything else made the first scroll step
318
+ visibly change the contrast while the colorbar labels stayed put.
277
319
  """
278
320
  raw = qmri_display_slice(session, dim_x, dim_y, list(idx_tuple), qmri_role)
279
321
  data, vmin, vmax = _prepare_display(
@@ -285,6 +327,7 @@ def _render_qmri_rgba(
285
327
  vmin_override=vmin_override,
286
328
  vmax_override=vmax_override,
287
329
  )
330
+ vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
288
331
  if vmax > vmin:
289
332
  normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
290
333
  else:
@@ -301,7 +344,6 @@ def _render_qmri_rgba(
301
344
  if transparent.any():
302
345
  rgba = rgba.copy()
303
346
  rgba[transparent, 3] = 0
304
- vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
305
347
  return rgba, vmin, vmax
306
348
 
307
349
 
@@ -367,6 +409,10 @@ def register_websocket_routes(app) -> None:
367
409
  native_request_id = ws.query_params.get("native_request_id")
368
410
  launch_request_id = ws.query_params.get("launch_request_id")
369
411
  launch_token = ws.query_params.get("launch_token")
412
+ # Only viewers that can inflate frames ask for them packed.
413
+ compress_frames = (
414
+ ws.query_params.get("frame_z") == "1" and _frame_compression_wanted()
415
+ )
370
416
  # Answer the handshake before the array is ready. Holding it closed
371
417
  # until the load finished made the viewer decide the backend was dead:
372
418
  # it gives up after three attempts and shows "could not connect", which
@@ -751,6 +797,9 @@ def register_websocket_routes(app) -> None:
751
797
  ).tobytes()
752
798
  vf_scale = np.array([vf_result["scale"]], dtype=np.float32).tobytes()
753
799
  payload += vf_hdr + vf_scale + arrows.tobytes()
800
+ if compress_frames and len(payload) >= _FRAME_Z_MIN_BYTES:
801
+ raw_payload = payload
802
+ payload = await _render(loop, lambda: _pack_frame(raw_payload))
754
803
  await ws.send_bytes(payload)
755
804
  if perf:
756
805
  total_ms = (time.perf_counter() - total_t0) * 1000.0
arrayview/_session.py CHANGED
@@ -870,6 +870,9 @@ def upgrade_memmap_in_background(session) -> bool:
870
870
  identical, so a render already holding the lazy array stays correct and the
871
871
  slice caches stay valid.
872
872
  """
873
+ if getattr(session, "_ram_upgrade_started", False):
874
+ return False
875
+ session._ram_upgrade_started = True
873
876
  data = getattr(session, "data", None)
874
877
  filepath = getattr(session, "filepath", None)
875
878
  if data is None or not filepath: