arrayview 0.11.0__tar.gz → 0.12.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 (73) hide show
  1. {arrayview-0.11.0 → arrayview-0.12.0}/AGENTS.md +9 -3
  2. arrayview-0.12.0/PKG-INFO +60 -0
  3. arrayview-0.12.0/README.md +25 -0
  4. {arrayview-0.11.0 → arrayview-0.12.0}/pyproject.toml +1 -1
  5. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/ARCHITECTURE.md +8 -2
  6. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_io.py +65 -0
  7. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_launcher.py +5 -2
  8. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_render.py +6 -2
  9. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_server.py +149 -20
  10. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_session.py +16 -1
  11. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_shell.html +28 -10
  12. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_stdio_server.py +5 -2
  13. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_viewer.html +3240 -2092
  14. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_vscode.py +80 -25
  15. arrayview-0.12.0/tests/test_command_reachability.py +120 -0
  16. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_mode_roundtrip.py +0 -1
  17. arrayview-0.12.0/tests/test_nifti_meta.py +59 -0
  18. {arrayview-0.11.0 → arrayview-0.12.0}/uv.lock +1 -1
  19. arrayview-0.11.0/PKG-INFO +0 -137
  20. arrayview-0.11.0/README.md +0 -102
  21. {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  22. {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
  23. {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  24. {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
  25. {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  26. {arrayview-0.11.0 → arrayview-0.12.0}/.github/workflows/docs.yml +0 -0
  27. {arrayview-0.11.0 → arrayview-0.12.0}/.github/workflows/python-publish.yml +0 -0
  28. {arrayview-0.11.0 → arrayview-0.12.0}/.gitignore +0 -0
  29. {arrayview-0.11.0 → arrayview-0.12.0}/.python-version +0 -0
  30. {arrayview-0.11.0 → arrayview-0.12.0}/.tmp-vsix/extension/extension.js +0 -0
  31. {arrayview-0.11.0 → arrayview-0.12.0}/.tmp-vsix/extension/package.json +0 -0
  32. {arrayview-0.11.0 → arrayview-0.12.0}/LICENSE +0 -0
  33. {arrayview-0.11.0 → arrayview-0.12.0}/docs/comparing.md +0 -0
  34. {arrayview-0.11.0 → arrayview-0.12.0}/docs/configuration.md +0 -0
  35. {arrayview-0.11.0 → arrayview-0.12.0}/docs/display.md +0 -0
  36. {arrayview-0.11.0 → arrayview-0.12.0}/docs/index.md +0 -0
  37. {arrayview-0.11.0 → arrayview-0.12.0}/docs/loading.md +0 -0
  38. {arrayview-0.11.0 → arrayview-0.12.0}/docs/logo.png +0 -0
  39. {arrayview-0.11.0 → arrayview-0.12.0}/docs/measurement.md +0 -0
  40. {arrayview-0.11.0 → arrayview-0.12.0}/docs/remote.md +0 -0
  41. {arrayview-0.11.0 → arrayview-0.12.0}/docs/stylesheets/extra.css +0 -0
  42. {arrayview-0.11.0 → arrayview-0.12.0}/docs/viewing.md +0 -0
  43. {arrayview-0.11.0 → arrayview-0.12.0}/matlab/arrayview.m +0 -0
  44. {arrayview-0.11.0 → arrayview-0.12.0}/mkdocs.yml +0 -0
  45. {arrayview-0.11.0 → arrayview-0.12.0}/plans/webview/LOG.md +0 -0
  46. {arrayview-0.11.0 → arrayview-0.12.0}/scripts/demo.py +0 -0
  47. {arrayview-0.11.0 → arrayview-0.12.0}/scripts/release.sh +0 -0
  48. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/__init__.py +0 -0
  49. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/__main__.py +0 -0
  50. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_app.py +0 -0
  51. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_config.py +0 -0
  52. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_icon.png +0 -0
  53. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_platform.py +0 -0
  54. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_segmentation.py +0 -0
  55. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_torch.py +0 -0
  56. {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/arrayview-opener.vsix +0 -0
  57. {arrayview-0.11.0 → arrayview-0.12.0}/tests/conftest.py +0 -0
  58. {arrayview-0.11.0 → arrayview-0.12.0}/tests/make_vectorfield_test_arrays.py +0 -0
  59. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_api.py +0 -0
  60. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_browser.py +0 -0
  61. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_cli.py +0 -0
  62. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_config.py +0 -0
  63. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_interactions.py +0 -0
  64. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_large_arrays.py +0 -0
  65. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_mode_consistency.py +0 -0
  66. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_mode_matrix.py +0 -0
  67. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_rgb_pixel_art.py +0 -0
  68. {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_torch.py +0 -0
  69. {arrayview-0.11.0 → arrayview-0.12.0}/tests/ui_audit.py +0 -0
  70. {arrayview-0.11.0 → arrayview-0.12.0}/tests/visual_smoke.py +0 -0
  71. {arrayview-0.11.0 → arrayview-0.12.0}/vscode-extension/LICENSE +0 -0
  72. {arrayview-0.11.0 → arrayview-0.12.0}/vscode-extension/extension.js +0 -0
  73. {arrayview-0.11.0 → arrayview-0.12.0}/vscode-extension/package.json +0 -0
@@ -2,6 +2,9 @@ Read `src/arrayview/ARCHITECTURE.md` for codebase orientation.
2
2
 
3
3
  # ArrayView
4
4
 
5
+ I haven't written or read a single line of code in src so when you ask me questions/input,
6
+ keep it simple with some simple examples.
7
+
5
8
  ## Skills
6
9
 
7
10
  Load the relevant skill before touching the corresponding area.
@@ -24,6 +27,7 @@ Load the relevant skill before touching the corresponding area.
24
27
  - For visual/animation features, propose 2-3 options BEFORE implementing
25
28
  - UI visibility changes go through reconcilers (`_reconcileUI`/`_reconcileLayout`/`_reconcileCompareState`/`_reconcileCbVisibility`), not inline `style.display` or `classList` toggles in mode functions
26
29
  - All colorbar state (animation, window/level, hover, drag) flows through `primaryCb` ColorBar instance — never read/write legacy globals. Multiview colorbars sync via `primaryCb`.
30
+ - Keybinds flow through the command registry (`commands` / `keybinds` in `_viewer.html`), not inline keydown branches. The help overlay auto-generates from command `title` fields — do not hand-edit it.
27
31
 
28
32
  ## Execution
29
33
 
@@ -32,9 +36,11 @@ Always use **subagent-driven development** for implementation. Commit completed
32
36
  ## Testing
33
37
 
34
38
  ```bash
35
- uv run pytest tests/test_api.py -v # HTTP API
36
- uv run pytest tests/test_browser.py -v # Playwright
37
- uv run python tests/visual_smoke.py # screenshots
39
+ uv run pytest tests/test_api.py -v # HTTP API
40
+ uv run pytest tests/test_browser.py -v # Playwright
41
+ uv run pytest tests/test_mode_roundtrip.py -v # mode state round-trip
42
+ uv run pytest tests/test_command_reachability.py -v # command when-clause matrix
43
+ uv run python tests/visual_smoke.py # screenshots
38
44
  ```
39
45
 
40
46
  After any UI change, use `/ui-consistency-audit` to verify across all modes.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: arrayview
3
+ Version: 0.12.0
4
+ Summary: Fast multi-dimensional array viewer
5
+ Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
+ Project-URL: Source, https://github.com/oscarvanderheide/arrayview
7
+ Author-email: Oscar <oscarvanderheide@example.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: array,mri,npy,viewer,visualization
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: fastapi>=0.129.0
18
+ Requires-Dist: h5py>=3.0
19
+ Requires-Dist: matplotlib>=3.9.0
20
+ Requires-Dist: nibabel>=5.3.3
21
+ Requires-Dist: numpy>=2.4.2
22
+ Requires-Dist: pillow>=12.1.1
23
+ Requires-Dist: pyqt5>=5.15; sys_platform == 'linux'
24
+ Requires-Dist: pyqtwebengine>=5.15; sys_platform == 'linux'
25
+ Requires-Dist: python-multipart>=0.0.22
26
+ Requires-Dist: pywebview>=6.1
27
+ Requires-Dist: qmricolors>=0.1.2
28
+ Requires-Dist: qtpy>=2.0; sys_platform == 'linux'
29
+ Requires-Dist: scipy>=1.10
30
+ Requires-Dist: tifffile>=2023.1.1
31
+ Requires-Dist: uvicorn>=0.41.0
32
+ Requires-Dist: websockets>=14.0
33
+ Requires-Dist: zarr>=2.17
34
+ Description-Content-Type: text/markdown
35
+
36
+ # <img src="docs/logo.png" height="36"> arrayview
37
+
38
+ This is what looking at arrays should feel like.
39
+
40
+ Load up `.npy`, `.nii`, `.h5`, `.mat` and friends. Works locally, in Jupyter, over SSH and VS Code tunnels. Press `?` once you're in.
41
+
42
+ ## CLI
43
+
44
+ ```bash
45
+ uvx arrayview your_array.npy
46
+ ```
47
+
48
+ ## Python
49
+
50
+ ```bash
51
+ uv add arrayview
52
+ ```
53
+
54
+ ```python
55
+ from arrayview import view
56
+ view(arr)
57
+ ```
58
+
59
+ [docs →](https://oscarvanderheide.github.io/arrayview/)
60
+
@@ -0,0 +1,25 @@
1
+ # <img src="docs/logo.png" height="36"> arrayview
2
+
3
+ This is what looking at arrays should feel like.
4
+
5
+ Load up `.npy`, `.nii`, `.h5`, `.mat` and friends. Works locally, in Jupyter, over SSH and VS Code tunnels. Press `?` once you're in.
6
+
7
+ ## CLI
8
+
9
+ ```bash
10
+ uvx arrayview your_array.npy
11
+ ```
12
+
13
+ ## Python
14
+
15
+ ```bash
16
+ uv add arrayview
17
+ ```
18
+
19
+ ```python
20
+ from arrayview import view
21
+ view(arr)
22
+ ```
23
+
24
+ [docs →](https://oscarvanderheide.github.io/arrayview/)
25
+
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.11.0"
7
+ version = "0.12.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"
@@ -52,7 +52,7 @@ Detection logic lives in `_platform.py`. Display opening logic lives in `_launch
52
52
  | `_stdio_server.py` | 791 | Stdio transport for VS Code direct webview — JSON stdin, binary stdout |
53
53
  | `_torch.py` | 217 | PyTorch integration: `view_batch()`, `TrainingMonitor` (lazy torch import) |
54
54
  | `_vscode.py` | 1014 | VS Code extension install/management, signal-file IPC, shared-memory IPC, browser opening |
55
- | `_viewer.html` | 14480 | **The entire frontend** — CSS + JS in a single file, all viewing modes |
55
+ | `_viewer.html` | 15600 | **The entire frontend** — CSS + JS in a single file, all viewing modes |
56
56
  | `_shell.html` | 174 | Tab-bar shell for native pywebview — wraps viewer iframes, manages multi-tab sessions |
57
57
 
58
58
  ## Frontend (_viewer.html)
@@ -89,7 +89,7 @@ The frontend is a single self-contained HTML file (~15k lines). No build step, n
89
89
  | Rendering Pipeline | `updateView()`, play/animate, screenshot capture |
90
90
  | ROI and Selection Modes | Rectangle/ellipse ROI drawing, statistics computation |
91
91
  | nnInteractive Segmentation | Click-to-segment UI, mask overlay, undo stack |
92
- | Keyboard Shortcuts | All hotkey bindings single master switch/case block |
92
+ | Keyboard Shortcuts | Command registry (`commands` / `keybinds` / `makeContext` / `evalWhen` / `dispatchCommand`) + `/`-triggered command palette. Keydown handler is a thin dispatcher prefix |
93
93
  | Mode Transitions | Compare/multiview/qMRI enter/exit, crosshair animation |
94
94
  | Scroll, Zoom, and Pan | Mouse wheel slice scroll, pinch zoom, scroll-to-zoom |
95
95
  | Immersive Mode, Cross-Fade, and Visual Effects | Zen mode, fullscreen (K), animated transitions |
@@ -136,6 +136,9 @@ Pill-shaped badges below the canvas showing active visualization transforms. **C
136
136
  ### Dynamic Islands
137
137
  Floating UI panels that appear/disappear based on context: ROI statistics, segmentation controls, colorbar hover, dimension sliders. Must be tested across all viewing modes (normal, immersive, multiview, compare).
138
138
 
139
+ ### Command Registry
140
+ All keybinds flow through a VS Code-style command registry in `_viewer.html`. Three tables: `commands` (id → `{title, when, run}`), `keybinds` (key+modifiers → command id), and `makeContext(state)` (mode/state flag bag). `dispatchCommand(e)` is wired as a prefix to the keydown handler; on a match it evaluates `when` against the context and runs the command, otherwise falls through. The help overlay is auto-generated from command `title` fields — never hand-edit it. A `/`-triggered command palette fuzzy-searches all commands. Cross-mode enablement is guarded by `tests/test_command_reachability.py`.
141
+
139
142
  ### Reconcilers
140
143
  Functions in the "UI Validation and Reconciliation" section (~line 13666) that enforce consistent UI state. When mode changes happen, reconcilers update visibility of containers, colorbars, dynamic islands, and compare sub-mode UI. There are four:
141
144
  1. **Unified UI reconciler** — master state enforcer
@@ -156,6 +159,9 @@ A dedicated daemon thread (`_session.py`) runs all CPU-heavy rendering off the a
156
159
  ### Dynamic Islands
157
160
  Must verify island positioning and visibility across normal, immersive, multiview, and compare modes. Islands use absolute/fixed positioning that breaks if parent containers change.
158
161
 
162
+ ### Keybind Changes
163
+ Keybinds live in the `commands` + `keybinds` tables, not in the keydown handler. Adding or changing a keybind means editing those tables and (if needed) extending `makeContext` / `evalWhen`. The help overlay regenerates itself from command `title` fields — do not edit overlay HTML directly.
164
+
159
165
  ### Layout Debugging
160
166
  When debugging layout issues: identify the root cause (which scale function, which reconciler, which CSS rule) before applying fixes. Symptoms in one mode often originate from shared code affecting all modes.
161
167
 
@@ -124,6 +124,71 @@ def _select_npz_array(npz, filepath):
124
124
  print(f" Please enter a number between 1 and {len(entries)}.")
125
125
 
126
126
 
127
+ def _load_nifti_with_meta(filepath):
128
+ """Load a NIfTI file, canonical-reorient, return (array, meta).
129
+
130
+ meta is a dict with keys:
131
+ affine : original 4x4 affine (RAS+ mm)
132
+ affine_canonical : 4x4 affine after as_closest_canonical
133
+ voxel_sizes : tuple (sx, sy, sz) in mm, post-reorient
134
+ axis_labels : tuple of 3 strs from {"R","L","A","P","S","I"}
135
+ — positive direction of each canonical axis
136
+ is_oblique : bool — True if rotation part has off-diagonal magnitude > 1e-3
137
+ after normalizing voxel sizes
138
+ """
139
+ nib = _nib()
140
+ img = nib.load(filepath)
141
+ original_affine = np.asarray(img.affine, dtype=np.float64)
142
+ canon = nib.as_closest_canonical(img)
143
+ affine_canonical = np.asarray(canon.affine, dtype=np.float64)
144
+
145
+ # NOTE: reorient requires materializing axis permutes/flips. .nii.gz is
146
+ # already eager (gzip not seekable), so this is free; .nii loses mmap as a
147
+ # necessary cost to apply the reorient.
148
+ arr = np.asarray(canon.dataobj)
149
+
150
+ rot = affine_canonical[:3, :3]
151
+ voxel_sizes = tuple(float(np.linalg.norm(rot[:, i])) for i in range(3))
152
+
153
+ # Direction of each canonical axis (sign of diagonal after normalizing)
154
+ norm_rot = np.zeros((3, 3))
155
+ for i in range(3):
156
+ if voxel_sizes[i] > 0:
157
+ norm_rot[:, i] = rot[:, i] / voxel_sizes[i]
158
+ pos_labels = ("R", "A", "S")
159
+ neg_labels = ("L", "P", "I")
160
+ axis_labels = tuple(
161
+ pos_labels[i] if norm_rot[i, i] >= 0 else neg_labels[i] for i in range(3)
162
+ )
163
+
164
+ # Oblique = off-diagonal of normalized rotation has |val| > tol
165
+ off_diag_max = 0.0
166
+ for i in range(3):
167
+ for j in range(3):
168
+ if i != j:
169
+ off_diag_max = max(off_diag_max, abs(norm_rot[i, j]))
170
+ is_oblique = bool(off_diag_max > 1e-3)
171
+
172
+ meta = {
173
+ "affine": original_affine,
174
+ "affine_canonical": affine_canonical,
175
+ "voxel_sizes": voxel_sizes,
176
+ "axis_labels": axis_labels,
177
+ "is_oblique": is_oblique,
178
+ }
179
+ return arr, meta
180
+
181
+
182
+ def load_data_with_meta(filepath):
183
+ """Like load_data but also returns spatial metadata for NIfTI files.
184
+
185
+ Returns (array, meta_or_None). meta is None for non-NIfTI formats.
186
+ """
187
+ if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
188
+ return _load_nifti_with_meta(filepath)
189
+ return load_data(filepath), None
190
+
191
+
127
192
  def load_data(filepath):
128
193
  if filepath.endswith(".npy"):
129
194
  return np.load(filepath, mmap_mode="r")
@@ -1654,10 +1654,10 @@ def _serve_daemon(
1654
1654
  ).start()
1655
1655
 
1656
1656
  def _load():
1657
- from arrayview._io import load_data
1657
+ from arrayview._io import load_data, load_data_with_meta
1658
1658
 
1659
1659
  try:
1660
- data = load_data(filepath)
1660
+ data, spatial_meta = load_data_with_meta(filepath)
1661
1661
  if cleanup:
1662
1662
  try:
1663
1663
  os.unlink(filepath)
@@ -1667,6 +1667,9 @@ def _serve_daemon(
1667
1667
  data, filepath=None if cleanup else filepath, name=name
1668
1668
  )
1669
1669
  session.sid = sid
1670
+ session.spatial_meta = spatial_meta
1671
+ if spatial_meta is not None:
1672
+ session.original_volume = data
1670
1673
  if rgb:
1671
1674
  from arrayview._render import _setup_rgb
1672
1675
 
@@ -667,10 +667,12 @@ def render_mosaic(
667
667
  complex_mode=0,
668
668
  log_scale=False,
669
669
  mosaic_cols=None,
670
+ vmin_override=None,
671
+ vmax_override=None,
670
672
  ):
671
673
  idx_norm = list(idx_tuple)
672
674
  idx_norm[dim_z] = 0
673
- key = (dim_x, dim_y, dim_z, tuple(idx_norm), colormap, dr, complex_mode, log_scale, mosaic_cols)
675
+ key = (dim_x, dim_y, dim_z, tuple(idx_norm), colormap, dr, complex_mode, log_scale, mosaic_cols, vmin_override, vmax_override)
674
676
  if key in session.mosaic_cache:
675
677
  session.mosaic_cache.move_to_end(key)
676
678
  return session.mosaic_cache[key]
@@ -690,7 +692,9 @@ def render_mosaic(
690
692
  frames = [np.log1p(np.abs(f)).astype(np.float32) for f in frames]
691
693
  all_data = np.stack(frames)
692
694
 
693
- if complex_mode == 1 and np.iscomplexobj(session.data):
695
+ if vmin_override is not None and vmax_override is not None:
696
+ vmin, vmax = float(vmin_override), float(vmax_override)
697
+ elif complex_mode == 1 and np.iscomplexobj(session.data):
694
698
  vmin, vmax = -float(np.pi), float(np.pi)
695
699
  else:
696
700
  vmin = float(np.percentile(all_data, 1))
@@ -404,6 +404,8 @@ async def websocket_endpoint(ws: WebSocket, sid: str):
404
404
  complex_mode,
405
405
  log_scale,
406
406
  mosaic_cols=mosaic_cols,
407
+ vmin_override=vmin_override,
408
+ vmax_override=vmax_override,
407
409
  ),
408
410
  )
409
411
  h, w = rgba.shape[:2]
@@ -744,7 +746,7 @@ async def get_metadata(sid: str):
744
746
  if not session:
745
747
  return Response(status_code=404)
746
748
  try:
747
- return {
749
+ meta_dict = {
748
750
  "shape": [
749
751
  int(s)
750
752
  for s in (
@@ -759,6 +761,17 @@ async def get_metadata(sid: str):
759
761
  "vfield_n_times": _vfield_n_times(session),
760
762
  "is_rgb": session.rgb_axis is not None,
761
763
  }
764
+ if getattr(session, "spatial_meta", None) is not None:
765
+ sm = session.spatial_meta
766
+ meta_dict["spatial_meta"] = {
767
+ "voxel_sizes": list(sm["voxel_sizes"]),
768
+ "axis_labels": list(sm["axis_labels"]),
769
+ "is_oblique": bool(sm["is_oblique"]),
770
+ }
771
+ meta_dict["ras_resample_active"] = bool(
772
+ getattr(session, "ras_resample_active", False)
773
+ )
774
+ return meta_dict
762
775
  except Exception as e:
763
776
  import traceback
764
777
 
@@ -2124,9 +2137,116 @@ def get_info(sid: str, session: "Session" = Depends(get_session_or_404)):
2124
2137
  info["recommended_colormap_reason"] = None
2125
2138
  if session.fft_axes is not None:
2126
2139
  info["fft_axes"] = list(session.fft_axes)
2140
+ if getattr(session, "spatial_meta", None) is not None:
2141
+ sm = session.spatial_meta
2142
+ info["spatial_meta"] = {
2143
+ "voxel_sizes": list(sm["voxel_sizes"]),
2144
+ "axis_labels": list(sm["axis_labels"]),
2145
+ "is_oblique": bool(sm["is_oblique"]),
2146
+ }
2147
+ info["ras_resample_active"] = bool(getattr(session, "ras_resample_active", False))
2127
2148
  return info
2128
2149
 
2129
2150
 
2151
+ @app.post("/resample_ras/{sid}")
2152
+ async def resample_ras(sid: str, request: Request):
2153
+ """Toggle RAS resample for a NIfTI session.
2154
+
2155
+ Body: {"enabled": true/false}
2156
+ Returns:
2157
+ - {"skipped": true, "reason": "axis_aligned"} if scan needs no resample
2158
+ - {"skipped": true, "reason": "not_nifti"} if no spatial_meta
2159
+ - {"ok": true, "shape": [...], "elapsed_ms": ...} on success
2160
+ """
2161
+ import time
2162
+
2163
+ session = SESSIONS.get(sid)
2164
+ if session is None:
2165
+ return {"error": "session not found"}
2166
+ body = await request.json()
2167
+ enabled = bool(body.get("enabled", False))
2168
+ sm = getattr(session, "spatial_meta", None)
2169
+ if sm is None or session.original_volume is None:
2170
+ return {"skipped": True, "reason": "not_nifti"}
2171
+
2172
+ if enabled:
2173
+ if not sm["is_oblique"]:
2174
+ return {"skipped": True, "reason": "axis_aligned"}
2175
+ t0 = time.time()
2176
+ if session.resampled_volume is None:
2177
+ try:
2178
+ from scipy.ndimage import affine_transform
2179
+ except ImportError:
2180
+ return {"error": "scipy not available"}
2181
+ vol = session.original_volume
2182
+ # Only resample the leading 3 spatial axes; pass higher dims through
2183
+ # by resampling per-frame would be expensive — for v1 require ndim==3.
2184
+ if vol.ndim != 3:
2185
+ return {"skipped": True, "reason": "ndim_not_3"}
2186
+ affine_canonical = sm["affine_canonical"]
2187
+ rot = np.asarray(affine_canonical[:3, :3], dtype=np.float64)
2188
+ iso = float(min(sm["voxel_sizes"]))
2189
+ # Map all 8 input-volume corners to RAS to find output bounding box.
2190
+ shp = vol.shape
2191
+ corners_idx = np.array(
2192
+ [[i, j, k]
2193
+ for i in (0, shp[0] - 1)
2194
+ for j in (0, shp[1] - 1)
2195
+ for k in (0, shp[2] - 1)],
2196
+ dtype=np.float64,
2197
+ )
2198
+ origin = np.asarray(affine_canonical[:3, 3], dtype=np.float64)
2199
+ ras = corners_idx @ rot.T + origin
2200
+ ras_min = ras.min(axis=0)
2201
+ ras_max = ras.max(axis=0)
2202
+ out_shape = tuple(
2203
+ int(np.ceil((ras_max[i] - ras_min[i]) / iso)) + 1 for i in range(3)
2204
+ )
2205
+ # Output index -> RAS: ras_min + idx * iso
2206
+ # RAS -> input voxel: inv(rot) @ (ras - origin)
2207
+ inv_rot = np.linalg.inv(rot)
2208
+ matrix = inv_rot * iso # output index -> input voxel (linear part)
2209
+ offset = inv_rot @ (ras_min - origin)
2210
+ try:
2211
+ resampled = affine_transform(
2212
+ np.asarray(vol),
2213
+ matrix=matrix,
2214
+ offset=offset,
2215
+ output_shape=out_shape,
2216
+ order=1,
2217
+ cval=0.0,
2218
+ prefilter=False,
2219
+ )
2220
+ except Exception as exc:
2221
+ return {"error": f"resample failed: {exc}"}
2222
+ session.resampled_volume = resampled
2223
+ session.data = session.resampled_volume
2224
+ session.shape = session.resampled_volume.shape
2225
+ if session.rgb_axis is None:
2226
+ session.spatial_shape = session.shape
2227
+ session.ras_resample_active = True
2228
+ session.raw_cache.clear()
2229
+ session.rgba_cache.clear()
2230
+ session.mosaic_cache.clear()
2231
+ session._raw_bytes = session._rgba_bytes = session._mosaic_bytes = 0
2232
+ return {
2233
+ "ok": True,
2234
+ "shape": list(session.shape),
2235
+ "elapsed_ms": int((time.time() - t0) * 1000),
2236
+ }
2237
+ else:
2238
+ session.data = session.original_volume
2239
+ session.shape = session.original_volume.shape
2240
+ if session.rgb_axis is None:
2241
+ session.spatial_shape = session.shape
2242
+ session.ras_resample_active = False
2243
+ session.raw_cache.clear()
2244
+ session.rgba_cache.clear()
2245
+ session.mosaic_cache.clear()
2246
+ session._raw_bytes = session._rgba_bytes = session._mosaic_bytes = 0
2247
+ return {"ok": True, "shape": list(session.shape)}
2248
+
2249
+
2130
2250
  @app.post("/fft/{sid}")
2131
2251
  async def toggle_fft(sid: str, request: Request):
2132
2252
  session = SESSIONS.get(sid)
@@ -2278,26 +2398,31 @@ def get_slice(
2278
2398
  complex_mode,
2279
2399
  log_scale,
2280
2400
  mosaic_cols=mosaic_cols,
2401
+ vmin_override=vmin_override,
2402
+ vmax_override=vmax_override,
2281
2403
  )
2282
- idx_norm = list(idx_tuple)
2283
- idx_norm[dim_z] = 0
2284
- frames_raw = [
2285
- extract_slice(
2286
- session,
2287
- dim_x,
2288
- dim_y,
2289
- [i if j == dim_z else idx_tuple[j] for j in range(len(session.shape))],
2290
- )
2291
- for i in range(session.shape[dim_z])
2292
- ]
2293
- frames = [apply_complex_mode(frame, complex_mode) for frame in frames_raw]
2294
- if log_scale:
2295
- frames = [np.log1p(np.abs(frame)).astype(np.float32) for frame in frames]
2296
- all_data = np.stack(frames)
2297
- vmin = float(np.percentile(all_data, 1))
2298
- vmax = float(np.percentile(all_data, 99))
2404
+ if vmin_override is not None and vmax_override is not None:
2405
+ vmin, vmax = float(vmin_override), float(vmax_override)
2299
2406
  else:
2300
- vmin, vmax = _compute_vmin_vmax(session, np.stack(frames), dr, complex_mode)
2407
+ idx_norm = list(idx_tuple)
2408
+ idx_norm[dim_z] = 0
2409
+ frames_raw = [
2410
+ extract_slice(
2411
+ session,
2412
+ dim_x,
2413
+ dim_y,
2414
+ [i if j == dim_z else idx_tuple[j] for j in range(len(session.shape))],
2415
+ )
2416
+ for i in range(session.shape[dim_z])
2417
+ ]
2418
+ frames = [apply_complex_mode(frame, complex_mode) for frame in frames_raw]
2419
+ if log_scale:
2420
+ frames = [np.log1p(np.abs(frame)).astype(np.float32) for frame in frames]
2421
+ all_data = np.stack(frames)
2422
+ vmin = float(np.percentile(all_data, 1))
2423
+ vmax = float(np.percentile(all_data, 99))
2424
+ else:
2425
+ vmin, vmax = _compute_vmin_vmax(session, np.stack(frames), dr, complex_mode)
2301
2426
  else:
2302
2427
  if session.rgb_axis is not None:
2303
2428
  rgba = render_rgb_rgba(session, dim_x, dim_y, list(idx_tuple))
@@ -3174,10 +3299,14 @@ async def load_file(request: Request):
3174
3299
  except ImportError:
3175
3300
  pass # psutil not available — skip guard
3176
3301
  try:
3177
- data = await asyncio.to_thread(load_data, filepath)
3302
+ from ._io import load_data_with_meta
3303
+ data, spatial_meta = await asyncio.to_thread(load_data_with_meta, filepath)
3178
3304
  except Exception as e:
3179
3305
  return {"error": str(e)}
3180
3306
  session = await asyncio.to_thread(Session, data, filepath=filepath, name=name)
3307
+ if spatial_meta is not None:
3308
+ session.spatial_meta = spatial_meta
3309
+ session.original_volume = data
3181
3310
  if body.get("rgb"):
3182
3311
  try:
3183
3312
  await asyncio.to_thread(_setup_rgb, session)
@@ -173,8 +173,23 @@ class Session:
173
173
  self.vfield_time_dim = None # optional time axis in the raw vfield array
174
174
  self.vfield_spatial_axes = None # image spatial dim -> vfield axis mapping
175
175
 
176
+ # Spatial metadata for NIfTI files (None for other formats).
177
+ # Set externally after construction by the loader.
178
+ self.spatial_meta = None
179
+ # RAS resample (tier 2 of NIfTI orientation feature).
180
+ # original_volume holds the canonical-reoriented array; resampled_volume
181
+ # caches the RAS-resampled volume after first computation. active_volume
182
+ # is None when the original is in use.
183
+ self.original_volume = None
184
+ self.resampled_volume = None
185
+ self.ras_resample_active = False
186
+
176
187
  def reset_caches(self):
177
- """Clear all three render caches and reset their byte counters to 0."""
188
+ """Clear all three render caches and reset their byte counters to 0.
189
+
190
+ Does NOT touch the RAS-resample toggle — that's independent state,
191
+ managed exclusively by the /resample_ras endpoint.
192
+ """
178
193
  self.raw_cache.clear()
179
194
  self.rgba_cache.clear()
180
195
  self.mosaic_cache.clear()
@@ -130,8 +130,6 @@
130
130
  if (e.data.phase === 'tab-cycle') cycleTab(e.data.detail?.dir ?? 1);
131
131
  });
132
132
 
133
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
134
- let ws = new WebSocket(`${proto}//${location.host}/ws/shell`);
135
133
  // Mark this window as the native shell so embedded viewer iframes
136
134
  // can distinguish themselves from Jupyter embeds and enable the picker.
137
135
  window.__arrayviewShell = true;
@@ -141,14 +139,34 @@
141
139
  // the web content area already starts below the native title bar and
142
140
  // window.innerHeight reflects the usable height. No padding needed.
143
141
 
144
- ws.onmessage = (e) => {
145
- const msg = JSON.parse(e.data);
146
- if (msg.action === 'new_tab') {
147
- // Close the welcome placeholder tab when a real array arrives
148
- if (tabs['__welcome__']) closeTab('__welcome__');
149
- addTab(msg.sid, msg.name, msg.url);
150
- }
151
- };
142
+ // Shell WebSocket with keepalive + auto-reconnect.
143
+ // macOS WebKit (pywebview) drops idle WebSocket connections after ~30-60s.
144
+ // Without this, tab injection via _notify_shells() fails on the second
145
+ // `arrayview` CLI call because SHELL_SOCKETS is empty on the server.
146
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
147
+ let ws = null;
148
+ let heartbeatId = null;
149
+ function connectShellWS() {
150
+ ws = new WebSocket(`${proto}//${location.host}/ws/shell`);
151
+ ws.onopen = () => {
152
+ clearInterval(heartbeatId);
153
+ heartbeatId = setInterval(() => {
154
+ if (ws.readyState === WebSocket.OPEN) ws.send('{"action":"ping"}');
155
+ }, 15000);
156
+ };
157
+ ws.onmessage = (e) => {
158
+ const msg = JSON.parse(e.data);
159
+ if (msg.action === 'new_tab') {
160
+ if (tabs['__welcome__']) closeTab('__welcome__');
161
+ addTab(msg.sid, msg.name, msg.url);
162
+ }
163
+ };
164
+ ws.onclose = () => {
165
+ clearInterval(heartbeatId);
166
+ setTimeout(connectShellWS, 1000);
167
+ };
168
+ }
169
+ connectShellWS();
152
170
 
153
171
  const params = new URLSearchParams(window.location.search);
154
172
  const initSid = params.get('init_sid');
@@ -22,7 +22,7 @@ import uuid
22
22
 
23
23
  import numpy as np
24
24
 
25
- from arrayview._io import load_data
25
+ from arrayview._io import load_data, load_data_with_meta
26
26
  from arrayview._render import (
27
27
  _composite_overlay_mask,
28
28
  _extract_overlay_mask,
@@ -236,8 +236,11 @@ def _handle_register(msg: dict) -> None:
236
236
  name = msg.get("name") or __import__("os").path.basename(file_path)
237
237
  options = msg.get("options", {})
238
238
 
239
- data = load_data(file_path)
239
+ data, spatial_meta = load_data_with_meta(file_path)
240
240
  session = Session(data=data, filepath=file_path, name=name)
241
+ session.spatial_meta = spatial_meta
242
+ if spatial_meta is not None:
243
+ session.original_volume = data
241
244
 
242
245
  if options.get("rgb"):
243
246
  _setup_rgb(session)