arrayview 0.17.0__tar.gz → 0.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {arrayview-0.17.0 → arrayview-0.18.0}/PKG-INFO +1 -1
  2. {arrayview-0.17.0 → arrayview-0.18.0}/pyproject.toml +1 -1
  3. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_server.py +51 -16
  4. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_stdio_server.py +56 -12
  5. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_viewer.html +587 -404
  6. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_browser.py +7 -10
  7. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_interactions.py +167 -63
  8. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_mode_roundtrip.py +5 -3
  9. {arrayview-0.17.0 → arrayview-0.18.0}/tests/visual_smoke.py +29 -22
  10. {arrayview-0.17.0 → arrayview-0.18.0}/uv.lock +1 -1
  11. {arrayview-0.17.0 → arrayview-0.18.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  12. {arrayview-0.17.0 → arrayview-0.18.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
  13. {arrayview-0.17.0 → arrayview-0.18.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  14. {arrayview-0.17.0 → arrayview-0.18.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
  15. {arrayview-0.17.0 → arrayview-0.18.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  16. {arrayview-0.17.0 → arrayview-0.18.0}/.github/copilot-instructions.md +0 -0
  17. {arrayview-0.17.0 → arrayview-0.18.0}/.github/workflows/docs.yml +0 -0
  18. {arrayview-0.17.0 → arrayview-0.18.0}/.github/workflows/python-publish.yml +0 -0
  19. {arrayview-0.17.0 → arrayview-0.18.0}/.gitignore +0 -0
  20. {arrayview-0.17.0 → arrayview-0.18.0}/.ignore +0 -0
  21. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/AGENTS.md +0 -0
  22. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/ROUTER.md +0 -0
  23. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/SETUP.md +0 -0
  24. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/SYNC.md +0 -0
  25. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/architecture.md +0 -0
  26. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/conventions.md +0 -0
  27. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/decisions.md +0 -0
  28. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/frontend.md +0 -0
  29. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/project-state.md +0 -0
  30. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/render-pipeline.md +0 -0
  31. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/setup.md +0 -0
  32. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/context/stack.md +0 -0
  33. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/patterns/INDEX.md +0 -0
  34. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/patterns/README.md +0 -0
  35. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/patterns/add-file-format.md +0 -0
  36. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/patterns/add-server-endpoint.md +0 -0
  37. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/patterns/debug-render.md +0 -0
  38. {arrayview-0.17.0 → arrayview-0.18.0}/.mex/patterns/frontend-change.md +0 -0
  39. {arrayview-0.17.0 → arrayview-0.18.0}/.opencode/opencode.json +0 -0
  40. {arrayview-0.17.0 → arrayview-0.18.0}/.python-version +0 -0
  41. {arrayview-0.17.0 → arrayview-0.18.0}/.vscode/settings.json +0 -0
  42. {arrayview-0.17.0 → arrayview-0.18.0}/AGENTS.md +0 -0
  43. {arrayview-0.17.0 → arrayview-0.18.0}/CONTRIBUTING.md +0 -0
  44. {arrayview-0.17.0 → arrayview-0.18.0}/IMMERSIVE_ANIMATION.md +0 -0
  45. {arrayview-0.17.0 → arrayview-0.18.0}/LICENSE +0 -0
  46. {arrayview-0.17.0 → arrayview-0.18.0}/README.md +0 -0
  47. {arrayview-0.17.0 → arrayview-0.18.0}/docs/comparing.md +0 -0
  48. {arrayview-0.17.0 → arrayview-0.18.0}/docs/configuration.md +0 -0
  49. {arrayview-0.17.0 → arrayview-0.18.0}/docs/display.md +0 -0
  50. {arrayview-0.17.0 → arrayview-0.18.0}/docs/index.md +0 -0
  51. {arrayview-0.17.0 → arrayview-0.18.0}/docs/loading.md +0 -0
  52. {arrayview-0.17.0 → arrayview-0.18.0}/docs/logo.png +0 -0
  53. {arrayview-0.17.0 → arrayview-0.18.0}/docs/measurement.md +0 -0
  54. {arrayview-0.17.0 → arrayview-0.18.0}/docs/remote.md +0 -0
  55. {arrayview-0.17.0 → arrayview-0.18.0}/docs/stylesheets/extra.css +0 -0
  56. {arrayview-0.17.0 → arrayview-0.18.0}/docs/viewing.md +0 -0
  57. {arrayview-0.17.0 → arrayview-0.18.0}/matlab/arrayview.m +0 -0
  58. {arrayview-0.17.0 → arrayview-0.18.0}/mkdocs.yml +0 -0
  59. {arrayview-0.17.0 → arrayview-0.18.0}/plans/2026-04-14-immersive-animation.md +0 -0
  60. {arrayview-0.17.0 → arrayview-0.18.0}/plans/webview/LOG.md +0 -0
  61. {arrayview-0.17.0 → arrayview-0.18.0}/scripts/demo.py +0 -0
  62. {arrayview-0.17.0 → arrayview-0.18.0}/scripts/release.sh +0 -0
  63. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/ARCHITECTURE.md +0 -0
  64. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/__init__.py +0 -0
  65. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/__main__.py +0 -0
  66. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_app.py +0 -0
  67. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_config.py +0 -0
  68. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_icon.png +0 -0
  69. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_io.py +0 -0
  70. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_launcher.py +0 -0
  71. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_platform.py +0 -0
  72. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_render.py +0 -0
  73. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_segmentation.py +0 -0
  74. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_session.py +0 -0
  75. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_shell.html +0 -0
  76. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_torch.py +0 -0
  77. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/_vscode.py +0 -0
  78. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/arrayview-opener.vsix +0 -0
  79. {arrayview-0.17.0 → arrayview-0.18.0}/src/arrayview/gsap.min.js +0 -0
  80. {arrayview-0.17.0 → arrayview-0.18.0}/tests/conftest.py +0 -0
  81. {arrayview-0.17.0 → arrayview-0.18.0}/tests/make_vectorfield_test_arrays.py +0 -0
  82. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_api.py +0 -0
  83. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_cli.py +0 -0
  84. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_command_reachability.py +0 -0
  85. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_config.py +0 -0
  86. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_cross_mode_parametrized.py +0 -0
  87. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_large_arrays.py +0 -0
  88. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_loading_server.py +0 -0
  89. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_mode_consistency.py +0 -0
  90. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_mode_matrix.py +0 -0
  91. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_nifti_meta.py +0 -0
  92. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_rgb_pixel_art.py +0 -0
  93. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_torch.py +0 -0
  94. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_view_component_integration.py +0 -0
  95. {arrayview-0.17.0 → arrayview-0.18.0}/tests/test_view_component_unit.py +0 -0
  96. {arrayview-0.17.0 → arrayview-0.18.0}/tests/ui_audit.py +0 -0
  97. {arrayview-0.17.0 → arrayview-0.18.0}/vscode-extension/AGENTS.md +0 -0
  98. {arrayview-0.17.0 → arrayview-0.18.0}/vscode-extension/LICENSE +0 -0
  99. {arrayview-0.17.0 → arrayview-0.18.0}/vscode-extension/extension.js +0 -0
  100. {arrayview-0.17.0 → arrayview-0.18.0}/vscode-extension/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.17.0
3
+ Version: 0.18.0
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.17.0"
7
+ version = "0.18.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -2130,21 +2130,24 @@ def get_volume_histogram(
2130
2130
  sid: str,
2131
2131
  dim_x: int,
2132
2132
  dim_y: int,
2133
- scroll_dim: int,
2133
+ scroll_dim: int = -1,
2134
+ scroll_dims: str = "",
2134
2135
  fixed_indices: str = "",
2135
2136
  complex_mode: int = 0,
2136
2137
  bins: int = 64,
2137
2138
  session: "Session" = Depends(get_session_or_404),
2138
2139
  ):
2139
- """Return a histogram sampled across the scroll dimension.
2140
+ """Return a histogram sampled across one or more aggregation dims.
2140
2141
 
2141
- Subsamples up to 16 evenly-spaced slices along *scroll_dim*, merges
2142
- their pixel data, and returns a single histogram. The result is
2143
- cached on the session so repeated requests are instant.
2142
+ *scroll_dims* is a comma-separated list of dim indices to aggregate
2143
+ over (in addition to *dim_x* / *dim_y*). When unset, falls back to
2144
+ the legacy single *scroll_dim* param. The sampler enumerates index
2145
+ combinations across all aggregation dims but caps total samples at
2146
+ ~16 via stride-subsampling.
2144
2147
 
2145
2148
  *fixed_indices* is a comma-separated list of ``dim:idx`` pairs that
2146
- pin non-display, non-scroll dimensions (e.g. ``"3:0"`` to select the
2147
- first parameter map in qMRI mode).
2149
+ pin non-display, non-aggregation dimensions (e.g. ``"3:0"`` to
2150
+ select the first parameter map in qMRI mode).
2148
2151
  """
2149
2152
 
2150
2153
  # Parse fixed indices
@@ -2155,27 +2158,59 @@ def get_volume_histogram(
2155
2158
  d, v = pair.split(":", 1)
2156
2159
  fixed[int(d)] = int(v)
2157
2160
 
2161
+ # Resolve aggregation dims: prefer scroll_dims, fall back to scroll_dim.
2162
+ agg_dims: list[int] = []
2163
+ if scroll_dims:
2164
+ for tok in scroll_dims.split(","):
2165
+ tok = tok.strip()
2166
+ if not tok:
2167
+ continue
2168
+ try:
2169
+ d = int(tok)
2170
+ except ValueError:
2171
+ continue
2172
+ if 0 <= d < len(session.shape) and d != dim_x and d != dim_y and d not in agg_dims:
2173
+ agg_dims.append(d)
2174
+ if not agg_dims and scroll_dim >= 0 and scroll_dim != dim_x and scroll_dim != dim_y:
2175
+ agg_dims = [scroll_dim]
2176
+
2158
2177
  # Check cache
2159
- cache_key = (dim_x, dim_y, scroll_dim, tuple(sorted(fixed.items())), complex_mode)
2178
+ cache_key = (dim_x, dim_y, tuple(agg_dims), tuple(sorted(fixed.items())), complex_mode)
2160
2179
  if not hasattr(session, "_volume_hist_cache"):
2161
2180
  session._volume_hist_cache = {}
2162
2181
  cached = session._volume_hist_cache.get(cache_key)
2163
2182
  if cached is not None and cached.get("_data_version") == session.data_version:
2164
2183
  return cached["result"]
2165
2184
 
2166
- # Sample slices along scroll_dim
2167
- n = session.shape[scroll_dim]
2185
+ # Build sample index lists per aggregation dim, stride-subsampled so
2186
+ # their Cartesian product stays around ~16 samples total.
2168
2187
  max_samples = 16
2169
- if n <= max_samples:
2170
- sample_indices = list(range(n))
2188
+ if not agg_dims:
2189
+ # No aggregation dims — use the current middle slice only.
2190
+ sample_combos = [tuple()]
2171
2191
  else:
2172
- step = n / max_samples
2173
- sample_indices = [int(i * step) for i in range(max_samples)]
2192
+ per_dim_counts = [session.shape[d] for d in agg_dims]
2193
+ # Target roughly equal sample count per dim so the product ≈ max_samples.
2194
+ # Ceil to avoid zero; clamp to dim size.
2195
+ k = len(agg_dims)
2196
+ per_dim_target = max(1, int(round(max_samples ** (1.0 / k))))
2197
+ sample_per_dim: list[list[int]] = []
2198
+ for n in per_dim_counts:
2199
+ m = min(n, per_dim_target)
2200
+ if n <= m:
2201
+ sample_per_dim.append(list(range(n)))
2202
+ else:
2203
+ step = n / m
2204
+ sample_per_dim.append([int(i * step) for i in range(m)])
2205
+ # Cartesian product with a hard cap.
2206
+ import itertools as _it
2207
+ sample_combos = list(_it.islice(_it.product(*sample_per_dim), max_samples))
2174
2208
 
2175
2209
  pixels = []
2176
- for si in sample_indices:
2210
+ for combo in sample_combos:
2177
2211
  idx_list = [s // 2 for s in session.shape]
2178
- idx_list[scroll_dim] = si
2212
+ for d, si in zip(agg_dims, combo):
2213
+ idx_list[d] = si
2179
2214
  for d, v in fixed.items():
2180
2215
  idx_list[d] = v
2181
2216
  raw = extract_slice(session, dim_x, dim_y, idx_list)
@@ -12,6 +12,7 @@ The binary payload has the same format as the WebSocket binary response for
12
12
  slice requests, and is length-prefixed JSON for metadata/register/sessions.
13
13
  """
14
14
 
15
+ import concurrent.futures as _futures
15
16
  import json
16
17
  import io
17
18
  import arrayview._session as _session_mod
@@ -798,13 +799,35 @@ def _handle_fetch_proxy(msg: dict) -> None:
798
799
  _write_error(f"unsupported endpoint: {endpoint}")
799
800
 
800
801
 
801
- def _handle_slice(msg: dict) -> None:
802
- """Render a slice and write the binary response."""
802
+ # Slice render pool: renders multi-pane modes (qMRI, multiview, compare) in
803
+ # parallel instead of serializing on the stdin read loop. Responses are still
804
+ # written to stdout in arrival order by the caller. Per-cache locks on the
805
+ # Session protect raw/rgba/mosaic caches from concurrent access.
806
+ _SLICE_POOL: _futures.ThreadPoolExecutor | None = None
807
+
808
+
809
+ def _get_slice_pool() -> _futures.ThreadPoolExecutor:
810
+ global _SLICE_POOL
811
+ if _SLICE_POOL is None:
812
+ from arrayview._session import _RENDER_WORKERS
813
+ _SLICE_POOL = _futures.ThreadPoolExecutor(
814
+ max_workers=_RENDER_WORKERS,
815
+ thread_name_prefix="arrayview-stdio-render",
816
+ )
817
+ return _SLICE_POOL
818
+
819
+
820
+ def _build_slice_payload(msg: dict) -> bytes:
821
+ """Render a slice and return its binary payload.
822
+
823
+ Raises on missing session / bad input; does NOT write to stdout so this is
824
+ safe to call from a thread pool. The caller is responsible for writing
825
+ the returned bytes (or the error) in arrival order.
826
+ """
803
827
  sid = msg["sid"]
804
828
  session = SESSIONS.get(sid)
805
829
  if not session:
806
- _write_error("session not found")
807
- return
830
+ raise RuntimeError("session not found")
808
831
 
809
832
  seq = int(msg.get("seq", 0))
810
833
  dim_x = int(msg["dim_x"])
@@ -908,7 +931,12 @@ def _handle_slice(msg: dict) -> None:
908
931
  ).tobytes()
909
932
  payload += vf_hdr + vf_scale + arrows.tobytes()
910
933
 
911
- _write_response(payload)
934
+ return payload
935
+
936
+
937
+ def _handle_slice(msg: dict) -> None:
938
+ """Render a slice and write the binary response (single-slice path)."""
939
+ _write_response(_build_slice_payload(msg))
912
940
 
913
941
 
914
942
  def _handle_get_viewer_html(msg: dict) -> None:
@@ -1085,17 +1113,33 @@ def run_stdio_server() -> None:
1085
1113
  pane_key = (s.get("dim_x", -1), s.get("dim_y", -1))
1086
1114
  latest_for_pane[pane_key] = i
1087
1115
 
1088
- # Process in arrival order: skip stale same-pane dups, render the rest
1116
+ # Submit non-stale renders to the thread pool so multi-pane modes
1117
+ # (qMRI, multiview, compare) don't serialize on the read loop.
1118
+ # Per-cache locks on the Session make concurrent renders safe.
1119
+ pool = _get_slice_pool() if len(pending_slices) > 1 else None
1120
+ payload_futures: dict[int, _futures.Future] = {}
1121
+ for i, s in enumerate(pending_slices):
1122
+ pane_key = (s.get("dim_x", -1), s.get("dim_y", -1))
1123
+ if latest_for_pane[pane_key] != i:
1124
+ continue
1125
+ payload_futures[i] = (
1126
+ pool.submit(_build_slice_payload, s) if pool is not None else None
1127
+ )
1128
+
1129
+ # Write responses in arrival order to match the extension's FIFO
1130
+ # callback matching.
1089
1131
  for i, s in enumerate(pending_slices):
1090
1132
  pane_key = (s.get("dim_x", -1), s.get("dim_y", -1))
1091
1133
  if latest_for_pane[pane_key] != i:
1092
1134
  _write_skip_response(s)
1093
- else:
1094
- try:
1095
- _handle_slice(s)
1096
- except Exception as e:
1097
- traceback.print_exc(file=sys.stderr)
1098
- _write_error(str(e))
1135
+ continue
1136
+ try:
1137
+ fut = payload_futures.get(i)
1138
+ payload = fut.result() if fut is not None else _build_slice_payload(s)
1139
+ _write_response(payload)
1140
+ except Exception as e:
1141
+ traceback.print_exc(file=sys.stderr)
1142
+ _write_error(str(e))
1099
1143
 
1100
1144
  for def_type, def_msg in deferred:
1101
1145
  try: