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.
Files changed (114) hide show
  1. {arrayview-0.44.0 → arrayview-0.45.1}/PKG-INFO +9 -1
  2. {arrayview-0.44.0 → arrayview-0.45.1}/README.md +8 -0
  3. {arrayview-0.44.0 → arrayview-0.45.1}/pyproject.toml +1 -1
  4. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_analysis.py +6 -0
  5. arrayview-0.45.1/src/arrayview/_bootstrap_app.py +120 -0
  6. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_io.py +46 -5
  7. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_launch_plan.py +6 -0
  8. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_launcher.py +48 -6
  9. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_platform.py +6 -0
  10. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_loading.py +144 -130
  11. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_server.py +142 -106
  12. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_session.py +121 -1
  13. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_viewer.html +1460 -462
  14. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_api.py +89 -9
  15. arrayview-0.45.1/tests/test_bootstrap_app.py +114 -0
  16. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_browser.py +1471 -66
  17. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_mode_consistency.py +43 -0
  18. arrayview-0.45.1/tests/test_roi_hud.py +183 -0
  19. {arrayview-0.44.0 → arrayview-0.45.1}/tests/visual_smoke.py +18 -6
  20. {arrayview-0.44.0 → arrayview-0.45.1}/.gitignore +0 -0
  21. {arrayview-0.44.0 → arrayview-0.45.1}/LICENSE +0 -0
  22. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/ARCHITECTURE.md +0 -0
  23. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/__init__.py +0 -0
  24. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/__main__.py +0 -0
  25. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_app.py +0 -0
  26. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_config.py +0 -0
  27. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_diagnostics.py +0 -0
  28. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_dicom.py +0 -0
  29. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_diff.py +0 -0
  30. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_extra_ports.py +0 -0
  31. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_icon.png +0 -0
  32. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_imaging.py +0 -0
  33. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_instance_registry.py +0 -0
  34. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_launch_trace.py +0 -0
  35. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_lifecycle.py +0 -0
  36. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_overlays.py +0 -0
  37. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_render.py +0 -0
  38. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_analysis.py +0 -0
  39. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_drop.py +0 -0
  40. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_export.py +0 -0
  41. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_persistence.py +0 -0
  42. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_preferences.py +0 -0
  43. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_preload.py +0 -0
  44. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_query.py +0 -0
  45. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_rendering.py +0 -0
  46. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_segmentation.py +0 -0
  47. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_state.py +0 -0
  48. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_vectorfield.py +0 -0
  49. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_routes_websocket.py +0 -0
  50. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_segmentation.py +0 -0
  51. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_session_spec.py +0 -0
  52. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_shell.html +0 -0
  53. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_source_safety.py +0 -0
  54. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_synthetic_mri.py +0 -0
  55. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_torch.py +0 -0
  56. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_tutorial.py +0 -0
  57. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vectorfield.py +0 -0
  58. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode.py +0 -0
  59. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode_browser.py +0 -0
  60. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode_extension.py +0 -0
  61. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_vscode_signal.py +0 -0
  62. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/_watch.py +0 -0
  63. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/arrayview-opener.vsix +0 -0
  64. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/gsap.min.js +0 -0
  65. {arrayview-0.44.0 → arrayview-0.45.1}/src/arrayview/todo.md +0 -0
  66. {arrayview-0.44.0 → arrayview-0.45.1}/tests/capture_patient_loading.py +0 -0
  67. {arrayview-0.44.0 → arrayview-0.45.1}/tests/capture_v_animation.py +0 -0
  68. {arrayview-0.44.0 → arrayview-0.45.1}/tests/conftest.py +0 -0
  69. {arrayview-0.44.0 → arrayview-0.45.1}/tests/lifecycle_matrix.py +0 -0
  70. {arrayview-0.44.0 → arrayview-0.45.1}/tests/make_vectorfield_test_arrays.py +0 -0
  71. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_1d_arrays.py +0 -0
  72. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_backend_shared.py +0 -0
  73. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_cli.py +0 -0
  74. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_colorbar_hover_highlight.py +0 -0
  75. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_config.py +0 -0
  76. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_dicom.py +0 -0
  77. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_display_opener_results.py +0 -0
  78. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_doctor_commands.py +0 -0
  79. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_drop_import.py +0 -0
  80. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_instance_registry.py +0 -0
  81. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_invocation_contract_matrix.py +0 -0
  82. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_io.py +0 -0
  83. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_large_arrays.py +0 -0
  84. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_blackbox.py +0 -0
  85. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_diagnostics.py +0 -0
  86. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_execution_ownership.py +0 -0
  87. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_plan.py +0 -0
  88. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_launch_trace.py +0 -0
  89. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_lifecycle_contract.py +0 -0
  90. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_loading_server.py +0 -0
  91. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_mode_matrix.py +0 -0
  92. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_mode_roundtrip.py +0 -0
  93. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_nifti_meta.py +0 -0
  94. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_nifti_series.py +0 -0
  95. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_preferences_ui.py +0 -0
  96. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_public_invocation_regressions.py +0 -0
  97. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_render_failure_reporting.py +0 -0
  98. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_render_progress.py +0 -0
  99. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_rgb_pixel_art.py +0 -0
  100. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_safe_server_stop.py +0 -0
  101. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_server_identity_status.py +0 -0
  102. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_session_spec.py +0 -0
  103. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_source_safety.py +0 -0
  104. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_spawn_ownership.py +0 -0
  105. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_torch.py +0 -0
  106. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_tutorial.py +0 -0
  107. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_tutorial_browser.py +0 -0
  108. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_value_formatting.py +0 -0
  109. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_view_handle_lifecycle.py +0 -0
  110. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_view_launch_execution.py +0 -0
  111. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_vscode_ack_protocol.py +0 -0
  112. {arrayview-0.44.0 → arrayview-0.45.1}/tests/test_window_level_preview.py +0 -0
  113. {arrayview-0.44.0 → arrayview-0.45.1}/tests/ui_audit.py +0 -0
  114. {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.44.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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.44.0"
7
+ version = "0.45.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -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
- if job is None:
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
- return job.plane(z_index)
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__(self, file_matrix, spatial_shapes, dtype, spatial_meta=None, all_nifti=False):
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(file_matrix, ref_shape, ref_dtype, spatial_meta)
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=_server_mod().SERVER_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
- _server_mod().configure_server_runtime(
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
- _server_mod().app,
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
- # Pre-warm colormap LUTs in background (saves ~200 ms on first frame render).
4642
- def _warm_luts():
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
- threading.Thread(target=_warm_luts, daemon=True).start()
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