arrayview 0.44.0__tar.gz → 0.45.1__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.44.0 → arrayview-0.45.1}/PKG-INFO +9 -1
- {arrayview-0.44.0 → arrayview-0.45.1}/README.md +8 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/pyproject.toml +1 -1
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_analysis.py +6 -0
- arrayview-0.45.1/src/arrayview/_bootstrap_app.py +120 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_io.py +46 -5
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_launch_plan.py +6 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_launcher.py +48 -6
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_platform.py +6 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_loading.py +144 -130
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_server.py +142 -106
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_session.py +121 -1
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_viewer.html +1460 -462
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_api.py +89 -9
- arrayview-0.45.1/tests/test_bootstrap_app.py +114 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_browser.py +1471 -66
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_mode_consistency.py +43 -0
- arrayview-0.45.1/tests/test_roi_hud.py +183 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/visual_smoke.py +18 -6
- {arrayview-0.44.0 → arrayview-0.45.1}/.gitignore +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/LICENSE +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/__init__.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/__main__.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_app.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_config.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_diff.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_icon.png +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_render.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_shell.html +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_torch.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode_extension.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_watch.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/todo.md +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/capture_v_animation.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/conftest.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_backend_shared.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_cli.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_config.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_dicom.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_drop_import.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_instance_registry.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_io.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_large_arrays.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_plan.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_trace.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_lifecycle_contract.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_loading_server.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_nifti_series.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_render_failure_reporting.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_render_progress.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_session_spec.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_source_safety.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_torch.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_tutorial.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_tutorial_browser.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_value_formatting.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_view_launch_execution.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_window_level_preview.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/ui_audit.py +0 -0
- {arrayview-0.44.0 → arrayview-0.45.1}/tests/v_anim_frames/.gitkeep +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arrayview
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.45.1
|
|
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
|
|
@@ -59,6 +59,14 @@ uvx arrayview path/to/dicom-series/
|
|
|
59
59
|
Press `v` for the three-plane ortho view. `Shift+3` replaces the current slice,
|
|
60
60
|
or all three ortho panes, with interactive 3D cutaway renders.
|
|
61
61
|
Drop another file onto an open viewer to compare, open separately, or overlay it.
|
|
62
|
+
Press `Shift+R` for ROI analysis: a compact HUD lists each region's mean and standard
|
|
63
|
+
deviation. The HUD sits beside the image when space allows; drag its header
|
|
64
|
+
to move it. Hover a row or region to highlight its counterpart, and use each
|
|
65
|
+
row's trash icon to delete that region. Open the detailed analysis with the
|
|
66
|
+
button beside the HUD's column headings.
|
|
67
|
+
Hold a flood-fill seed and drag up/down to adjust sensitivity; a translucent tapered gauge
|
|
68
|
+
at the starting point shows the current value and the 1%–100% range. The
|
|
69
|
+
pointer hides while adjusting; only the gauge marker moves.
|
|
62
70
|
|
|
63
71
|
|
|
64
72
|
Check the [docs](https://oscarvanderheide.github.io/arrayview/) to learn more.
|
|
@@ -22,6 +22,14 @@ uvx arrayview path/to/dicom-series/
|
|
|
22
22
|
Press `v` for the three-plane ortho view. `Shift+3` replaces the current slice,
|
|
23
23
|
or all three ortho panes, with interactive 3D cutaway renders.
|
|
24
24
|
Drop another file onto an open viewer to compare, open separately, or overlay it.
|
|
25
|
+
Press `Shift+R` for ROI analysis: a compact HUD lists each region's mean and standard
|
|
26
|
+
deviation. The HUD sits beside the image when space allows; drag its header
|
|
27
|
+
to move it. Hover a row or region to highlight its counterpart, and use each
|
|
28
|
+
row's trash icon to delete that region. Open the detailed analysis with the
|
|
29
|
+
button beside the HUD's column headings.
|
|
30
|
+
Hold a flood-fill seed and drag up/down to adjust sensitivity; a translucent tapered gauge
|
|
31
|
+
at the starting point shows the current value and the 1%–100% range. The
|
|
32
|
+
pointer hides while adjusting; only the gauge marker moves.
|
|
25
33
|
|
|
26
34
|
|
|
27
35
|
Check the [docs](https://oscarvanderheide.github.io/arrayview/) to learn more.
|
|
@@ -70,6 +70,12 @@ def _build_metadata(session) -> dict:
|
|
|
70
70
|
[list(shape) for shape in row]
|
|
71
71
|
for row in session.data.ragged_spatial_shapes
|
|
72
72
|
]
|
|
73
|
+
case_voxel_spacings = getattr(session.data, "case_voxel_spacings", None)
|
|
74
|
+
if case_voxel_spacings:
|
|
75
|
+
meta["case_voxel_spacings"] = [
|
|
76
|
+
[list(spacing) for spacing in row]
|
|
77
|
+
for row in case_voxel_spacings
|
|
78
|
+
]
|
|
73
79
|
return meta
|
|
74
80
|
|
|
75
81
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Answer the health check while the real server is still loading.
|
|
2
|
+
|
|
3
|
+
A freshly spawned daemon binds its port within ~150 ms, but importing the web
|
|
4
|
+
framework and the route modules takes another ~300-500 ms (more on a busy
|
|
5
|
+
host). Nothing that arrives in that window can be served by the real app, and
|
|
6
|
+
the launcher, the VS Code opener and the tunnel proxy all wait on ``/ping``
|
|
7
|
+
before they will open a viewer tab.
|
|
8
|
+
|
|
9
|
+
This tiny ASGI app is what uvicorn serves from the moment the socket is bound:
|
|
10
|
+
|
|
11
|
+
* ``GET /ping`` is answered immediately with the daemon's real identity (the
|
|
12
|
+
same payload the real route returns, built from ``_session``), so the tab
|
|
13
|
+
can be opened while the framework is still importing;
|
|
14
|
+
* every other request simply waits in line and is handed to the real app the
|
|
15
|
+
moment it is attached, so the page download overlaps the import instead of
|
|
16
|
+
starting after it;
|
|
17
|
+
* if the real app fails to load, waiting requests get a 503 and the daemon
|
|
18
|
+
exits, which is what happened before too — just later.
|
|
19
|
+
|
|
20
|
+
No ordering that the launch relies on changes: ``/ping`` still reports the
|
|
21
|
+
identity that the registry record carries, and the viewer page still cannot
|
|
22
|
+
arrive before the server that serves it exists.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import threading
|
|
30
|
+
from typing import Awaitable, Callable
|
|
31
|
+
|
|
32
|
+
Scope = dict
|
|
33
|
+
Receive = Callable[[], Awaitable[dict]]
|
|
34
|
+
Send = Callable[[dict], Awaitable[None]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BootstrapApp:
|
|
38
|
+
"""ASGI app that answers ``/ping`` itself until the real app is attached."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._app = None
|
|
42
|
+
self._error: BaseException | None = None
|
|
43
|
+
self._ready = threading.Event()
|
|
44
|
+
|
|
45
|
+
# ── wiring (called from whichever thread loads the real app) ──────────
|
|
46
|
+
def attach(self, app) -> None:
|
|
47
|
+
self._app = app
|
|
48
|
+
self._ready.set()
|
|
49
|
+
|
|
50
|
+
def fail(self, error: BaseException) -> None:
|
|
51
|
+
self._error = error
|
|
52
|
+
self._ready.set()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ready(self) -> bool:
|
|
56
|
+
return self._app is not None
|
|
57
|
+
|
|
58
|
+
# ── ASGI ──────────────────────────────────────────────────────────────
|
|
59
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
60
|
+
kind = scope.get("type")
|
|
61
|
+
if kind == "lifespan":
|
|
62
|
+
await self._lifespan(receive, send)
|
|
63
|
+
return
|
|
64
|
+
if (
|
|
65
|
+
self._app is None
|
|
66
|
+
and kind == "http"
|
|
67
|
+
and scope.get("method") == "GET"
|
|
68
|
+
and scope.get("path") == "/ping"
|
|
69
|
+
):
|
|
70
|
+
await self._ping(send)
|
|
71
|
+
return
|
|
72
|
+
if not self._ready.is_set():
|
|
73
|
+
loop = asyncio.get_running_loop()
|
|
74
|
+
await loop.run_in_executor(None, self._ready.wait)
|
|
75
|
+
if self._app is None:
|
|
76
|
+
await self._unavailable(kind, send)
|
|
77
|
+
return
|
|
78
|
+
await self._app(scope, receive, send)
|
|
79
|
+
|
|
80
|
+
async def _lifespan(self, receive: Receive, send: Send) -> None:
|
|
81
|
+
# The real app registers no startup/shutdown hooks, so uvicorn's
|
|
82
|
+
# lifespan protocol is answered here and never forwarded.
|
|
83
|
+
while True:
|
|
84
|
+
message = await receive()
|
|
85
|
+
if message["type"] == "lifespan.startup":
|
|
86
|
+
await send({"type": "lifespan.startup.complete"})
|
|
87
|
+
elif message["type"] == "lifespan.shutdown":
|
|
88
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
async def _ping(self, send: Send) -> None:
|
|
92
|
+
import arrayview._session as _session_mod
|
|
93
|
+
|
|
94
|
+
payload = _session_mod.ping_payload(
|
|
95
|
+
active_sessions=len(_session_mod.SESSIONS)
|
|
96
|
+
)
|
|
97
|
+
await _send_json(send, 200, payload)
|
|
98
|
+
|
|
99
|
+
async def _unavailable(self, kind: str | None, send: Send) -> None:
|
|
100
|
+
detail = f"ArrayView server failed to start: {self._error!r}"
|
|
101
|
+
if kind == "websocket":
|
|
102
|
+
await send({"type": "websocket.close", "code": 1011, "reason": detail[:120]})
|
|
103
|
+
return
|
|
104
|
+
await _send_json(send, 503, {"error": detail, "type": "ServerStartupFailed"})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def _send_json(send: Send, status: int, payload: dict) -> None:
|
|
108
|
+
body = json.dumps(payload).encode("utf-8")
|
|
109
|
+
await send(
|
|
110
|
+
{
|
|
111
|
+
"type": "http.response.start",
|
|
112
|
+
"status": status,
|
|
113
|
+
"headers": [
|
|
114
|
+
(b"content-type", b"application/json"),
|
|
115
|
+
(b"content-length", str(len(body)).encode("ascii")),
|
|
116
|
+
(b"cache-control", b"no-store"),
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
await send({"type": "http.response.body", "body": body})
|
|
@@ -283,6 +283,11 @@ class _ProgressiveNiftiJob:
|
|
|
283
283
|
if self.future is not None and self.future.cancel():
|
|
284
284
|
self._finish(cancelled=True)
|
|
285
285
|
|
|
286
|
+
def has_plane(self, z_index) -> bool:
|
|
287
|
+
"""True if *z_index* is already decoded (a non-blocking peek)."""
|
|
288
|
+
with self._condition:
|
|
289
|
+
return bool(self._decoded[z_index]) or self._done
|
|
290
|
+
|
|
286
291
|
def plane(self, z_index):
|
|
287
292
|
with self._condition:
|
|
288
293
|
while not self._decoded[z_index] and not self._done:
|
|
@@ -442,7 +447,8 @@ class _ProgressiveNiftiSeriesMixin:
|
|
|
442
447
|
return None
|
|
443
448
|
with self._progressive_lock:
|
|
444
449
|
job = self._progressive_jobs.get(cache_key)
|
|
445
|
-
|
|
450
|
+
is_new_job = job is None
|
|
451
|
+
if is_new_job:
|
|
446
452
|
spec = _progressive_nifti_spec(filepath)
|
|
447
453
|
if spec is None:
|
|
448
454
|
return None
|
|
@@ -471,7 +477,18 @@ class _ProgressiveNiftiSeriesMixin:
|
|
|
471
477
|
overlay_prefetch=overlay_prefetch
|
|
472
478
|
).submit(job.run)
|
|
473
479
|
try:
|
|
474
|
-
|
|
480
|
+
# The request that starts the job waits on just the one plane, so
|
|
481
|
+
# the first frame of a fresh file shows up as soon as the decoder
|
|
482
|
+
# reaches it rather than waiting for the whole thing. Every later
|
|
483
|
+
# request for this same file instead waits for the whole volume
|
|
484
|
+
# (unless its plane is already decoded): scrolling through every
|
|
485
|
+
# plane that way finishes in less total time than paying a small
|
|
486
|
+
# wait on each individual plane (~200ms vs ~315ms measured on a
|
|
487
|
+
# 75-plane volume), so it also keeps later frames from feeling
|
|
488
|
+
# slower than the ones on the other two axes.
|
|
489
|
+
if is_new_job or job.has_plane(z_index):
|
|
490
|
+
return job.plane(z_index)
|
|
491
|
+
return job.volume()[:, :, z_index]
|
|
475
492
|
except _ProgressiveLoadCancelled:
|
|
476
493
|
raise
|
|
477
494
|
except Exception:
|
|
@@ -491,11 +508,12 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
491
508
|
|
|
492
509
|
_av_lazy = True
|
|
493
510
|
|
|
494
|
-
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None):
|
|
511
|
+
def __init__(self, file_matrix, vol_shape, dtype, spatial_meta=None, spacing_matrix=None):
|
|
495
512
|
self._file_matrix = file_matrix
|
|
496
513
|
self._vol_shape = tuple(vol_shape)
|
|
497
514
|
self._dtype = np.dtype(dtype)
|
|
498
515
|
self._spatial_meta = spatial_meta
|
|
516
|
+
self._spacing_matrix = spacing_matrix
|
|
499
517
|
n_patients = len(file_matrix)
|
|
500
518
|
n_modalities = len(file_matrix[0]) if file_matrix else 0
|
|
501
519
|
if n_modalities <= 1:
|
|
@@ -518,6 +536,10 @@ class _NiftiSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
518
536
|
def dtype(self):
|
|
519
537
|
return self._dtype
|
|
520
538
|
|
|
539
|
+
@property
|
|
540
|
+
def case_voxel_spacings(self):
|
|
541
|
+
return self._spacing_matrix
|
|
542
|
+
|
|
521
543
|
def _get_volume(self, p_idx, m_idx):
|
|
522
544
|
cache_key = (p_idx, m_idx)
|
|
523
545
|
with self._cache_lock:
|
|
@@ -711,7 +733,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
711
733
|
_av_lazy = True
|
|
712
734
|
_av_ragged = True
|
|
713
735
|
|
|
714
|
-
def __init__(
|
|
736
|
+
def __init__(
|
|
737
|
+
self, file_matrix, spatial_shapes, dtype, spatial_meta=None, all_nifti=False,
|
|
738
|
+
spacing_matrix=None,
|
|
739
|
+
):
|
|
715
740
|
self._file_matrix = file_matrix
|
|
716
741
|
self._spatial_shapes = [
|
|
717
742
|
[tuple(int(s) for s in shape) for shape in row]
|
|
@@ -719,6 +744,7 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
719
744
|
]
|
|
720
745
|
self._dtype = np.dtype(dtype)
|
|
721
746
|
self._spatial_meta = spatial_meta
|
|
747
|
+
self._spacing_matrix = spacing_matrix
|
|
722
748
|
self._all_nifti = bool(all_nifti)
|
|
723
749
|
self._spatial_ndim = len(self._spatial_shapes[0][0])
|
|
724
750
|
self._vol_shape = tuple(
|
|
@@ -755,6 +781,10 @@ class _RaggedFileSeries(_ProgressiveNiftiSeriesMixin):
|
|
|
755
781
|
def ragged_spatial_shapes(self):
|
|
756
782
|
return self._spatial_shapes
|
|
757
783
|
|
|
784
|
+
@property
|
|
785
|
+
def case_voxel_spacings(self):
|
|
786
|
+
return self._spacing_matrix
|
|
787
|
+
|
|
758
788
|
def _normalize_collection_indices(self, idx_list):
|
|
759
789
|
n_p = len(self._file_matrix)
|
|
760
790
|
n_m = len(self._file_matrix[0]) if self._file_matrix else 0
|
|
@@ -992,12 +1022,14 @@ def _series_from_file_matrix(
|
|
|
992
1022
|
|
|
993
1023
|
if all_nifti:
|
|
994
1024
|
spatial_shapes = []
|
|
1025
|
+
voxel_sizes_list = []
|
|
995
1026
|
for fpath, img, item_meta in _iter_nifti_headers(
|
|
996
1027
|
all_paths, scan_progress=scan_progress, scan_label=scan_label
|
|
997
1028
|
):
|
|
998
1029
|
shape = tuple(item_meta["canonical_shape"])
|
|
999
1030
|
dtype = img.get_data_dtype()
|
|
1000
1031
|
spatial_shapes.append(shape)
|
|
1032
|
+
voxel_sizes_list.append(tuple(item_meta["voxel_sizes"]))
|
|
1001
1033
|
if ref_shape is None:
|
|
1002
1034
|
ref_shape = shape
|
|
1003
1035
|
ref_dtype = dtype
|
|
@@ -1013,16 +1045,21 @@ def _series_from_file_matrix(
|
|
|
1013
1045
|
f"{dtype}, expected {ref_dtype}."
|
|
1014
1046
|
)
|
|
1015
1047
|
shape_matrix = []
|
|
1048
|
+
spacing_matrix = []
|
|
1016
1049
|
pos = 0
|
|
1017
1050
|
for row in file_matrix:
|
|
1018
1051
|
row_shapes = []
|
|
1052
|
+
row_spacings = []
|
|
1019
1053
|
for path in row:
|
|
1020
1054
|
if path is None:
|
|
1021
1055
|
row_shapes.append(ref_shape)
|
|
1056
|
+
row_spacings.append(voxel_sizes_list[0] if voxel_sizes_list else (1.0, 1.0, 1.0))
|
|
1022
1057
|
else:
|
|
1023
1058
|
row_shapes.append(spatial_shapes[pos])
|
|
1059
|
+
row_spacings.append(voxel_sizes_list[pos])
|
|
1024
1060
|
pos += 1
|
|
1025
1061
|
shape_matrix.append(row_shapes)
|
|
1062
|
+
spacing_matrix.append(row_spacings)
|
|
1026
1063
|
shapes_differ = any(shape != ref_shape for shape in spatial_shapes)
|
|
1027
1064
|
if stack == "dense" and shapes_differ:
|
|
1028
1065
|
raise ValueError("Dense stacking requires every file to have the same shape.")
|
|
@@ -1033,9 +1070,13 @@ def _series_from_file_matrix(
|
|
|
1033
1070
|
ref_dtype,
|
|
1034
1071
|
spatial_meta=spatial_meta,
|
|
1035
1072
|
all_nifti=True,
|
|
1073
|
+
spacing_matrix=spacing_matrix,
|
|
1036
1074
|
)
|
|
1037
1075
|
else:
|
|
1038
|
-
series = _NiftiSeries(
|
|
1076
|
+
series = _NiftiSeries(
|
|
1077
|
+
file_matrix, ref_shape, ref_dtype, spatial_meta,
|
|
1078
|
+
spacing_matrix=spacing_matrix,
|
|
1079
|
+
)
|
|
1039
1080
|
if load == "eager":
|
|
1040
1081
|
for p, row in enumerate(file_matrix):
|
|
1041
1082
|
for m, path in enumerate(row):
|
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
import socket
|
|
10
10
|
import sys
|
|
11
11
|
import time
|
|
12
|
+
import urllib.error
|
|
12
13
|
import urllib.request
|
|
13
14
|
import uuid
|
|
14
15
|
|
|
@@ -710,6 +711,11 @@ def _ping_arrayview_server(port: int) -> dict | None:
|
|
|
710
711
|
payload = json.loads(resp.read().decode("utf-8"))
|
|
711
712
|
if payload.get("ok") is True and payload.get("service") == "arrayview":
|
|
712
713
|
return payload
|
|
714
|
+
except urllib.error.URLError as exc:
|
|
715
|
+
# Nobody is listening on the port: retrying cannot change that,
|
|
716
|
+
# and the two 50 ms pauses were paid on every fresh launch.
|
|
717
|
+
if isinstance(exc.reason, ConnectionRefusedError):
|
|
718
|
+
return None
|
|
713
719
|
except Exception:
|
|
714
720
|
pass
|
|
715
721
|
if attempt < 2:
|
|
@@ -155,15 +155,17 @@ def _register_server_runtime(port: int, owner_mode: str):
|
|
|
155
155
|
os.chmod(log_directory, 0o700)
|
|
156
156
|
except OSError:
|
|
157
157
|
pass
|
|
158
|
+
# Identity lives in _session, not _server, so registering does not pull in
|
|
159
|
+
# the web framework: the daemon can answer /ping before that import runs.
|
|
158
160
|
record = InstanceRecord.create(
|
|
159
161
|
port=port,
|
|
160
|
-
protocol_version=
|
|
162
|
+
protocol_version=_session_mod.SERVER_PROTOCOL_VERSION,
|
|
161
163
|
package_version=__version__,
|
|
162
164
|
owner_mode=owner_mode,
|
|
163
165
|
log_path=str(log_directory / f"server-{os.getpid()}.log"),
|
|
164
166
|
)
|
|
165
167
|
registry.write(record)
|
|
166
|
-
|
|
168
|
+
_session_mod.configure_server_runtime(
|
|
167
169
|
instance_id=record.instance_id,
|
|
168
170
|
process_start=record.process_start,
|
|
169
171
|
owner_mode=record.owner_mode,
|
|
@@ -4622,13 +4624,24 @@ def _serve_daemon(
|
|
|
4622
4624
|
port=record.port,
|
|
4623
4625
|
)
|
|
4624
4626
|
|
|
4627
|
+
# Serve from the bootstrap app right away: /ping answers with this
|
|
4628
|
+
# daemon's identity while the web framework is still importing, so the
|
|
4629
|
+
# launcher and the opener can open the tab ~0.4 s sooner and the page
|
|
4630
|
+
# download overlaps the import. Every other request waits in line and is
|
|
4631
|
+
# served by the real app the moment it is attached.
|
|
4632
|
+
from arrayview._bootstrap_app import BootstrapApp
|
|
4633
|
+
|
|
4634
|
+
bootstrap = BootstrapApp()
|
|
4635
|
+
uvicorn_server: list = []
|
|
4636
|
+
|
|
4625
4637
|
def _run_uvicorn_on_socket():
|
|
4626
4638
|
config = _uvicorn().Config(
|
|
4627
|
-
|
|
4639
|
+
bootstrap,
|
|
4628
4640
|
log_level="error",
|
|
4629
4641
|
timeout_keep_alive=30,
|
|
4630
4642
|
)
|
|
4631
4643
|
server = _uvicorn().Server(config)
|
|
4644
|
+
uvicorn_server.append(server)
|
|
4632
4645
|
asyncio.run(server.serve(sockets=sock))
|
|
4633
4646
|
|
|
4634
4647
|
# Start uvicorn immediately — the window can open before data is ready.
|
|
@@ -4638,14 +4651,43 @@ def _serve_daemon(
|
|
|
4638
4651
|
).start()
|
|
4639
4652
|
_trace_launch_event("backend.http_thread_started", port=port)
|
|
4640
4653
|
|
|
4641
|
-
#
|
|
4642
|
-
|
|
4654
|
+
# Let the listener come up before any CPU-heavy import starts: with the
|
|
4655
|
+
# framework import, the colormap warm-up and the data load all competing
|
|
4656
|
+
# for the interpreter, uvicorn's own startup was measured taking ~0.6 s
|
|
4657
|
+
# instead of ~0.03 s, which delayed the first /ping answer by the same.
|
|
4658
|
+
serving_deadline = time.monotonic() + 5.0
|
|
4659
|
+
while time.monotonic() < serving_deadline:
|
|
4660
|
+
if uvicorn_server and getattr(uvicorn_server[0], "started", False):
|
|
4661
|
+
break
|
|
4662
|
+
time.sleep(0.002)
|
|
4663
|
+
_trace_launch_event("backend.serving", port=port)
|
|
4664
|
+
|
|
4665
|
+
def _attach_full_app():
|
|
4666
|
+
try:
|
|
4667
|
+
bootstrap.attach(_server_mod().app)
|
|
4668
|
+
except BaseException as exc: # noqa: BLE001 — any failure here is fatal
|
|
4669
|
+
bootstrap.fail(exc)
|
|
4670
|
+
_trace_launch_event("daemon.exiting", reason="server_import_failed")
|
|
4671
|
+
_vprint(f"[ArrayView] server failed to start: {exc!r}", flush=True)
|
|
4672
|
+
try:
|
|
4673
|
+
registry.remove(record.instance_id)
|
|
4674
|
+
except Exception:
|
|
4675
|
+
pass
|
|
4676
|
+
_cleanup_source_staging_dirs(source_staging_dirs)
|
|
4677
|
+
os._exit(1)
|
|
4678
|
+
_trace_launch_event("backend.app_attached", port=port)
|
|
4679
|
+
# Pre-warm colormap LUTs (the page and the first frame both need
|
|
4680
|
+
# them). Sequenced after the framework import rather than alongside
|
|
4681
|
+
# it: run together they contend for the interpreter and both finish
|
|
4682
|
+
# later than they do one after the other.
|
|
4643
4683
|
try:
|
|
4644
4684
|
from arrayview._render import _init_luts
|
|
4645
4685
|
_init_luts()
|
|
4646
4686
|
except Exception:
|
|
4647
4687
|
pass
|
|
4648
|
-
|
|
4688
|
+
_trace_launch_event("backend.luts_ready", port=port)
|
|
4689
|
+
|
|
4690
|
+
threading.Thread(target=_attach_full_app, daemon=True).start()
|
|
4649
4691
|
|
|
4650
4692
|
def _load():
|
|
4651
4693
|
from arrayview._io import (
|
|
@@ -102,6 +102,12 @@ def _in_jupyter() -> bool:
|
|
|
102
102
|
global _JUPYTER_CACHE
|
|
103
103
|
if _JUPYTER_CACHE is not None:
|
|
104
104
|
return _JUPYTER_CACHE
|
|
105
|
+
# A notebook kernel has IPython loaded before any user code runs, so a
|
|
106
|
+
# process without it cannot be one. Importing IPython just to ask costs
|
|
107
|
+
# ~250 ms on every terminal launch; skip it when it is not already there.
|
|
108
|
+
if "IPython" not in sys.modules:
|
|
109
|
+
_JUPYTER_CACHE = False
|
|
110
|
+
return False
|
|
105
111
|
try:
|
|
106
112
|
from IPython import get_ipython
|
|
107
113
|
|