arrayview 0.43.2__py3-none-any.whl → 0.43.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
@@ -158,7 +158,10 @@ def _compute_diff(
158
158
  b = _resize_normalized_like(b, a)
159
159
  if diff_mode == 1:
160
160
  raw = a - b
161
- vmin, vmax = -1.0, 1.0
161
+ lo = float(np.nanpercentile(raw, 1))
162
+ hi = float(np.nanpercentile(raw, 99))
163
+ vmax = max(abs(lo), abs(hi)) or 1.0
164
+ vmin = -vmax
162
165
  colormap = "RdBu_r"
163
166
  elif diff_mode == 2:
164
167
  raw = np.abs(a - b)
arrayview/_launch_plan.py CHANGED
@@ -343,22 +343,32 @@ def plan_launch(
343
343
  )
344
344
 
345
345
  environment = facts.environment
346
+ in_jupyter_kernel = intent.invocation is Invocation.JUPYTER or facts.in_jupyter
346
347
  explicit_inline = window == "inline" or (
347
348
  intent.inline is True and intent.inline_explicit
348
349
  )
349
- remote_jupyter = (
350
- facts.is_vscode_remote
351
- and facts.in_jupyter
352
- and intent.inline is not False
353
- and not explicit_inline
354
- )
355
- if remote_jupyter:
356
- environment = Environment.VSCODE_REMOTE
357
- reasons.append("remote_jupyter_uses_vscode")
358
- elif intent.invocation is Invocation.JUPYTER or facts.in_jupyter:
359
- environment = Environment.JUPYTER
350
+ # A VS Code notebook shows the array in the cell, not in a tab. Over a
351
+ # tunnel the cell's iframe additionally needs the port published, because
352
+ # its webview runs on the client and cannot reach this host's localhost —
353
+ # see _inline_url_for_vscode_tunnel and LAUNCH-MATRIX.md row 13. Do not
354
+ # revert this to the VS Code tab; a black cell means the publish failed,
355
+ # and the cell now says so.
356
+ default_inline_ok = in_jupyter_kernel
357
+ if in_jupyter_kernel:
358
+ if facts.is_vscode_remote and intent.inline is not False and not explicit_inline:
359
+ environment = Environment.VSCODE_REMOTE
360
+ else:
361
+ environment = Environment.JUPYTER
360
362
  elif intent.invocation is Invocation.JULIA or facts.in_julia:
361
- environment = Environment.JULIA
363
+ # A Julia process embedded in a VS Code window (local, remote, or
364
+ # tunnel) can still target the VS Code tab — only a standalone Julia
365
+ # process needs the browser-default fallback below.
366
+ if facts.is_vscode_remote:
367
+ environment = Environment.VSCODE_REMOTE
368
+ elif facts.in_vscode_terminal:
369
+ environment = Environment.VSCODE_LOCAL
370
+ else:
371
+ environment = Environment.JULIA
362
372
  elif intent.invocation is Invocation.MATLAB and not facts.is_vscode_remote:
363
373
  environment = Environment.MATLAB
364
374
 
@@ -393,10 +403,10 @@ def plan_launch(
393
403
  owner = ServerOwner.EXISTING
394
404
  registration = Registration.HTTP_LOAD
395
405
  reasons.append("reuse_compatible_server")
396
- elif environment is Environment.JULIA:
406
+ elif intent.invocation is Invocation.JULIA or facts.in_julia:
397
407
  owner = ServerOwner.SPAWNED_DAEMON
398
408
  registration = Registration.DAEMON_STARTUP
399
- reasons.append(f"{environment.value}_requires_subprocess")
409
+ reasons.append("julia_requires_subprocess")
400
410
  elif intent.invocation in {Invocation.CLI, Invocation.VSCODE_EXPLORER}:
401
411
  owner = (
402
412
  ServerOwner.PERSISTENT
@@ -411,9 +421,10 @@ def plan_launch(
411
421
  reasons.append("python_process_owns_session")
412
422
 
413
423
  display, fallback, fallback_allowed = _display_policy(
414
- window=None if remote_jupyter else window,
415
- inline=False if remote_jupyter else intent.inline,
424
+ window=window,
425
+ inline=intent.inline,
416
426
  environment=environment,
427
+ in_jupyter=default_inline_ok,
417
428
  native_available=facts.native_backend is not None,
418
429
  in_vscode_tunnel=facts.in_vscode_tunnel,
419
430
  reasons=reasons,
@@ -439,6 +450,7 @@ def _display_policy(
439
450
  window: str | None,
440
451
  inline: bool | None,
441
452
  environment: Environment,
453
+ in_jupyter: bool,
442
454
  native_available: bool,
443
455
  in_vscode_tunnel: bool,
444
456
  reasons: list[str],
@@ -449,11 +461,7 @@ def _display_policy(
449
461
  if (
450
462
  window == "inline"
451
463
  or inline is True
452
- or (
453
- window is None
454
- and inline is None
455
- and environment is Environment.JUPYTER
456
- )
464
+ or (window is None and inline is None and in_jupyter)
457
465
  ):
458
466
  reasons.append("jupyter_inline" if window is None else "explicit_inline")
459
467
  return Display.INLINE, None, False
arrayview/_launcher.py CHANGED
@@ -3031,6 +3031,11 @@ def _should_use_jupyter_proxy_inline() -> bool:
3031
3031
  if forced:
3032
3032
  return forced not in {"0", "false", "no", "off"}
3033
3033
 
3034
+ # Whether the proxy route is *available*, not whether it will be used.
3035
+ # Only the page can decide that — see _build_jupyter_inline_html. Sniffing
3036
+ # the process tree here got it wrong for a Jupyter server started from a
3037
+ # VS Code terminal and opened in an ordinary browser, which looks exactly
3038
+ # like a VS Code notebook kernel from this side.
3034
3039
  global _JUPYTER_PROXY_INLINE_CACHE
3035
3040
  if _JUPYTER_PROXY_INLINE_CACHE is not None:
3036
3041
  return _JUPYTER_PROXY_INLINE_CACHE
@@ -3114,7 +3119,12 @@ def _build_jupyter_inline_html(
3114
3119
  const frame = host.querySelector('iframe');
3115
3120
  if (!frame) return;
3116
3121
  const directSrc = {json.dumps(viewer_url)};
3117
- const useProxy = {json.dumps(use_proxy)};
3122
+ // The page knows where it is rendering and Python does not. A classic
3123
+ // Jupyter Notebook/Lab page has an http(s) origin for the relative proxy
3124
+ // path to resolve against; a VS Code notebook cell renders in a webview
3125
+ // whose origin is not HTTP at all, so the same path resolves to nothing.
3126
+ const proxyAvailable = {json.dumps(use_proxy)};
3127
+ const useProxy = proxyAvailable && /^https?:$/.test(window.location.protocol);
3118
3128
  const defaultHeight = {height};
3119
3129
  const modeHeights = {_script_json(mode_heights)};
3120
3130
  const modeAliases = {{
@@ -3147,6 +3157,28 @@ def _build_jupyter_inline_html(
3147
3157
  window.removeEventListener('message', onMessage);
3148
3158
  removalObserver.disconnect();
3149
3159
  }};
3160
+ // Nearest ancestor of the host that can actually scroll vertically (JupyterLab
3161
+ // scrolls the notebook pane; classic notebook scrolls the window).
3162
+ const scrollParentOf = (el) => {{
3163
+ let node = el && el.parentElement ? el.parentElement : null;
3164
+ while (node) {{
3165
+ const overflowY = window.getComputedStyle(node).overflowY;
3166
+ const scrollable = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
3167
+ if (scrollable && node.scrollHeight > node.clientHeight) return node;
3168
+ node = node.parentElement;
3169
+ }}
3170
+ return null;
3171
+ }};
3172
+ // WheelEvent deltaMode -> pixel delta: 0 = pixels, 1 = lines (~16px),
3173
+ // 2 = pages (use a large step).
3174
+ const wheelPassthroughPx = (detail) => {{
3175
+ const d = detail || {{}};
3176
+ let deltaY = Number.isFinite(Number(d.deltaY)) ? Number(d.deltaY) : 0;
3177
+ const deltaMode = Number.isFinite(Number(d.deltaMode)) ? Number(d.deltaMode) : 0;
3178
+ if (deltaMode === 1) deltaY *= 16;
3179
+ else if (deltaMode === 2) deltaY *= Math.max(1, window.innerHeight || 600);
3180
+ return deltaY;
3181
+ }};
3150
3182
  const onMessage = (event) => {{
3151
3183
  const msg = event && event.data;
3152
3184
  if (!msg || msg.source !== 'arrayview-viewer') return;
@@ -3158,6 +3190,13 @@ def _build_jupyter_inline_html(
3158
3190
  );
3159
3191
  return;
3160
3192
  }}
3193
+ if (msg.phase === 'wheel-passthrough') {{
3194
+ const deltaY = wheelPassthroughPx(msg.detail);
3195
+ const scroller = scrollParentOf(host);
3196
+ if (scroller) scroller.scrollBy({{ top: deltaY }});
3197
+ else window.scrollBy({{ top: deltaY }});
3198
+ return;
3199
+ }}
3161
3200
  }};
3162
3201
  window.addEventListener('message', onMessage);
3163
3202
  const removalObserver = new MutationObserver(() => {{
@@ -3197,6 +3236,13 @@ def _make_jupyter_proxy_inline_html(
3197
3236
  height: int,
3198
3237
  mode_heights: dict[str, int] | None = None,
3199
3238
  ):
3239
+ # The direct address is the fallback the page uses when its origin cannot
3240
+ # resolve the relative proxy path — which is exactly what a VS Code
3241
+ # notebook cell does — so over a tunnel it has to be one the client can
3242
+ # reach, even though the proxy route is also on offer here.
3243
+ viewer_url, reason = _inline_url_for_vscode_tunnel(viewer_url, port)
3244
+ if reason:
3245
+ return _inline_unreachable_html(reason)
3200
3246
  return _make_jupyter_inline_html(
3201
3247
  viewer_url,
3202
3248
  port,
@@ -3206,6 +3252,75 @@ def _make_jupyter_proxy_inline_html(
3206
3252
  )
3207
3253
 
3208
3254
 
3255
+ # One published route per (port, backend) is enough for a whole session: the
3256
+ # forward stays up, and re-asking costs a round trip through the extension.
3257
+ _INLINE_TUNNEL_ROUTES: dict[tuple[int, str | None], str] = {}
3258
+
3259
+
3260
+ def _inline_url_for_vscode_tunnel(
3261
+ viewer_url: str, port: int
3262
+ ) -> tuple[str, str | None]:
3263
+ """Return ``(url, reason)`` for an inline viewer the notebook cell can load.
3264
+
3265
+ A cell's output renders in a webview on the VS Code client. Over a tunnel
3266
+ that client has nothing on this host's ``localhost``, and it carries no
3267
+ devtunnel cookie, so a private forward answers it with a sign-in redirect
3268
+ and the cell stays black. Publishing the forward is the only address left.
3269
+
3270
+ The viewer tab does not come through here and keeps the private route:
3271
+ VS Code fetches that page itself, on this side.
3272
+ """
3273
+ if not _in_vscode_tunnel():
3274
+ return viewer_url, None
3275
+ from arrayview import _session as _session_mod
3276
+ from arrayview._vscode_signal import _public_tunnel_base
3277
+
3278
+ runtime = _session_mod.SERVER_RUNTIME
3279
+ instance_id = getattr(runtime, "instance_id", None)
3280
+ cache_key = (port, instance_id)
3281
+ base = _INLINE_TUNNEL_ROUTES.get(cache_key)
3282
+ reason = None
3283
+ if base is None:
3284
+ base, reason = _public_tunnel_base(port, instance_id)
3285
+ if base:
3286
+ _INLINE_TUNNEL_ROUTES[cache_key] = base
3287
+ if not base:
3288
+ return viewer_url, reason
3289
+
3290
+ parts = urllib.parse.urlsplit(viewer_url)
3291
+ base_parts = urllib.parse.urlsplit(base)
3292
+ return (
3293
+ urllib.parse.urlunsplit(
3294
+ (
3295
+ base_parts.scheme,
3296
+ base_parts.netloc,
3297
+ parts.path,
3298
+ parts.query,
3299
+ parts.fragment,
3300
+ )
3301
+ ),
3302
+ None,
3303
+ )
3304
+
3305
+
3306
+ def _inline_unreachable_html(reason: str):
3307
+ """Say why a cell cannot show the array, instead of rendering black."""
3308
+ import html
3309
+
3310
+ from IPython.display import HTML
3311
+
3312
+ return HTML(
3313
+ '<div style="padding:14px 16px;border-radius:6px;background:#2a1f1f;'
3314
+ 'border:1px solid #6b3a3a;color:#e6b8b8;font:13px/1.5 '
3315
+ 'ui-monospace,SFMono-Regular,Menlo,monospace">'
3316
+ "<b>ArrayView cannot show this array in the cell.</b><br>"
3317
+ f"{html.escape(reason)}<br>"
3318
+ "<span style=\"color:#c99\">Open it in a tab instead with "
3319
+ "<code>av.view(arr, window=&quot;vscode&quot;)</code>.</span>"
3320
+ "</div>"
3321
+ )
3322
+
3323
+
3209
3324
  def _make_resizable_jupyter_iframe(
3210
3325
  viewer_url: str,
3211
3326
  port: int,
@@ -3216,6 +3331,9 @@ def _make_resizable_jupyter_iframe(
3216
3331
 
3217
3332
  from IPython.display import IFrame
3218
3333
 
3334
+ viewer_url, reason = _inline_url_for_vscode_tunnel(viewer_url, port)
3335
+ if reason:
3336
+ return _inline_unreachable_html(reason)
3219
3337
  iframe = IFrame(src=viewer_url, width="100%", height=height)
3220
3338
 
3221
3339
  def _repr_html_(self):
@@ -51,6 +51,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
51
51
  if not token or len(token) > 256 or not window_id:
52
52
  raise HTTPException(status_code=400, detail="Incomplete viewer phase identity")
53
53
  allowed = {
54
+ "navigation-arrived",
54
55
  "script-loaded",
55
56
  "ws-open",
56
57
  "metadata-loaded",
@@ -58,13 +59,16 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
58
59
  }
59
60
  if phase != "launch-prepared" and phase not in allowed:
60
61
  raise HTTPException(status_code=400, detail="Invalid viewer phase")
61
- if phase != "launch-prepared" and not viewer_instance_id:
62
+ if (
63
+ phase not in {"launch-prepared", "navigation-arrived"}
64
+ and not viewer_instance_id
65
+ ):
62
66
  raise HTTPException(status_code=400, detail="Incomplete viewer phase identity")
63
67
  # Preparation and script startup are control-plane events. The viewer
64
68
  # is meant to open while a large array is still loading, so hiding
65
69
  # either event behind Session readiness creates a false pre-script
66
70
  # timeout. Data-dependent phases still wait for the real Session.
67
- if phase in {"launch-prepared", "script-loaded"}:
71
+ if phase in {"launch-prepared", "navigation-arrived", "script-loaded"}:
68
72
  if sid not in SESSIONS and sid not in _session_mod.PENDING_SESSIONS:
69
73
  raise HTTPException(status_code=404, detail="Session not found")
70
74
  else:
@@ -207,13 +211,24 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
207
211
  raise HTTPException(status_code=409, detail="Viewer phase owner changed")
208
212
  if body.get("sid") != sid:
209
213
  raise HTTPException(status_code=409, detail="Viewer primary session changed")
210
- related_sids = _viewer_related_sids(body, sid)
211
- if journal["related_sids"] is None:
212
- journal["related_sids"] = related_sids
213
- elif journal["related_sids"] != related_sids:
214
- raise HTTPException(status_code=409, detail="Viewer related sessions changed")
215
- if viewer_instance_id not in journal["viewer_instance_ids"]:
216
- journal["viewer_instance_ids"].append(viewer_instance_id)
214
+ if phase == "navigation-arrived":
215
+ navigation_attempt = body.get("navigation_attempt")
216
+ if (
217
+ type(navigation_attempt) is not int
218
+ or navigation_attempt != journal.get("navigation_attempt")
219
+ ):
220
+ raise HTTPException(
221
+ status_code=409,
222
+ detail="Viewer navigation attempt changed",
223
+ )
224
+ else:
225
+ related_sids = _viewer_related_sids(body, sid)
226
+ if journal["related_sids"] is None:
227
+ journal["related_sids"] = related_sids
228
+ elif journal["related_sids"] != related_sids:
229
+ raise HTTPException(status_code=409, detail="Viewer related sessions changed")
230
+ if viewer_instance_id not in journal["viewer_instance_ids"]:
231
+ journal["viewer_instance_ids"].append(viewer_instance_id)
217
232
  phases = journal["phases"]
218
233
  if phase not in phases:
219
234
  phases.append(phase)
@@ -245,7 +260,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
245
260
  "token": token,
246
261
  "phases": list(phases),
247
262
  "viewer_instance_ids": list(journal["viewer_instance_ids"]),
248
- "related_sids": list(journal["related_sids"]),
263
+ "related_sids": list(journal["related_sids"] or []),
249
264
  }
250
265
 
251
266
  @app.get("/viewer-phase/{sid}/{request_id}")
@@ -14,7 +14,6 @@ from arrayview._render import (
14
14
  LUTS,
15
15
  _prepare_display,
16
16
  _ensure_lut,
17
- apply_colormap_rgba,
18
17
  extract_projection,
19
18
  extract_slice,
20
19
  render_mosaic,
@@ -237,6 +236,58 @@ def _render_rgba_from_data(data, colormap, vmin, vmax):
237
236
  return lut[(normalized * 255).astype(np.uint8)]
238
237
 
239
238
 
239
+ def _render_qmri_rgba(
240
+ session,
241
+ dim_x,
242
+ dim_y,
243
+ idx_tuple,
244
+ colormap,
245
+ dr,
246
+ complex_mode,
247
+ log_scale,
248
+ qmri_role,
249
+ vmin_override=None,
250
+ vmax_override=None,
251
+ ):
252
+ """Render a qMRI parameter-map slice to RGBA and return (rgba, vmin, vmax).
253
+
254
+ Kept separate from the generic path so the slice read and the window
255
+ computation happen exactly once, and so multi-pane qMRI/ortho renders run in
256
+ the render thread pool instead of blocking the event loop serially. The
257
+ colour map is applied with the unadjusted window (matching the previous
258
+ synchronous path); the returned window is the qMRI-role-adjusted value sent
259
+ in the frame header.
260
+ """
261
+ raw = qmri_display_slice(session, dim_x, dim_y, list(idx_tuple), qmri_role)
262
+ data, vmin, vmax = _prepare_display(
263
+ session,
264
+ raw,
265
+ complex_mode,
266
+ dr,
267
+ log_scale,
268
+ vmin_override=vmin_override,
269
+ vmax_override=vmax_override,
270
+ )
271
+ if vmax > vmin:
272
+ normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
273
+ else:
274
+ normalized = np.zeros_like(data)
275
+ _ensure_lut(colormap)
276
+ lut = LUTS.get(colormap, LUTS["gray"])
277
+ rgba = lut[(normalized * 255).astype(np.uint8)]
278
+ alpha_on = getattr(session, "alpha_level", 0) > 0
279
+ if alpha_on:
280
+ if vmax > vmin:
281
+ transparent = data < vmin
282
+ else:
283
+ transparent = data == np.float32(0)
284
+ if transparent.any():
285
+ rgba = rgba.copy()
286
+ rgba[transparent, 3] = 0
287
+ vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
288
+ return rgba, vmin, vmax
289
+
290
+
240
291
  async def _notify_shells(sid, name, url=None, wait: bool = True) -> bool:
241
292
  """Push a new-tab message to all connected webview shell windows."""
242
293
  if wait:
@@ -566,19 +617,23 @@ def register_websocket_routes(app) -> None:
566
617
  )
567
618
  else:
568
619
  if qmri_role:
569
- raw = qmri_display_slice(
570
- session, dim_x, dim_y, list(idx_tuple), qmri_role
571
- )
572
- rgba = apply_colormap_rgba(
573
- session,
574
- raw,
575
- colormap,
576
- dr,
577
- complex_mode,
578
- log_scale,
579
- vmin_override=vmin_override,
580
- vmax_override=vmax_override,
620
+ rgba, vmin, vmax = await _render(
621
+ loop,
622
+ lambda: _render_qmri_rgba(
623
+ session,
624
+ dim_x,
625
+ dim_y,
626
+ idx_tuple,
627
+ colormap,
628
+ dr,
629
+ complex_mode,
630
+ log_scale,
631
+ qmri_role,
632
+ vmin_override=vmin_override,
633
+ vmax_override=vmax_override,
634
+ ),
581
635
  )
636
+ h, w = rgba.shape[:2]
582
637
  else:
583
638
  rgba = await _render(
584
639
  loop,
@@ -598,23 +653,17 @@ def register_websocket_routes(app) -> None:
598
653
  ),
599
654
  ),
600
655
  )
601
- h, w = rgba.shape[:2]
602
- raw = (
603
- qmri_display_slice(session, dim_x, dim_y, list(idx_tuple), qmri_role)
604
- if qmri_role
605
- else extract_slice(session, dim_x, dim_y, list(idx_tuple))
606
- )
607
- _, vmin, vmax = _prepare_display(
608
- session,
609
- raw,
610
- complex_mode,
611
- dr,
612
- log_scale,
613
- vmin_override=vmin_override,
614
- vmax_override=vmax_override,
615
- )
616
- if qmri_role:
617
- vmin, vmax = _qmri_adjust_vmin_vmax(vmin, vmax, qmri_role)
656
+ h, w = rgba.shape[:2]
657
+ raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
658
+ _, vmin, vmax = _prepare_display(
659
+ session,
660
+ raw,
661
+ complex_mode,
662
+ dr,
663
+ log_scale,
664
+ vmin_override=vmin_override,
665
+ vmax_override=vmax_override,
666
+ )
618
667
 
619
668
  overlay_sid = msg.get("overlay_sid")
620
669
  overlay_colors = msg.get("overlay_colors")
arrayview/_session.py CHANGED
@@ -505,44 +505,75 @@ def _startup_dims_for_data(data, shape=None) -> tuple[int, int] | None:
505
505
  return _viewer_start_dims_for_shape(target_shape)
506
506
 
507
507
 
508
+ def _nifti_proxy_nbytes(data) -> int:
509
+ """Estimate the in-RAM size of a nibabel ArrayProxy."""
510
+ try:
511
+ return int(np.prod(data.shape)) * np.dtype(data.dtype).itemsize
512
+ except Exception:
513
+ return 0
514
+
515
+
508
516
  def upgrade_memmap_in_background(session) -> bool:
509
- """Read a memmap-backed session into RAM behind the viewer.
517
+ """Read a lazily-backed session array into RAM behind the viewer.
510
518
 
511
- ``load_data`` hands back a memmap only when the first frame is cheap to
519
+ ``load_data`` hands back a lazy source — an ``np.memmap`` for .npy, or a
520
+ nibabel ArrayProxy for uncompressed .nii — when the first frame is cheap to
512
521
  fault in, which buys an immediate picture but leaves later orientations
513
- paying scattered reads. The sequential read still has to happen, so run it
514
- off the hot path and swap the array in when it lands.
522
+ (ortho panes, qMRI planes) paying scattered reads. The sequential read
523
+ still has to happen, so run it off the hot path and swap the array in when
524
+ it lands.
515
525
 
516
526
  Rebinding ``session.data`` is atomic under the GIL and the values are
517
- identical, so a render already holding the memmap stays correct and the
527
+ identical, so a render already holding the lazy array stays correct and the
518
528
  slice caches stay valid.
519
529
  """
520
530
  data = getattr(session, "data", None)
521
531
  filepath = getattr(session, "filepath", None)
522
- if not isinstance(data, np.memmap) or not filepath:
532
+ if data is None or not filepath:
523
533
  return False
524
- if not str(filepath).endswith(".npy"):
534
+ filepath = str(filepath)
535
+
536
+ is_memmap = isinstance(data, np.memmap)
537
+ is_nifti_proxy = filepath.endswith(".nii") and not isinstance(data, np.ndarray)
538
+ if not (is_memmap or is_nifti_proxy):
525
539
  return False
540
+
526
541
  try:
527
542
  from ._io import npy_eager_limit
528
-
529
- if os.path.getsize(filepath) >= npy_eager_limit():
530
- return False # does not fit in RAM — the map is the only option
531
- except OSError:
543
+ except Exception:
532
544
  return False
533
545
 
546
+ if is_memmap:
547
+ if not filepath.endswith(".npy"):
548
+ return False
549
+ try:
550
+ nbytes = os.path.getsize(filepath)
551
+ except OSError:
552
+ return False
553
+ else:
554
+ nbytes = _nifti_proxy_nbytes(data)
555
+ if nbytes >= npy_eager_limit():
556
+ return False # does not fit in RAM — the lazy source is the only option
557
+
534
558
  expected = data
535
559
  version = getattr(session, "data_version", 0)
536
560
 
537
561
  def _upgrade():
538
562
  try:
539
- loaded = np.load(filepath)
563
+ if is_memmap:
564
+ loaded = np.load(filepath)
565
+ else:
566
+ from ._io import _nifti_display_array
567
+
568
+ loaded = _nifti_display_array(data)
540
569
  except Exception:
541
- return # keep serving from the map
570
+ return # keep serving from the lazy source
542
571
  # A /reload or a released session must not be clobbered by a read that
543
572
  # started against the previous array.
544
573
  if session.data is expected and getattr(session, "data_version", 0) == version:
545
574
  session.data = loaded
575
+ if getattr(session, "original_volume", None) is expected:
576
+ session.original_volume = loaded
546
577
 
547
578
  threading.Thread(target=_upgrade, daemon=True).start()
548
579
  return True