arrayview 0.43.3__tar.gz → 0.44.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arrayview-0.43.3 → arrayview-0.44.0}/PKG-INFO +1 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/pyproject.toml +1 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_analysis.py +5 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_diff.py +4 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_io.py +3 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_launch_plan.py +29 -21
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_launcher.py +119 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_websocket.py +79 -30
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_session.py +44 -13
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_viewer.html +1903 -618
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_vscode_browser.py +7 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_vscode_extension.py +1 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_vscode_signal.py +89 -3
- arrayview-0.44.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/lifecycle_matrix.py +1 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_api.py +17 -12
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_backend_shared.py +4 -1
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_browser.py +546 -92
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_launch_plan.py +7 -2
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_lifecycle_contract.py +9 -2
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_mode_consistency.py +493 -6
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_nifti_series.py +31 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/visual_smoke.py +53 -12
- arrayview-0.43.3/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/.gitignore +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/LICENSE +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/README.md +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_server.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/_watch.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/conftest.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_cli.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_config.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_dicom.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_instance_registry.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_io.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_render_failure_reporting.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_render_progress.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_source_safety.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_torch.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_tutorial.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_tutorial_browser.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_value_formatting.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/test_window_level_preview.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/ui_audit.py +0 -0
- {arrayview-0.43.3 → arrayview-0.44.0}/tests/v_anim_frames/.gitkeep +0 -0
|
@@ -42,6 +42,11 @@ def _build_metadata(session) -> dict:
|
|
|
42
42
|
# Collection axes are appended after the image dimensions. They
|
|
43
43
|
# select a case; they must not influence the startup plane.
|
|
44
44
|
default_shape = target_shape[:spatial_ndim]
|
|
45
|
+
# NIfTI metadata identifies the first three axes as anatomical.
|
|
46
|
+
# Later axes may be echoes, parameters, or time points, so they
|
|
47
|
+
# must not change which anatomical plane opens first.
|
|
48
|
+
if getattr(session, "spatial_meta", None) is not None:
|
|
49
|
+
default_shape = default_shape[:3]
|
|
45
50
|
default_dims = _session_mod._startup_dims_for_data(session.data, default_shape)
|
|
46
51
|
if default_dims is not None:
|
|
47
52
|
meta["default_dims"] = [int(default_dims[0]), int(default_dims[1])]
|
|
@@ -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
|
-
|
|
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)
|
|
@@ -172,7 +172,9 @@ def _nifti_header_with_meta(filepath, *, native=False):
|
|
|
172
172
|
(abs(norm_rot[i, j]) for i in range(3) for j in range(3) if i != j),
|
|
173
173
|
default=0.0,
|
|
174
174
|
)
|
|
175
|
-
canonical_shape = tuple(
|
|
175
|
+
canonical_shape = tuple(
|
|
176
|
+
int(img.shape[int(axis)]) for axis in transform[:, 0]
|
|
177
|
+
) + tuple(int(size) for size in img.shape[3:])
|
|
176
178
|
meta = {
|
|
177
179
|
"affine": original_affine,
|
|
178
180
|
"affine_canonical": affine_canonical,
|
|
@@ -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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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=
|
|
415
|
-
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
|
|
@@ -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
|
-
|
|
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="vscode")</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):
|
|
@@ -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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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")
|
|
@@ -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
|
|
517
|
+
"""Read a lazily-backed session array into RAM behind the viewer.
|
|
510
518
|
|
|
511
|
-
``load_data`` hands back a
|
|
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
|
|
514
|
-
off the hot path and swap the array in when
|
|
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
|
|
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
|
|
532
|
+
if data is None or not filepath:
|
|
523
533
|
return False
|
|
524
|
-
|
|
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
|
-
|
|
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
|
|
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
|