arrayview 0.46.1__tar.gz → 0.46.2__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 (119) hide show
  1. {arrayview-0.46.1 → arrayview-0.46.2}/PKG-INFO +1 -1
  2. {arrayview-0.46.1 → arrayview-0.46.2}/pyproject.toml +1 -1
  3. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_analysis.py +31 -0
  4. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_extra_ports.py +1 -1
  5. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_io.py +3 -0
  6. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_launcher.py +156 -4
  7. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_overlays.py +28 -2
  8. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_render.py +31 -17
  9. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_analysis.py +21 -0
  10. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_loading.py +3 -0
  11. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_query.py +74 -1
  12. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_session.py +79 -0
  13. arrayview-0.46.2/src/arrayview/_tutorial.py +525 -0
  14. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_viewer.html +1918 -1380
  15. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_vscode_extension.py +1 -1
  16. arrayview-0.46.2/src/arrayview/arrayview-opener.vsix +0 -0
  17. arrayview-0.46.2/src/arrayview/todo.md +12 -0
  18. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_api.py +1 -0
  19. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_browser.py +252 -44
  20. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_roi_hud.py +26 -9
  21. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_tutorial.py +1 -1
  22. arrayview-0.46.2/tests/test_tutorial_browser.py +481 -0
  23. {arrayview-0.46.1 → arrayview-0.46.2}/tests/visual_smoke.py +17 -11
  24. arrayview-0.46.1/src/arrayview/_tutorial.py +0 -194
  25. arrayview-0.46.1/src/arrayview/arrayview-opener.vsix +0 -0
  26. arrayview-0.46.1/src/arrayview/todo.md +0 -12
  27. arrayview-0.46.1/tests/test_tutorial_browser.py +0 -402
  28. {arrayview-0.46.1 → arrayview-0.46.2}/.gitignore +0 -0
  29. {arrayview-0.46.1 → arrayview-0.46.2}/LICENSE +0 -0
  30. {arrayview-0.46.1 → arrayview-0.46.2}/README.md +0 -0
  31. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/ARCHITECTURE.md +0 -0
  32. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/__init__.py +0 -0
  33. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/__main__.py +0 -0
  34. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_app.py +0 -0
  35. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_bootstrap_app.py +0 -0
  36. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_config.py +0 -0
  37. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_diagnostics.py +0 -0
  38. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_dicom.py +0 -0
  39. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_diff.py +0 -0
  40. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_icon.png +0 -0
  41. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_imaging.py +0 -0
  42. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_instance_registry.py +0 -0
  43. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_launch_plan.py +0 -0
  44. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_launch_trace.py +0 -0
  45. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_lifecycle.py +0 -0
  46. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_platform.py +0 -0
  47. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_drop.py +0 -0
  48. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_export.py +0 -0
  49. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_persistence.py +0 -0
  50. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_preferences.py +0 -0
  51. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_preload.py +0 -0
  52. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_rendering.py +0 -0
  53. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_segmentation.py +0 -0
  54. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_state.py +0 -0
  55. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_vectorfield.py +0 -0
  56. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_routes_websocket.py +0 -0
  57. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_segmentation.py +0 -0
  58. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_server.py +0 -0
  59. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_session_spec.py +0 -0
  60. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_shell.html +0 -0
  61. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_source_safety.py +0 -0
  62. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_synthetic_mri.py +0 -0
  63. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_torch.py +0 -0
  64. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_vectorfield.py +0 -0
  65. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_vscode.py +0 -0
  66. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_vscode_browser.py +0 -0
  67. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_vscode_signal.py +0 -0
  68. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/_watch.py +0 -0
  69. {arrayview-0.46.1 → arrayview-0.46.2}/src/arrayview/gsap.min.js +0 -0
  70. {arrayview-0.46.1 → arrayview-0.46.2}/tests/capture_patient_loading.py +0 -0
  71. {arrayview-0.46.1 → arrayview-0.46.2}/tests/capture_v_animation.py +0 -0
  72. {arrayview-0.46.1 → arrayview-0.46.2}/tests/conftest.py +0 -0
  73. {arrayview-0.46.1 → arrayview-0.46.2}/tests/lifecycle_matrix.py +0 -0
  74. {arrayview-0.46.1 → arrayview-0.46.2}/tests/make_vectorfield_test_arrays.py +0 -0
  75. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_1d_arrays.py +0 -0
  76. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_backend_shared.py +0 -0
  77. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_bootstrap_app.py +0 -0
  78. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_cli.py +0 -0
  79. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_colorbar_hover_highlight.py +0 -0
  80. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_config.py +0 -0
  81. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_dicom.py +0 -0
  82. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_display_opener_results.py +0 -0
  83. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_doctor_commands.py +0 -0
  84. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_drop_import.py +0 -0
  85. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_instance_registry.py +0 -0
  86. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_invocation_contract_matrix.py +0 -0
  87. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_io.py +0 -0
  88. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_large_arrays.py +0 -0
  89. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_launch_blackbox.py +0 -0
  90. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_launch_diagnostics.py +0 -0
  91. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_launch_execution_ownership.py +0 -0
  92. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_launch_plan.py +0 -0
  93. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_launch_trace.py +0 -0
  94. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_lifecycle_contract.py +0 -0
  95. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_loading_server.py +0 -0
  96. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_mode_consistency.py +0 -0
  97. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_mode_matrix.py +0 -0
  98. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_mode_roundtrip.py +0 -0
  99. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_nifti_meta.py +0 -0
  100. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_nifti_series.py +0 -0
  101. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_preferences_ui.py +0 -0
  102. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_public_invocation_regressions.py +0 -0
  103. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_render_failure_reporting.py +0 -0
  104. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_render_progress.py +0 -0
  105. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_rgb_pixel_art.py +0 -0
  106. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_safe_server_stop.py +0 -0
  107. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_server_identity_status.py +0 -0
  108. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_session_spec.py +0 -0
  109. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_source_safety.py +0 -0
  110. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_spawn_ownership.py +0 -0
  111. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_torch.py +0 -0
  112. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_tunnel_warm_path.py +0 -0
  113. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_value_formatting.py +0 -0
  114. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_view_handle_lifecycle.py +0 -0
  115. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_view_launch_execution.py +0 -0
  116. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_vscode_ack_protocol.py +0 -0
  117. {arrayview-0.46.1 → arrayview-0.46.2}/tests/test_window_level_preview.py +0 -0
  118. {arrayview-0.46.1 → arrayview-0.46.2}/tests/ui_audit.py +0 -0
  119. {arrayview-0.46.1 → arrayview-0.46.2}/tests/v_anim_frames/.gitkeep +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.46.1
3
+ Version: 0.46.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.46.1"
7
+ version = "0.46.2"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -19,6 +19,29 @@ def _visible_shape(session) -> list[int]:
19
19
  return [int(s) for s in shape]
20
20
 
21
21
 
22
+ def _code_source(session) -> dict | None:
23
+ """Say how a copied line of code should name this array.
24
+
25
+ Arrays passed from Python are named by the caller's variable. Arrays
26
+ opened from a file are reopened with the command line and that file.
27
+ """
28
+ code_source = getattr(session, "code_source", None)
29
+ if code_source:
30
+ return code_source
31
+ filepath = getattr(session, "filepath", None)
32
+ if not filepath:
33
+ return None
34
+ import shlex
35
+
36
+ path = os.path.abspath(filepath)
37
+ home = os.path.expanduser("~")
38
+ if path.startswith(home + os.sep) and shlex.quote(path) == path:
39
+ path = "~" + path[len(home):]
40
+ else:
41
+ path = shlex.quote(path)
42
+ return {"kind": "file", "path": path}
43
+
44
+
22
45
  def _build_metadata(session) -> dict:
23
46
  """Build metadata shared by HTTP routes and WebSocket startup."""
24
47
  target_shape = session.spatial_shape if session.rgb_axis is not None else session.shape
@@ -35,6 +58,11 @@ def _build_metadata(session) -> dict:
35
58
  "is_rgb": session.rgb_axis is not None,
36
59
  "has_source_file": bool(getattr(session, "filepath", None)),
37
60
  }
61
+ if getattr(session, "initial_view", None):
62
+ meta["initial_view"] = session.initial_view
63
+ code_source = _code_source(session)
64
+ if code_source is not None:
65
+ meta["code_source"] = code_source
38
66
  collection_spatial_ndim = getattr(session, "collection_spatial_ndim", None)
39
67
  default_shape = target_shape
40
68
  if collection_spatial_ndim is not None:
@@ -74,6 +102,9 @@ def _build_metadata(session) -> dict:
74
102
  [list(shape) for shape in row]
75
103
  for row in session.data.ragged_spatial_shapes
76
104
  ]
105
+ case_names = getattr(session.data, "case_names", None)
106
+ if case_names:
107
+ meta["case_names"] = [str(name) for name in case_names]
77
108
  case_voxel_spacings = getattr(session.data, "case_voxel_spacings", None)
78
109
  if case_voxel_spacings:
79
110
  meta["case_voxel_spacings"] = [
@@ -91,7 +91,7 @@ def _start_extra_port() -> tuple[int, dict] | None:
91
91
  timeout_keep_alive=30,
92
92
  ws_ping_interval=None,
93
93
  ws="websockets",
94
- ws_per_message_deflate=True,
94
+ ws_per_message_deflate=False,
95
95
  )
96
96
  server = uvicorn.Server(config)
97
97
  task = asyncio.create_task(server.serve(sockets=[sock]))
@@ -1281,6 +1281,9 @@ def load_dir_collection(
1281
1281
  scan_progress=scan_progress,
1282
1282
  scan_label="images",
1283
1283
  )
1284
+ # Lets the viewer label each case by its id (e.g. the patient folder)
1285
+ # instead of its position in the stack.
1286
+ data.case_names = [str(case) for case in case_ids]
1284
1287
  spatial_shape = data._vol_shape
1285
1288
 
1286
1289
  overlay_items = []
@@ -1312,6 +1312,8 @@ def _load_session_from_filepath(
1312
1312
  related_sids: list[str] | None = None,
1313
1313
  source_staging_dir: str | None = None,
1314
1314
  watch: bool = False,
1315
+ initial_view: dict | None = None,
1316
+ code_source: dict | None = None,
1315
1317
  ) -> dict:
1316
1318
  requested_sid = uuid.uuid4().hex
1317
1319
  payload = {
@@ -1329,6 +1331,10 @@ def _load_session_from_filepath(
1329
1331
  payload["source_staging_dir"] = source_staging_dir
1330
1332
  if watch:
1331
1333
  payload["watch"] = True
1334
+ if initial_view:
1335
+ payload["initial_view"] = initial_view
1336
+ if code_source:
1337
+ payload["code_source"] = code_source
1332
1338
  if rgb:
1333
1339
  payload["rgb"] = True
1334
1340
  if dir_patterns:
@@ -1665,6 +1671,7 @@ def _register_cli_session_with_existing_server_impl(
1665
1671
  native_request_id: str | None = None,
1666
1672
  source_staging_dirs: dict[str, str] | None = None,
1667
1673
  watch: bool = False,
1674
+ initial_view: dict | None = None,
1668
1675
  _loaded_sids: list[str],
1669
1676
  ) -> dict[str, object]:
1670
1677
  overlay_sids_list: list[str] = []
@@ -1719,6 +1726,7 @@ def _register_cli_session_with_existing_server_impl(
1719
1726
  related_sids=[*compare_sids, *overlay_sids_list],
1720
1727
  source_staging_dir=(source_staging_dirs or {}).get(base_file),
1721
1728
  watch=watch,
1729
+ initial_view=initial_view,
1722
1730
  )
1723
1731
  if "error" in result:
1724
1732
  error = str(result["error"])
@@ -1797,6 +1805,7 @@ def _handle_cli_existing_server(
1797
1805
  use_native_shell: bool,
1798
1806
  dims_override: tuple[int, int] | None,
1799
1807
  watch: bool,
1808
+ initial_view: dict | None = None,
1800
1809
  window_mode: str | None,
1801
1810
  floating: bool,
1802
1811
  is_remote: bool = False,
@@ -1847,6 +1856,7 @@ def _handle_cli_existing_server(
1847
1856
  native_request_id=native_request_id,
1848
1857
  source_staging_dirs=source_staging_dirs,
1849
1858
  watch=watch,
1859
+ initial_view=initial_view,
1850
1860
  )
1851
1861
  # Inside this try on purpose: the generated bundle is removed in the
1852
1862
  # finally below, and these sessions read from it.
@@ -1902,6 +1912,7 @@ def _handle_cli_existing_server(
1902
1912
  dims_override=dims_override,
1903
1913
  use_native_shell=use_native_shell,
1904
1914
  watch=watch,
1915
+ initial_view=initial_view,
1905
1916
  window_mode=window_mode,
1906
1917
  floating=floating,
1907
1918
  is_remote=is_remote,
@@ -2036,6 +2047,7 @@ def _handle_cli_spawned_daemon(
2036
2047
  dims_override: tuple[int, int] | None,
2037
2048
  use_native_shell: bool,
2038
2049
  watch: bool,
2050
+ initial_view: dict | None = None,
2039
2051
  window_mode: str | None,
2040
2052
  floating: bool,
2041
2053
  is_remote: bool,
@@ -2098,6 +2110,7 @@ def _handle_cli_spawned_daemon(
2098
2110
  use_native_shell=use_native_shell,
2099
2111
  dims_override=dims_override,
2100
2112
  watch=watch,
2113
+ initial_view=initial_view,
2101
2114
  window_mode=window_mode,
2102
2115
  floating=floating,
2103
2116
  is_remote=is_remote,
@@ -2170,6 +2183,7 @@ def _handle_cli_spawned_daemon(
2170
2183
  f" cleanup_dir={repr(cleanup_dir)},"
2171
2184
  f" source_staging_dirs={repr(source_staging_dirs or {})},"
2172
2185
  f" watch={watch},"
2186
+ f" initial_view={repr(initial_view)},"
2173
2187
  f")"
2174
2188
  )
2175
2189
 
@@ -2779,8 +2793,10 @@ async def _serve_background(
2779
2793
  log_level="error",
2780
2794
  timeout_keep_alive=30,
2781
2795
  ws_ping_interval=None,
2782
- ws="websockets", # S7: explicit backend — enables permessage-deflate compression
2783
- ws_per_message_deflate=True, # S7: negotiate deflate with browser (transparent)
2796
+ ws="websockets",
2797
+ # Deflate costs more than rendering a large slice and stalls playback.
2798
+ # A 256×256 frame measured 18 fps with it and 54 fps without it.
2799
+ ws_per_message_deflate=False,
2784
2800
  )
2785
2801
  server = _uvicorn().Server(config)
2786
2802
  if stop_when_closed:
@@ -3476,6 +3492,30 @@ class ViewHandle(str):
3476
3492
  return False
3477
3493
 
3478
3494
 
3495
+ def _caller_variable_name(obj) -> str | None:
3496
+ """Name of the caller's variable that holds ``obj``, if there is one.
3497
+
3498
+ Used only to write a copyable ``view(<name>, ...)`` line, so a miss is
3499
+ harmless: expressions such as ``view(a[0])`` simply have no name.
3500
+ """
3501
+ import inspect
3502
+
3503
+ frame = inspect.currentframe()
3504
+ try:
3505
+ caller = frame.f_back.f_back if frame and frame.f_back else None
3506
+ if caller is None:
3507
+ return None
3508
+ for scope in (caller.f_locals, caller.f_globals):
3509
+ for key, value in list(scope.items()):
3510
+ if value is obj and key.isidentifier() and not key.startswith("_"):
3511
+ return key
3512
+ except Exception:
3513
+ return None
3514
+ finally:
3515
+ del frame
3516
+ return None
3517
+
3518
+
3479
3519
  def view(
3480
3520
  *arrays,
3481
3521
  name=None,
@@ -3487,6 +3527,14 @@ def view(
3487
3527
  rgb: bool | list = False,
3488
3528
  overlay=None,
3489
3529
  floating: bool = False,
3530
+ mode: str | None = None,
3531
+ dims=None,
3532
+ index=None,
3533
+ cmap: str | None = None,
3534
+ vmin: float | None = None,
3535
+ vmax: float | None = None,
3536
+ log: bool = False,
3537
+ complex: str | None = None,
3490
3538
  ):
3491
3539
  """
3492
3540
  Launch the viewer. Does not block the main Python process.
@@ -3526,6 +3574,16 @@ def view(
3526
3574
  ``overlay`` — a single array or list of arrays to composite as overlays.
3527
3575
  Each overlay is assigned an auto-palette color from _OVERLAY_PALETTE.
3528
3576
 
3577
+ ``mode``, ``dims``, ``index``, ``cmap``, ``vmin``/``vmax`` and ``log`` set
3578
+ the view the viewer opens with: the viewing mode (``"ortho"``,
3579
+ ``"mosaic"``, ``"qmri"``, ``"qmri-ortho"``, ``"qmri-mosaic"``), the two
3580
+ dimensions shown as x and y (three for ortho: its volume dims; three for
3581
+ the mosaics: x, y and the gridded dim), the position along every
3582
+ dimension, the colormap, the display range and log scale. ``complex``
3583
+ picks what complex data shows: ``"mag"``, ``"phase"``, ``"real"`` or
3584
+ ``"imag"`` (``"mag"`` shows the absolute value of real data). Pressing
3585
+ ``E`` in the viewer copies the current view as such a call.
3586
+
3529
3587
  Returns a ``ViewHandle`` for a single array, or a tuple of ``ViewHandle``
3530
3588
  objects for multiple arrays (one per array). In inline/Jupyter mode with
3531
3589
  multiple arrays, the IFrame is displayed automatically and a uniform tuple
@@ -3537,6 +3595,12 @@ def view(
3537
3595
  import numpy as np
3538
3596
  from arrayview._io import _tensor_to_numpy
3539
3597
 
3598
+ _initial_view = _session_mod.initial_view_spec(
3599
+ mode=mode, dims=dims, index=index, cmap=cmap, vmin=vmin, vmax=vmax, log=log,
3600
+ complex=complex,
3601
+ )
3602
+ _code_name = _caller_variable_name(arrays[0]) if arrays else None
3603
+
3540
3604
  height = _normalize_inline_height(height)
3541
3605
  _inline_mode_heights = _normalize_inline_mode_heights(mode_heights)
3542
3606
 
@@ -3605,6 +3669,13 @@ def view(
3605
3669
  data = arrays[0]
3606
3670
  name = names[0]
3607
3671
  rgb_primary = rgbs[0]
3672
+ if _initial_view and "index" in _initial_view:
3673
+ _ndim = len(getattr(data, "shape", ()))
3674
+ if len(_initial_view["index"]) != _ndim:
3675
+ raise ValueError(
3676
+ f"index has {len(_initial_view['index'])} entries but the array "
3677
+ f"has {_ndim} dimensions"
3678
+ )
3608
3679
 
3609
3680
  # --- Normalise string window modes ---
3610
3681
  _raw_window = window
@@ -3634,6 +3705,18 @@ def view(
3634
3705
  else:
3635
3706
  _invocation = Invocation.PYTHON
3636
3707
 
3708
+ # Julia and MATLAB callers are not Python frames, so only their explicit
3709
+ # name can stand in for the variable.
3710
+ if _code_name is None or _invocation in (Invocation.JULIA, Invocation.MATLAB):
3711
+ _code_name = name if str(name).isidentifier() else "x"
3712
+ _code_source = {
3713
+ "kind": {
3714
+ Invocation.JULIA: "julia",
3715
+ Invocation.MATLAB: "matlab",
3716
+ }.get(_invocation, "python"),
3717
+ "name": _code_name,
3718
+ }
3719
+
3637
3720
  if isinstance(_raw_window, str):
3638
3721
  _requested_window = _raw_window
3639
3722
  elif _raw_window is True:
@@ -3704,6 +3787,8 @@ def view(
3704
3787
  mode_heights=_inline_mode_heights,
3705
3788
  floating=floating,
3706
3789
  launch_context=_launch_context,
3790
+ initial_view=_initial_view,
3791
+ code_source=_code_source,
3707
3792
  )
3708
3793
 
3709
3794
  # VS Code tunnel/remote: use the server + WebSocket path. The opener owns
@@ -3727,6 +3812,7 @@ def view(
3727
3812
  _tmp_path = _tmp.name
3728
3813
  try:
3729
3814
  np.save(_tmp_path, _array)
3815
+ _is_primary = not _loaded_sids
3730
3816
  _result = _load_session_from_filepath(
3731
3817
  port,
3732
3818
  _tmp_path,
@@ -3734,6 +3820,8 @@ def view(
3734
3820
  rgb=_rgb,
3735
3821
  expected_server_id=_expected_server_id,
3736
3822
  release_on_disconnect=inline,
3823
+ initial_view=_initial_view if _is_primary else None,
3824
+ code_source=_code_source if _is_primary else None,
3737
3825
  )
3738
3826
  finally:
3739
3827
  try:
@@ -3830,6 +3918,8 @@ def view(
3830
3918
  session = _session_mod.Session(data, name=name)
3831
3919
  if rgb_primary:
3832
3920
  _setup_rgb(session)
3921
+ session.initial_view = _initial_view
3922
+ session.code_source = _code_source
3833
3923
  _session_mod.SESSIONS[session.sid] = session
3834
3924
 
3835
3925
  # Register compare sessions (arrays[1:])
@@ -4260,6 +4350,8 @@ def _view_julia(
4260
4350
  mode_heights: dict[str, int] | None = None,
4261
4351
  floating: bool = False,
4262
4352
  launch_context=None,
4353
+ initial_view: dict | None = None,
4354
+ code_source: dict | None = None,
4263
4355
  ):
4264
4356
  """Julia-specific view() path: run the server in a subprocess so it is
4265
4357
  completely independent of Julia's GIL.
@@ -4281,6 +4373,8 @@ def _view_julia(
4281
4373
  force_vscode=force_vscode,
4282
4374
  floating=floating,
4283
4375
  launch_context=launch_context,
4376
+ initial_view=initial_view,
4377
+ code_source=code_source,
4284
4378
  )
4285
4379
 
4286
4380
 
@@ -4296,6 +4390,8 @@ def _view_subprocess(
4296
4390
  force_vscode: bool = False,
4297
4391
  floating: bool = False,
4298
4392
  launch_context=None,
4393
+ initial_view: dict | None = None,
4394
+ code_source: dict | None = None,
4299
4395
  ) -> str:
4300
4396
  """Run the viewer in a separate subprocess server.
4301
4397
 
@@ -4351,6 +4447,8 @@ def _view_subprocess(
4351
4447
  expected_server_id=active_server_id,
4352
4448
  native_request_id=native_request_id,
4353
4449
  release_on_disconnect=True,
4450
+ initial_view=initial_view,
4451
+ code_source=code_source,
4354
4452
  )
4355
4453
  if "error" in result:
4356
4454
  raise RuntimeError(result["error"])
@@ -4415,7 +4513,9 @@ def _view_subprocess(
4415
4513
  f"_serve_daemon({repr(tmp_path)}, {port}, {repr(sid)}, "
4416
4514
  f"name={repr(name)}, cleanup=True, "
4417
4515
  f"persist={persist_daemon}, "
4418
- f"connect_timeout={repr(daemon_connect_timeout)}, rgb={rgb})"
4516
+ f"connect_timeout={repr(daemon_connect_timeout)}, rgb={rgb}, "
4517
+ f"initial_view={repr(initial_view)}, "
4518
+ f"code_source={repr(code_source)})"
4419
4519
  )
4420
4520
  daemon_proc = subprocess.Popen(
4421
4521
  [sys.executable, "-c", script],
@@ -4550,6 +4650,8 @@ def _serve_empty(port: int) -> None:
4550
4650
  _server_mod().app,
4551
4651
  log_level="error",
4552
4652
  timeout_keep_alive=30,
4653
+ ws="websockets",
4654
+ ws_per_message_deflate=False,
4553
4655
  )
4554
4656
  server = _uvicorn().Server(config)
4555
4657
  asyncio.run(server.serve(sockets=socks))
@@ -4589,6 +4691,8 @@ def _serve_daemon(
4589
4691
  cleanup_dir: str | None = None,
4590
4692
  source_staging_dirs: dict[str, str] | None = None,
4591
4693
  watch: bool = False,
4694
+ initial_view: dict | None = None,
4695
+ code_source: dict | None = None,
4592
4696
  ) -> None:
4593
4697
  """Background server process. Loads data, serves it.
4594
4698
  persist=True: never exits (used on remote tunnel so port stays alive).
@@ -4642,6 +4746,8 @@ def _serve_daemon(
4642
4746
  bootstrap,
4643
4747
  log_level="error",
4644
4748
  timeout_keep_alive=30,
4749
+ ws="websockets",
4750
+ ws_per_message_deflate=False,
4645
4751
  )
4646
4752
  server = _uvicorn().Server(config)
4647
4753
  uvicorn_server.append(server)
@@ -4756,6 +4862,10 @@ def _serve_daemon(
4756
4862
  if filepath in source_staging_dirs:
4757
4863
  session._source_staging_dirs = [source_staging_dirs[filepath]]
4758
4864
  session.sid = sid
4865
+ _session_mod.apply_launch_view(
4866
+ session,
4867
+ {"initial_view": initial_view, "code_source": code_source},
4868
+ )
4759
4869
  if signature_before_load is not None:
4760
4870
  signature_after_load = file_signature(filepath)
4761
4871
  if signature_before_load == signature_after_load:
@@ -5667,6 +5777,28 @@ def arrayview():
5667
5777
  "Also accepts 0-based integer pair like '2,3'."
5668
5778
  ),
5669
5779
  )
5780
+ parser.add_argument(
5781
+ "--index",
5782
+ metavar="I,J,...",
5783
+ default=None,
5784
+ help="Open at this position: one 0-based index per dimension, e.g. '128,128,40'.",
5785
+ )
5786
+ parser.add_argument(
5787
+ "--mode",
5788
+ default=None,
5789
+ choices=["ortho", "mosaic", "qmri", "qmri-ortho", "qmri-mosaic"],
5790
+ help="Open in this viewing mode. With ortho or a mosaic, --dims takes three dims.",
5791
+ )
5792
+ parser.add_argument("--cmap", default=None, help="Open with this colormap, e.g. 'gray'.")
5793
+ parser.add_argument("--vmin", type=float, default=None, help="Lower end of the display range (with --vmax).")
5794
+ parser.add_argument("--vmax", type=float, default=None, help="Upper end of the display range (with --vmin).")
5795
+ parser.add_argument("--log", action="store_true", help="Open with log scale on.")
5796
+ parser.add_argument(
5797
+ "--complex",
5798
+ default=None,
5799
+ choices=["mag", "phase", "real", "imag"],
5800
+ help="Which part of complex data to show (mag shows |x| of real data).",
5801
+ )
5670
5802
  parser.add_argument(
5671
5803
  "--name",
5672
5804
  default=None,
@@ -5827,13 +5959,31 @@ def arrayview():
5827
5959
  return
5828
5960
 
5829
5961
  dims_override: tuple[int, int] | None = None
5830
- if args.dims:
5962
+ view_dims: list[str] | None = None
5963
+ if args.dims and args.mode in ("ortho", "mosaic", "qmri-mosaic"):
5964
+ # Three dims: not an x/y pair, so they only travel with the view.
5965
+ view_dims = args.dims.split(",")
5966
+ elif args.dims:
5831
5967
  dims_override = _parse_dims_spec(args.dims)
5832
5968
  if dims_override is None:
5833
5969
  parser.error(
5834
5970
  f"--dims {args.dims!r} is invalid. "
5835
5971
  "Use e.g. 'x,y,:,:' or ':,:,x,y' or '0,1'."
5836
5972
  )
5973
+ initial_view: dict | None = None
5974
+ try:
5975
+ initial_view = _session_mod.initial_view_spec(
5976
+ mode=args.mode,
5977
+ dims=view_dims or dims_override,
5978
+ index=args.index.split(",") if args.index else None,
5979
+ cmap=args.cmap,
5980
+ vmin=args.vmin,
5981
+ vmax=args.vmax,
5982
+ log=args.log,
5983
+ complex=args.complex,
5984
+ )
5985
+ except ValueError as exc:
5986
+ parser.error(str(exc))
5837
5987
  if getattr(args, "stack_flag_removed", False):
5838
5988
  parser.error(
5839
5989
  "--stack has been renamed to --match, because it never controlled "
@@ -6458,6 +6608,7 @@ def arrayview():
6458
6608
  vfield_components_dim=vfield_components_dim,
6459
6609
  use_native_shell=use_native_shell,
6460
6610
  dims_override=dims_override,
6611
+ initial_view=initial_view,
6461
6612
  watch=getattr(args, "watch", False),
6462
6613
  window_mode=window_mode,
6463
6614
  floating=args.floating,
@@ -6490,6 +6641,7 @@ def arrayview():
6490
6641
  overlay_files=[] if args.stack_mode else file_overlay_paths,
6491
6642
  overlay_names=[] if args.stack_mode else file_overlay_names,
6492
6643
  dims_override=dims_override,
6644
+ initial_view=initial_view,
6493
6645
  use_native_shell=use_native_shell,
6494
6646
  watch=getattr(args, "watch", False),
6495
6647
  window_mode=window_mode,
@@ -26,6 +26,22 @@ def _parse_hex_color(hex_str: str) -> np.ndarray | None:
26
26
  return None
27
27
 
28
28
 
29
+ def _parse_overlay_color_token(token: str) -> tuple[np.ndarray | None, frozenset[int]]:
30
+ """Parse one ``overlay_colors`` entry: ``RRGGBB`` or ``RRGGBB~2.5``.
31
+
32
+ The optional ``~`` suffix lists label values of a label-map overlay that
33
+ the viewer has switched off.
34
+ """
35
+ color_part, _, hidden_part = token.partition("~")
36
+ hidden = set()
37
+ for value in hidden_part.split("."):
38
+ try:
39
+ hidden.add(int(value))
40
+ except ValueError:
41
+ pass
42
+ return _parse_hex_color(color_part), frozenset(hidden)
43
+
44
+
29
45
  def _composite_overlays(
30
46
  rgba: np.ndarray,
31
47
  overlay_sid_str: str | None,
@@ -50,7 +66,11 @@ def _composite_overlays(
50
66
  [a.strip() for a in overlay_alphas_str.split(",")] if overlay_alphas_str else []
51
67
  )
52
68
  for i, sid in enumerate(sids):
53
- color = _parse_hex_color(colors_raw[i]) if i < len(colors_raw) else None
69
+ color, hidden = (
70
+ _parse_overlay_color_token(colors_raw[i])
71
+ if i < len(colors_raw)
72
+ else (None, frozenset())
73
+ )
54
74
  alpha = overlay_alpha
55
75
  if i < len(alphas_raw):
56
76
  try:
@@ -73,6 +93,7 @@ def _composite_overlays(
73
93
  is_label=_overlay_is_label_map(sid, ov_raw),
74
94
  override_color=color,
75
95
  outline_only=overlay_outline,
96
+ hidden_labels=hidden,
76
97
  )
77
98
  return rgba
78
99
 
@@ -114,7 +135,11 @@ def _composite_mosaic_overlays(
114
135
  n_frames = int(base_shape[dim_z])
115
136
 
116
137
  for i, sid in enumerate(sids):
117
- color = _parse_hex_color(colors_raw[i]) if i < len(colors_raw) else None
138
+ color, hidden = (
139
+ _parse_overlay_color_token(colors_raw[i])
140
+ if i < len(colors_raw)
141
+ else (None, frozenset())
142
+ )
118
143
  try:
119
144
  alpha = float(alphas_raw[i]) if i < len(alphas_raw) else overlay_alpha
120
145
  except ValueError:
@@ -146,5 +171,6 @@ def _composite_mosaic_overlays(
146
171
  is_label=_overlay_is_label_map(sid, grid),
147
172
  override_color=color,
148
173
  outline_only=overlay_outline,
174
+ hidden_labels=hidden,
149
175
  )
150
176
  return rgba
@@ -577,6 +577,32 @@ def render_projection_rgba(
577
577
  # ---------------------------------------------------------------------------
578
578
 
579
579
 
580
+ def _overlay_base_axes(
581
+ ov_session, main_shape: tuple[int, ...], dim_x: int, dim_y: int
582
+ ) -> tuple[int, ...] | None:
583
+ """Return the base-array axes an overlay's axes line up with, or None."""
584
+ ov_shape = tuple(int(size) for size in ov_session.shape)
585
+ if len(ov_shape) > len(main_shape):
586
+ return None
587
+ map_key = (main_shape, dim_x, dim_y)
588
+ axis_maps = getattr(ov_session, "_overlay_axis_maps", None)
589
+ if axis_maps is None:
590
+ axis_maps = {}
591
+ ov_session._overlay_axis_maps = axis_maps
592
+ if map_key not in axis_maps:
593
+ axis_maps[map_key] = next(
594
+ (
595
+ base_axes
596
+ for base_axes in itertools.combinations(range(len(main_shape)), len(ov_shape))
597
+ if dim_x in base_axes
598
+ and dim_y in base_axes
599
+ and tuple(main_shape[axis] for axis in base_axes) == ov_shape
600
+ ),
601
+ None,
602
+ )
603
+ return axis_maps[map_key]
604
+
605
+
580
606
  def _extract_overlay_mask(
581
607
  overlay_sid: str | None,
582
608
  dim_x: int,
@@ -604,23 +630,7 @@ def _extract_overlay_mask(
604
630
  # Match its axes to equal-sized base axes, then index only those axes. This
605
631
  # broadcasts the overlay over every omitted axis without allocating a tiled
606
632
  # copy (important for masks next to multi-echo or multi-channel data).
607
- map_key = (main_shape, dim_x, dim_y)
608
- axis_maps = getattr(ov_session, "_overlay_axis_maps", None)
609
- if axis_maps is None:
610
- axis_maps = {}
611
- ov_session._overlay_axis_maps = axis_maps
612
- if map_key not in axis_maps:
613
- axis_maps[map_key] = next(
614
- (
615
- base_axes
616
- for base_axes in itertools.combinations(range(len(main_shape)), len(ov_shape))
617
- if dim_x in base_axes
618
- and dim_y in base_axes
619
- and tuple(main_shape[axis] for axis in base_axes) == ov_shape
620
- ),
621
- None,
622
- )
623
- base_axes = axis_maps[map_key]
633
+ base_axes = _overlay_base_axes(ov_session, main_shape, dim_x, dim_y)
624
634
  if base_axes is None:
625
635
  return None
626
636
  ov_dim_x = base_axes.index(dim_x)
@@ -663,6 +673,7 @@ def _composite_overlay_mask(
663
673
  is_label: bool = False,
664
674
  override_color: np.ndarray | None = None,
665
675
  outline_only: bool = False,
676
+ hidden_labels: frozenset[int] = frozenset(),
666
677
  ) -> np.ndarray:
667
678
  """Alpha-composite an overlay on top of an RGBA frame.
668
679
 
@@ -689,6 +700,8 @@ def _composite_overlay_mask(
689
700
  # Binary mask with a palette override colour
690
701
  use_override = override_color is not None and len(nonzero_labels) == 1
691
702
  for lbl in nonzero_labels:
703
+ if int(lbl) in hidden_labels:
704
+ continue
692
705
  mask = labels == lbl
693
706
  if outline_only:
694
707
  mask = _mask_outline(mask)
@@ -975,6 +988,7 @@ __all__ = [
975
988
  "_extract_overlay_mask",
976
989
  "_composite_overlay_mask",
977
990
  "_overlay_is_label_map",
991
+ "_overlay_base_axes",
978
992
  "LABEL_COLORS",
979
993
  # Mosaic
980
994
  "render_mosaic",
@@ -347,12 +347,14 @@ def register_analysis_routes(app, get_session_or_404) -> None:
347
347
  results = []
348
348
  for roi_idx, roi in enumerate(rois):
349
349
  rows = []
350
+ values = []
350
351
  for idx in _roi_scope_indices(roi, indices, session.shape, {dim_x, dim_y}):
351
352
  raw = extract_slice(session, dim_x, dim_y, idx)
352
353
  data = apply_complex_mode(raw, complex_mode)
353
354
  mask = _roi_mask_for_shape(roi, data.shape, idx)
354
355
  finite = _roi_finite_values(data, mask)
355
356
  if finite.size:
357
+ values.append(finite)
356
358
  rows.append(
357
359
  {
358
360
  "indices": list(idx),
@@ -365,6 +367,8 @@ def register_analysis_routes(app, get_session_or_404) -> None:
365
367
  }
366
368
  )
367
369
  combined = _combine_roi_rows(rows)
370
+ if combined is not None:
371
+ combined["hist"] = _roi_value_histogram(values, combined["min"], combined["max"])
368
372
  results.append(
369
373
  {
370
374
  "roi": roi.get("id", roi_idx),
@@ -898,6 +902,23 @@ def _roi_finite_values(data: np.ndarray, mask: np.ndarray) -> np.ndarray:
898
902
  return roi[np.isfinite(roi)]
899
903
 
900
904
 
905
+ _ROI_HIST_BINS = 24
906
+
907
+
908
+ def _roi_value_histogram(values: list[np.ndarray], lo, hi) -> list[int]:
909
+ """Pixel counts in equal bins between the ROI's own min and max.
910
+
911
+ The viewer places each ROI's bins on one value axis shared by all ROIs,
912
+ so only the counts travel; the edges follow from min/max.
913
+ """
914
+ if not values or lo is None or hi is None:
915
+ return []
916
+ counts, _ = np.histogram(
917
+ np.concatenate(values), bins=_ROI_HIST_BINS, range=(float(lo), float(hi) if hi > lo else float(lo) + 1.0)
918
+ )
919
+ return [int(c) for c in counts]
920
+
921
+
901
922
  def _combine_roi_rows(rows: list[dict]) -> dict | None:
902
923
  if not rows:
903
924
  return None