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.
- {arrayview-0.11.0 → arrayview-0.12.0}/AGENTS.md +9 -3
- arrayview-0.12.0/PKG-INFO +60 -0
- arrayview-0.12.0/README.md +25 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/pyproject.toml +1 -1
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/ARCHITECTURE.md +8 -2
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_io.py +65 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_launcher.py +5 -2
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_render.py +6 -2
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_server.py +149 -20
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_session.py +16 -1
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_shell.html +28 -10
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_stdio_server.py +5 -2
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_viewer.html +3240 -2092
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_vscode.py +80 -25
- arrayview-0.12.0/tests/test_command_reachability.py +120 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_mode_roundtrip.py +0 -1
- arrayview-0.12.0/tests/test_nifti_meta.py +59 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/uv.lock +1 -1
- arrayview-0.11.0/PKG-INFO +0 -137
- arrayview-0.11.0/README.md +0 -102
- {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.github/workflows/docs.yml +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.github/workflows/python-publish.yml +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.gitignore +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.python-version +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.tmp-vsix/extension/extension.js +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/.tmp-vsix/extension/package.json +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/LICENSE +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/comparing.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/configuration.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/display.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/index.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/loading.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/logo.png +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/measurement.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/remote.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/stylesheets/extra.css +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/docs/viewing.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/matlab/arrayview.m +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/mkdocs.yml +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/plans/webview/LOG.md +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/scripts/demo.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/scripts/release.sh +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/conftest.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_api.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_browser.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_cli.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_config.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_interactions.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/test_torch.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/ui_audit.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/tests/visual_smoke.py +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/vscode-extension/LICENSE +0 -0
- {arrayview-0.11.0 → arrayview-0.12.0}/vscode-extension/extension.js +0 -0
- {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
|
|
36
|
-
uv run pytest tests/test_browser.py -v
|
|
37
|
-
uv run
|
|
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
|
+
|
|
@@ -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` |
|
|
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 |
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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 =
|
|
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)
|