arrayview 0.10.1__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.10.1 → 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.10.1 → arrayview-0.12.0}/pyproject.toml +1 -1
  5. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/ARCHITECTURE.md +8 -2
  6. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_config.py +15 -0
  7. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_io.py +72 -1
  8. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_launcher.py +5 -2
  9. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_render.py +16 -4
  10. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_server.py +291 -169
  11. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_session.py +24 -1
  12. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_shell.html +28 -10
  13. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_stdio_server.py +6 -6
  14. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_viewer.html +3382 -2162
  15. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_vscode.py +80 -25
  16. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_browser.py +32 -49
  17. arrayview-0.12.0/tests/test_command_reachability.py +120 -0
  18. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_interactions.py +34 -27
  19. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_mode_matrix.py +31 -33
  20. arrayview-0.12.0/tests/test_mode_roundtrip.py +346 -0
  21. arrayview-0.12.0/tests/test_nifti_meta.py +59 -0
  22. {arrayview-0.10.1 → arrayview-0.12.0}/tests/visual_smoke.py +1 -1
  23. {arrayview-0.10.1 → arrayview-0.12.0}/uv.lock +1 -1
  24. arrayview-0.10.1/PKG-INFO +0 -136
  25. arrayview-0.10.1/README.md +0 -101
  26. {arrayview-0.10.1 → arrayview-0.12.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  27. {arrayview-0.10.1 → arrayview-0.12.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
  28. {arrayview-0.10.1 → arrayview-0.12.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  29. {arrayview-0.10.1 → arrayview-0.12.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
  30. {arrayview-0.10.1 → arrayview-0.12.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  31. {arrayview-0.10.1 → arrayview-0.12.0}/.github/workflows/docs.yml +0 -0
  32. {arrayview-0.10.1 → arrayview-0.12.0}/.github/workflows/python-publish.yml +0 -0
  33. {arrayview-0.10.1 → arrayview-0.12.0}/.gitignore +0 -0
  34. {arrayview-0.10.1 → arrayview-0.12.0}/.python-version +0 -0
  35. {arrayview-0.10.1 → arrayview-0.12.0}/.tmp-vsix/extension/extension.js +0 -0
  36. {arrayview-0.10.1 → arrayview-0.12.0}/.tmp-vsix/extension/package.json +0 -0
  37. {arrayview-0.10.1 → arrayview-0.12.0}/LICENSE +0 -0
  38. {arrayview-0.10.1 → arrayview-0.12.0}/docs/comparing.md +0 -0
  39. {arrayview-0.10.1 → arrayview-0.12.0}/docs/configuration.md +0 -0
  40. {arrayview-0.10.1 → arrayview-0.12.0}/docs/display.md +0 -0
  41. {arrayview-0.10.1 → arrayview-0.12.0}/docs/index.md +0 -0
  42. {arrayview-0.10.1 → arrayview-0.12.0}/docs/loading.md +0 -0
  43. {arrayview-0.10.1 → arrayview-0.12.0}/docs/logo.png +0 -0
  44. {arrayview-0.10.1 → arrayview-0.12.0}/docs/measurement.md +0 -0
  45. {arrayview-0.10.1 → arrayview-0.12.0}/docs/remote.md +0 -0
  46. {arrayview-0.10.1 → arrayview-0.12.0}/docs/stylesheets/extra.css +0 -0
  47. {arrayview-0.10.1 → arrayview-0.12.0}/docs/viewing.md +0 -0
  48. {arrayview-0.10.1 → arrayview-0.12.0}/matlab/arrayview.m +0 -0
  49. {arrayview-0.10.1 → arrayview-0.12.0}/mkdocs.yml +0 -0
  50. {arrayview-0.10.1 → arrayview-0.12.0}/plans/webview/LOG.md +0 -0
  51. {arrayview-0.10.1 → arrayview-0.12.0}/scripts/demo.py +0 -0
  52. {arrayview-0.10.1 → arrayview-0.12.0}/scripts/release.sh +0 -0
  53. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/__init__.py +0 -0
  54. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/__main__.py +0 -0
  55. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_app.py +0 -0
  56. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_icon.png +0 -0
  57. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_platform.py +0 -0
  58. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_segmentation.py +0 -0
  59. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/_torch.py +0 -0
  60. {arrayview-0.10.1 → arrayview-0.12.0}/src/arrayview/arrayview-opener.vsix +0 -0
  61. {arrayview-0.10.1 → arrayview-0.12.0}/tests/conftest.py +0 -0
  62. {arrayview-0.10.1 → arrayview-0.12.0}/tests/make_vectorfield_test_arrays.py +0 -0
  63. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_api.py +0 -0
  64. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_cli.py +0 -0
  65. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_config.py +0 -0
  66. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_large_arrays.py +0 -0
  67. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_mode_consistency.py +0 -0
  68. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_rgb_pixel_art.py +0 -0
  69. {arrayview-0.10.1 → arrayview-0.12.0}/tests/test_torch.py +0 -0
  70. {arrayview-0.10.1 → arrayview-0.12.0}/tests/ui_audit.py +0 -0
  71. {arrayview-0.10.1 → arrayview-0.12.0}/vscode-extension/LICENSE +0 -0
  72. {arrayview-0.10.1 → arrayview-0.12.0}/vscode-extension/extension.js +0 -0
  73. {arrayview-0.10.1 → 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.10.1"
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
 
@@ -41,6 +41,21 @@ def get_viewer_colormaps() -> list[str] | None:
41
41
  return None
42
42
 
43
43
 
44
+ _VALID_THEMES = {"dark", "light", "solarized", "nord"}
45
+
46
+
47
+ def get_viewer_theme() -> str | None:
48
+ """Return user-configured default theme name, or None if not configured."""
49
+ cfg = load_config()
50
+ viewer_cfg = cfg.get("viewer", {})
51
+ if not isinstance(viewer_cfg, dict):
52
+ return None
53
+ theme = viewer_cfg.get("theme")
54
+ if isinstance(theme, str) and theme.strip().lower() in _VALID_THEMES:
55
+ return theme.strip().lower()
56
+ return None
57
+
58
+
44
59
  def get_nninteractive_url() -> str | None:
45
60
  """Return configured nnInteractive server URL, or None.
46
61
 
@@ -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")
@@ -133,7 +198,13 @@ def load_data(filepath):
133
198
  if len(keys) == 1:
134
199
  return npz[keys[0]]
135
200
  return _select_npz_array(npz, filepath)
136
- elif filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
201
+ elif filepath.endswith(".nii.gz"):
202
+ # Gzip streams aren't seekable, so nibabel's lazy dataobj decompresses
203
+ # large portions of the file on every arbitrary slice access. Materialize
204
+ # the volume up front so subsequent slicing is cheap.
205
+ return np.asarray(_nib().load(filepath).dataobj)
206
+ elif filepath.endswith(".nii"):
207
+ # Uncompressed NIfTI is memory-mapped via dataobj — slicing is cheap.
137
208
  return _nib().load(filepath).dataobj
138
209
  elif filepath.endswith(".zarr") or filepath.endswith(".zarr.zip"):
139
210
  import zarr
@@ -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
 
@@ -111,7 +111,10 @@ def _compute_vmin_vmax(session, data, dr=0, complex_mode=0):
111
111
 
112
112
 
113
113
  def extract_slice(session, dim_x, dim_y, idx_list):
114
- key = (dim_x, dim_y, tuple(idx_list))
114
+ # Mask out indices along the displayed dims — they're slice(None) in the
115
+ # slicer below, so the extracted data doesn't depend on them.
116
+ key_idx = tuple(None if i in (dim_x, dim_y) else idx_list[i] for i in range(len(idx_list)))
117
+ key = (dim_x, dim_y, key_idx)
115
118
  if key in session.raw_cache:
116
119
  session.raw_cache.move_to_end(key)
117
120
  return session.raw_cache[key]
@@ -154,7 +157,12 @@ def extract_projection(session, dim_x, dim_y, idx_list, proj_dim, proj_mode):
154
157
 
155
158
  proj_mode: 1=max, 2=min, 3=mean, 4=std, 5=sos (sum of squares)
156
159
  """
157
- key = ("proj", dim_x, dim_y, tuple(idx_list), proj_dim, proj_mode)
160
+ # Mask out indices along the displayed/projected dims — they're slice(None).
161
+ key_idx = tuple(
162
+ None if i in (dim_x, dim_y, proj_dim) else idx_list[i]
163
+ for i in range(len(idx_list))
164
+ )
165
+ key = ("proj", dim_x, dim_y, key_idx, proj_dim, proj_mode)
158
166
  if key in session.raw_cache:
159
167
  session.raw_cache.move_to_end(key)
160
168
  return session.raw_cache[key]
@@ -659,10 +667,12 @@ def render_mosaic(
659
667
  complex_mode=0,
660
668
  log_scale=False,
661
669
  mosaic_cols=None,
670
+ vmin_override=None,
671
+ vmax_override=None,
662
672
  ):
663
673
  idx_norm = list(idx_tuple)
664
674
  idx_norm[dim_z] = 0
665
- 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)
666
676
  if key in session.mosaic_cache:
667
677
  session.mosaic_cache.move_to_end(key)
668
678
  return session.mosaic_cache[key]
@@ -682,7 +692,9 @@ def render_mosaic(
682
692
  frames = [np.log1p(np.abs(f)).astype(np.float32) for f in frames]
683
693
  all_data = np.stack(frames)
684
694
 
685
- 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):
686
698
  vmin, vmax = -float(np.pi), float(np.pi)
687
699
  else:
688
700
  vmin = float(np.percentile(all_data, 1))