arrayview 0.12.1__tar.gz → 0.13.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 (72) hide show
  1. {arrayview-0.12.1 → arrayview-0.13.0}/.claude/skills/viewer-ui-checklist/SKILL.md +10 -2
  2. {arrayview-0.12.1 → arrayview-0.13.0}/AGENTS.md +2 -1
  3. {arrayview-0.12.1 → arrayview-0.13.0}/PKG-INFO +1 -1
  4. {arrayview-0.12.1 → arrayview-0.13.0}/pyproject.toml +1 -1
  5. {arrayview-0.12.1 → arrayview-0.13.0}/scripts/release.sh +1 -1
  6. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_launcher.py +79 -18
  7. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_stdio_server.py +284 -2
  8. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_viewer.html +279 -97
  9. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_vscode.py +131 -10
  10. arrayview-0.13.0/src/arrayview/arrayview-opener.vsix +0 -0
  11. {arrayview-0.12.1 → arrayview-0.13.0}/uv.lock +1 -1
  12. {arrayview-0.12.1 → arrayview-0.13.0}/vscode-extension/extension.js +179 -39
  13. arrayview-0.13.0/vscode-extension/package.json +53 -0
  14. arrayview-0.12.1/src/arrayview/arrayview-opener.vsix +0 -0
  15. arrayview-0.12.1/vscode-extension/package.json +0 -30
  16. {arrayview-0.12.1 → arrayview-0.13.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  17. {arrayview-0.12.1 → arrayview-0.13.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
  18. {arrayview-0.12.1 → arrayview-0.13.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  19. {arrayview-0.12.1 → arrayview-0.13.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  20. {arrayview-0.12.1 → arrayview-0.13.0}/.github/workflows/docs.yml +0 -0
  21. {arrayview-0.12.1 → arrayview-0.13.0}/.github/workflows/python-publish.yml +0 -0
  22. {arrayview-0.12.1 → arrayview-0.13.0}/.gitignore +0 -0
  23. {arrayview-0.12.1 → arrayview-0.13.0}/.python-version +0 -0
  24. {arrayview-0.12.1 → arrayview-0.13.0}/CONTRIBUTING.md +0 -0
  25. {arrayview-0.12.1 → arrayview-0.13.0}/LICENSE +0 -0
  26. {arrayview-0.12.1 → arrayview-0.13.0}/README.md +0 -0
  27. {arrayview-0.12.1 → arrayview-0.13.0}/docs/comparing.md +0 -0
  28. {arrayview-0.12.1 → arrayview-0.13.0}/docs/configuration.md +0 -0
  29. {arrayview-0.12.1 → arrayview-0.13.0}/docs/display.md +0 -0
  30. {arrayview-0.12.1 → arrayview-0.13.0}/docs/index.md +0 -0
  31. {arrayview-0.12.1 → arrayview-0.13.0}/docs/loading.md +0 -0
  32. {arrayview-0.12.1 → arrayview-0.13.0}/docs/logo.png +0 -0
  33. {arrayview-0.12.1 → arrayview-0.13.0}/docs/measurement.md +0 -0
  34. {arrayview-0.12.1 → arrayview-0.13.0}/docs/remote.md +0 -0
  35. {arrayview-0.12.1 → arrayview-0.13.0}/docs/stylesheets/extra.css +0 -0
  36. {arrayview-0.12.1 → arrayview-0.13.0}/docs/viewing.md +0 -0
  37. {arrayview-0.12.1 → arrayview-0.13.0}/matlab/arrayview.m +0 -0
  38. {arrayview-0.12.1 → arrayview-0.13.0}/mkdocs.yml +0 -0
  39. {arrayview-0.12.1 → arrayview-0.13.0}/plans/webview/LOG.md +0 -0
  40. {arrayview-0.12.1 → arrayview-0.13.0}/scripts/demo.py +0 -0
  41. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/ARCHITECTURE.md +0 -0
  42. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/__init__.py +0 -0
  43. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/__main__.py +0 -0
  44. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_app.py +0 -0
  45. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_config.py +0 -0
  46. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_icon.png +0 -0
  47. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_io.py +0 -0
  48. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_platform.py +0 -0
  49. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_render.py +0 -0
  50. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_segmentation.py +0 -0
  51. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_server.py +0 -0
  52. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_session.py +0 -0
  53. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_shell.html +0 -0
  54. {arrayview-0.12.1 → arrayview-0.13.0}/src/arrayview/_torch.py +0 -0
  55. {arrayview-0.12.1 → arrayview-0.13.0}/tests/conftest.py +0 -0
  56. {arrayview-0.12.1 → arrayview-0.13.0}/tests/make_vectorfield_test_arrays.py +0 -0
  57. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_api.py +0 -0
  58. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_browser.py +0 -0
  59. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_cli.py +0 -0
  60. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_command_reachability.py +0 -0
  61. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_config.py +0 -0
  62. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_interactions.py +0 -0
  63. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_large_arrays.py +0 -0
  64. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_mode_consistency.py +0 -0
  65. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_mode_matrix.py +0 -0
  66. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_mode_roundtrip.py +0 -0
  67. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_nifti_meta.py +0 -0
  68. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_rgb_pixel_art.py +0 -0
  69. {arrayview-0.12.1 → arrayview-0.13.0}/tests/test_torch.py +0 -0
  70. {arrayview-0.12.1 → arrayview-0.13.0}/tests/ui_audit.py +0 -0
  71. {arrayview-0.12.1 → arrayview-0.13.0}/tests/visual_smoke.py +0 -0
  72. {arrayview-0.12.1 → arrayview-0.13.0}/vscode-extension/LICENSE +0 -0
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: viewer-ui-checklist
3
- description: Use when adding keyboard shortcuts, changing layout, or making any UI change to arrayview. Ensures visual_smoke.py stays in sync.
3
+ description: Use when adding keyboard shortcuts, changing layout, or making any UI change to arrayview. Ensures visual_smoke.py, help overlay, and README stay in sync.
4
4
  ---
5
5
 
6
6
  # ArrayView UI Checklist
7
7
 
8
8
  ## Rule
9
9
 
10
- Every UI change to arrayview MUST be reflected in `tests/visual_smoke.py` before the task is complete.
10
+ Every UI change to arrayview MUST be reflected in `tests/visual_smoke.py`, the help overlay, and (when user-facing) `README.md` before the task is complete.
11
11
 
12
12
  ## What counts as a UI change
13
13
 
@@ -35,6 +35,14 @@ Every UI change to arrayview MUST be reflected in `tests/visual_smoke.py` before
35
35
 
36
36
  4. **Open and review** the new screenshots in `tests/smoke_output/`
37
37
 
38
+ 5. **Update `GUIDE_TABS`** in `_viewer.html` if you added, removed, or changed a keybinding
39
+ - Add/update the entry in the appropriate tab and section
40
+ - Include a `hint` for non-obvious shortcuts
41
+ - Use the `docs-style` skill for formatting rules
42
+
43
+ 6. **Update `README.md`** if the change is user-facing (new CLI flag, new API, new mode)
44
+ - Use the `docs-style` skill for formatting rules
45
+
38
46
  ## Red flags — STOP
39
47
 
40
48
  - "The shortcut is too simple to need a smoke test" → ALL shortcuts need entries
@@ -15,7 +15,8 @@ Load the relevant skill before touching the corresponding area.
15
15
  | `frontend-designer` | Styling/layout changes to `_viewer.html` |
16
16
  | `vscode-simplebrowser` | Extension, signal-file IPC, `_VSCODE_EXT_VERSION` |
17
17
  | `invocation-consistency` | Server startup, display-opening, env detection |
18
- | `docs-style` | README, help overlay, docstrings |
18
+ | `viewer-ui-checklist` | Any UI change (smoke tests, help overlay, README sync) |
19
+ | `docs-style` | README, help overlay, docstrings (formatting rules) |
19
20
 
20
21
  ## Non-Negotiables
21
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.12.1
3
+ Version: 0.13.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.12.1"
7
+ version = "0.13.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"
@@ -66,7 +66,7 @@ run() {
66
66
  }
67
67
 
68
68
  # --- Generate release notes ---
69
- CLAUDE_BIN="${CLAUDE_BIN:-/Users/oscar/.local/bin/claude}"
69
+ CLAUDE_BIN="${CLAUDE_BIN:-$(command -v claude 2>/dev/null || echo claude)}"
70
70
  PREV_TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo "")
71
71
  NOTES=""
72
72
 
@@ -2102,24 +2102,67 @@ def arrayview():
2102
2102
  from arrayview._io import load_data
2103
2103
  from arrayview._session import SESSIONS, Session
2104
2104
 
2105
- for file_path in args.files:
2106
- data = load_data(file_path)
2107
- session = Session(
2108
- data=data,
2109
- filepath=file_path,
2110
- name=_Path(file_path).name,
2105
+ # First file is the main array
2106
+ base_path = args.files[0]
2107
+ data = load_data(base_path)
2108
+ session = Session(
2109
+ data=data,
2110
+ filepath=base_path,
2111
+ name=_Path(base_path).name,
2112
+ )
2113
+ if getattr(args, "rgb", False):
2114
+ _setup_rgb(session)
2115
+
2116
+ # Attach vector field if provided
2117
+ if getattr(args, "vectorfield", None):
2118
+ from arrayview._server import _configure_vectorfield
2119
+
2120
+ vf_data = load_data(args.vectorfield)
2121
+ _configure_vectorfield(
2122
+ session, vf_data,
2123
+ getattr(args, "vectorfield_components_dim", None),
2111
2124
  )
2112
- if getattr(args, "rgb", False):
2113
- _setup_rgb(session)
2114
- SESSIONS[session.sid] = session
2115
- info = json.dumps(
2116
- {
2117
- "sid": session.sid,
2118
- "name": session.name,
2119
- "shape": [int(s) for s in session.shape],
2120
- }
2125
+
2126
+ SESSIONS[session.sid] = session
2127
+
2128
+ # Load overlay as a separate session
2129
+ overlay_sid = None
2130
+ if getattr(args, "overlay", None):
2131
+ ov_data = load_data(args.overlay)
2132
+ ov_session = Session(
2133
+ data=ov_data,
2134
+ filepath=args.overlay,
2135
+ name="overlay",
2121
2136
  )
2122
- print(f"SESSION:{info}", file=sys.stderr)
2137
+ SESSIONS[ov_session.sid] = ov_session
2138
+ overlay_sid = ov_session.sid
2139
+
2140
+ # Load compare files (additional positional args + --compare)
2141
+ compare_sids = []
2142
+ compare_paths = list(args.files[1:])
2143
+ if getattr(args, "compare", None):
2144
+ compare_paths.append(args.compare)
2145
+ for cp in compare_paths:
2146
+ cmp_data = load_data(cp)
2147
+ cmp_session = Session(
2148
+ data=cmp_data,
2149
+ filepath=cp,
2150
+ name=_Path(cp).name,
2151
+ )
2152
+ SESSIONS[cmp_session.sid] = cmp_session
2153
+ compare_sids.append(cmp_session.sid)
2154
+
2155
+ info = json.dumps(
2156
+ {
2157
+ "sid": session.sid,
2158
+ "name": session.name,
2159
+ "shape": [int(s) for s in session.shape],
2160
+ "has_vectorfield": session.vfield is not None,
2161
+ "overlay_sid": overlay_sid,
2162
+ "compare_sids": compare_sids or None,
2163
+ }
2164
+ )
2165
+ print(f"SESSION:{info}", file=sys.stderr)
2123
2166
  run_stdio_server()
2124
2167
  return
2125
2168
 
@@ -2662,9 +2705,27 @@ def arrayview():
2662
2705
  # WebSocket server entirely. The extension spawns a Python subprocess
2663
2706
  # with --mode stdio and bridges via postMessage — no ports needed.
2664
2707
  is_remote = _is_vscode_remote()
2665
- if is_remote and not args.overlay and not compare_files and not getattr(args, 'vectorfield', None):
2708
+ if is_remote:
2666
2709
  _ensure_vscode_extension()
2667
- _open_direct_via_signal_file(base_file, title=f"ArrayView: {name}")
2710
+ # Build generic extra CLI args so new flags work without touching the
2711
+ # VS Code extension — they're forwarded verbatim to the subprocess.
2712
+ extra_args: list[str] = []
2713
+ if args.vectorfield:
2714
+ extra_args += ["--vectorfield", os.path.abspath(args.vectorfield)]
2715
+ if vfield_components_dim is not None:
2716
+ extra_args += ["--vectorfield-components-dim", str(vfield_components_dim)]
2717
+ if args.overlay:
2718
+ extra_args += ["--overlay", os.path.abspath(args.overlay)]
2719
+ if args.rgb:
2720
+ extra_args.append("--rgb")
2721
+ # Compare files: pass as additional positional args
2722
+ for cf in compare_files:
2723
+ extra_args.append(cf)
2724
+ _open_direct_via_signal_file(
2725
+ base_file,
2726
+ title=f"ArrayView: {name}",
2727
+ extra_args=extra_args or None,
2728
+ )
2668
2729
  return
2669
2730
 
2670
2731
  sid = uuid.uuid4().hex
@@ -24,7 +24,10 @@ import numpy as np
24
24
 
25
25
  from arrayview._io import load_data, load_data_with_meta
26
26
  from arrayview._render import (
27
+ LUTS,
27
28
  _composite_overlay_mask,
29
+ _compute_vmin_vmax,
30
+ _ensure_lut,
28
31
  _extract_overlay_mask,
29
32
  _init_luts,
30
33
  _overlay_is_label_map,
@@ -32,6 +35,7 @@ from arrayview._render import (
32
35
  apply_complex_mode,
33
36
  extract_projection,
34
37
  extract_slice,
38
+ mosaic_shape,
35
39
  render_mosaic,
36
40
  render_projection_rgba,
37
41
  render_rgb_rgba,
@@ -485,6 +489,258 @@ def _handle_lebesgue(sid: str, params: dict[str, str]) -> None:
485
489
  })
486
490
 
487
491
 
492
+ def _handle_vectorfield_fetch(sid: str, params: dict) -> None:
493
+ """Return downsampled vector field arrows for a 2-D view."""
494
+ session = SESSIONS.get(sid)
495
+ if not session or session.vfield is None:
496
+ _write_json({"error": "no vectorfield"})
497
+ return
498
+ dim_x = int(params.get("dim_x", 0))
499
+ dim_y = int(params.get("dim_y", 0))
500
+ indices = params.get("indices", "")
501
+ idx_tuple = tuple(int(x) for x in indices.split(",")) if indices else ()
502
+ t_index = int(params.get("t_index", 0))
503
+ density_offset = int(params.get("density_offset", 0))
504
+ result = _compute_vfield_arrows(session, dim_x, dim_y, idx_tuple, t_index, density_offset)
505
+ if result is None:
506
+ _write_json({"error": "vectorfield computation failed"})
507
+ return
508
+ _write_json({
509
+ "arrows": result["arrows"].tolist(),
510
+ "scale": result["scale"],
511
+ "stride": result["stride"],
512
+ })
513
+
514
+
515
+ def _handle_pixel(sid: str, params: dict) -> None:
516
+ """Return the raw value at a single pixel."""
517
+ session = SESSIONS.get(sid)
518
+ if not session:
519
+ _write_json({"error": "session not found"})
520
+ return
521
+ if session.rgb_axis is not None:
522
+ _write_json({"value": None})
523
+ return
524
+ dim_x = int(params.get("dim_x", 0))
525
+ dim_y = int(params.get("dim_y", 0))
526
+ indices = params.get("indices", "")
527
+ idx_tuple = tuple(int(x) for x in indices.split(",")) if indices else ()
528
+ px = int(params.get("px", 0))
529
+ py = int(params.get("py", 0))
530
+ complex_mode = int(params.get("complex_mode", 0))
531
+ raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
532
+ data = apply_complex_mode(raw, complex_mode)
533
+ h, w = data.shape
534
+ if 0 <= py < h and 0 <= px < w:
535
+ v = data[py, px]
536
+ val = None if np.isnan(v) or np.isinf(v) else float(v)
537
+ else:
538
+ val = None
539
+ _write_json({"value": val})
540
+
541
+
542
+ # ---------------------------------------------------------------------------
543
+ # Diff / Compare helpers
544
+ # ---------------------------------------------------------------------------
545
+
546
+ def _render_normalized(session, dim_x, dim_y, idx_tuple, dr, complex_mode, log_scale):
547
+ """Extract a slice and normalize to [0, 1] float32."""
548
+ raw = extract_slice(session, dim_x, dim_y, list(idx_tuple))
549
+ data, vmin, vmax = _prepare_display(session, raw, complex_mode, dr, log_scale)
550
+ if vmax > vmin:
551
+ normalized = np.clip((data - vmin) / (vmax - vmin), 0, 1)
552
+ else:
553
+ normalized = np.zeros_like(data)
554
+ return normalized.astype(np.float32)
555
+
556
+
557
+ def _render_normalized_mosaic(session, dim_x, dim_y, dim_z, idx_tuple, dr, complex_mode, log_scale):
558
+ """Return (float32 normalized mosaic [0,1], nan_mask)."""
559
+ n = session.shape[dim_z]
560
+ idx_list = list(idx_tuple)
561
+ frames_raw = [
562
+ extract_slice(
563
+ session, dim_x, dim_y,
564
+ [i if j == dim_z else idx_list[j] for j in range(len(session.shape))],
565
+ )
566
+ for i in range(n)
567
+ ]
568
+ frames = [apply_complex_mode(f, complex_mode) for f in frames_raw]
569
+ if log_scale:
570
+ frames = [np.log1p(np.abs(f)).astype(np.float32) for f in frames]
571
+ all_data = np.stack(frames)
572
+ if log_scale:
573
+ vmin = float(np.percentile(all_data, 1))
574
+ vmax = float(np.percentile(all_data, 99))
575
+ else:
576
+ vmin, vmax = _compute_vmin_vmax(session, all_data, dr, complex_mode)
577
+ rows, cols = mosaic_shape(n)
578
+ H, W = frames[0].shape
579
+ GAP = 2
580
+ total_h = rows * H + (rows - 1) * GAP
581
+ total_w = cols * W + (cols - 1) * GAP
582
+ grid = np.full((total_h, total_w), np.nan, dtype=np.float32)
583
+ for k in range(n):
584
+ r, c = divmod(k, cols)
585
+ r0, c0 = r * (H + GAP), c * (W + GAP)
586
+ grid[r0 : r0 + H, c0 : c0 + W] = all_data[k]
587
+ nan_mask = np.isnan(grid)
588
+ if vmax > vmin:
589
+ normalized = np.clip(np.where(nan_mask, 0.0, (grid - vmin) / (vmax - vmin)), 0, 1)
590
+ else:
591
+ normalized = np.zeros_like(grid)
592
+ return normalized.astype(np.float32), nan_mask
593
+
594
+
595
+ def _compute_diff(session_a, session_b, dim_x, dim_y, indices, dim_z, dr, complex_mode, log_scale, diff_mode):
596
+ """Shared diff logic for both diff image and diff histogram handlers.
597
+
598
+ Returns (raw_diff, vmin, vmax, colormap, nan_mask_or_None).
599
+ """
600
+ idx_tuple = tuple(int(x) for x in indices.split(",")) if isinstance(indices, str) else indices
601
+ ndim_a = len(session_a.shape)
602
+ ndim_b = len(session_b.shape)
603
+ idx_a = idx_tuple[:ndim_a]
604
+ idx_b = idx_tuple[:ndim_b]
605
+ nan_mask = None
606
+
607
+ if dim_z >= 0:
608
+ a, nan_mask_a = _render_normalized_mosaic(session_a, dim_x, dim_y, dim_z, idx_a, dr, complex_mode, log_scale)
609
+ b, nan_mask_b = _render_normalized_mosaic(session_b, dim_x, dim_y, dim_z, idx_b, dr, complex_mode, log_scale)
610
+ nan_mask = nan_mask_a | nan_mask_b
611
+ else:
612
+ a = _render_normalized(session_a, dim_x, dim_y, idx_a, dr, complex_mode, log_scale)
613
+ b = _render_normalized(session_b, dim_x, dim_y, idx_b, dr, complex_mode, log_scale)
614
+
615
+ # Resize b to match a if shapes differ
616
+ if a.shape != b.shape:
617
+ b_img = _pil_image().fromarray((b * 255).astype(np.uint8), mode="L")
618
+ b_img = b_img.resize((a.shape[1], a.shape[0]), _pil_image().BILINEAR)
619
+ b = np.array(b_img, dtype=np.float32) / 255.0
620
+
621
+ if diff_mode == 1:
622
+ raw = a - b
623
+ vmin, vmax = -1.0, 1.0
624
+ colormap = "RdBu_r"
625
+ elif diff_mode == 2:
626
+ raw = np.abs(a - b)
627
+ vmax = float(raw.max()) or 1.0
628
+ vmin = 0.0
629
+ colormap = "afmhot"
630
+ else:
631
+ raw = np.abs(a - b) / np.maximum(np.abs(a), 1e-6)
632
+ raw = np.clip(raw, 0.0, 2.0).astype(np.float32)
633
+ vmax = float(raw.max()) or 1.0
634
+ vmin = 0.0
635
+ colormap = "afmhot"
636
+
637
+ return raw, vmin, vmax, colormap, nan_mask
638
+
639
+
640
+ def _handle_diff(sid_a: str, sid_b: str, params: dict) -> None:
641
+ """Render a diff image and return as base64 JPEG with metadata headers."""
642
+ import base64
643
+ import io
644
+
645
+ session_a = SESSIONS.get(sid_a)
646
+ session_b = SESSIONS.get(sid_b)
647
+ if not session_a or not session_b:
648
+ _write_json({"error": "session not found"})
649
+ return
650
+
651
+ dim_x = int(params.get("dim_x", 0))
652
+ dim_y = int(params.get("dim_y", 0))
653
+ indices = params.get("indices", "")
654
+ dim_z = int(params.get("dim_z", -1))
655
+ dr = int(params.get("dr", 1))
656
+ complex_mode = int(params.get("complex_mode", 0))
657
+ log_scale = params.get("log_scale", "false").lower() == "true"
658
+ diff_mode = int(params.get("diff_mode", 1))
659
+ diff_colormap = params.get("diff_colormap", "")
660
+ _vmin_ov = params.get("vmin_override")
661
+ _vmax_ov = params.get("vmax_override")
662
+ vmin_override = float(_vmin_ov) if _vmin_ov is not None else None
663
+ vmax_override = float(_vmax_ov) if _vmax_ov is not None else None
664
+
665
+ try:
666
+ raw, vmin, vmax, colormap, nan_mask = _compute_diff(
667
+ session_a, session_b, dim_x, dim_y, indices,
668
+ dim_z, dr, complex_mode, log_scale, diff_mode,
669
+ )
670
+ except Exception as e:
671
+ _write_json({"error": str(e)})
672
+ return
673
+
674
+ if vmin_override is not None:
675
+ vmin = vmin_override
676
+ if vmax_override is not None:
677
+ vmax = vmax_override
678
+ if diff_colormap and _ensure_lut(diff_colormap):
679
+ colormap = diff_colormap
680
+
681
+ if vmax > vmin:
682
+ normalized = np.clip((raw - vmin) / (vmax - vmin), 0, 1)
683
+ else:
684
+ normalized = np.zeros_like(raw)
685
+ _ensure_lut(colormap)
686
+ lut = LUTS.get(colormap, LUTS["gray"])
687
+ rgba = lut[(normalized * 255).astype(np.uint8)]
688
+ if nan_mask is not None and nan_mask.shape == rgba.shape[:2]:
689
+ rgba[nan_mask] = [22, 22, 22, 255]
690
+
691
+ img = _pil_image().fromarray(rgba[:, :, :3], mode="RGB")
692
+ buf = io.BytesIO()
693
+ img.save(buf, format="JPEG", quality=90)
694
+
695
+ _write_json({
696
+ "_binary": True,
697
+ "data": base64.b64encode(buf.getvalue()).decode("ascii"),
698
+ "headers": {
699
+ "X-ArrayView-Vmin": str(vmin),
700
+ "X-ArrayView-Vmax": str(vmax),
701
+ "X-ArrayView-Colormap": colormap,
702
+ },
703
+ })
704
+
705
+
706
+ def _handle_diff_histogram(sid_a: str, sid_b: str, params: dict) -> None:
707
+ """Compute histogram of the diff between two sessions."""
708
+ session_a = SESSIONS.get(sid_a)
709
+ session_b = SESSIONS.get(sid_b)
710
+ if not session_a or not session_b:
711
+ _write_json({"error": "session not found"})
712
+ return
713
+
714
+ dim_x = int(params.get("dim_x", 0))
715
+ dim_y = int(params.get("dim_y", 0))
716
+ indices = params.get("indices", "")
717
+ dim_z = int(params.get("dim_z", -1))
718
+ dr = int(params.get("dr", 1))
719
+ complex_mode = int(params.get("complex_mode", 0))
720
+ log_scale = params.get("log_scale", "false").lower() == "true"
721
+ diff_mode = int(params.get("diff_mode", 1))
722
+ bins = int(params.get("bins", 64))
723
+
724
+ try:
725
+ raw, _, _, _, _ = _compute_diff(
726
+ session_a, session_b, dim_x, dim_y, indices,
727
+ dim_z, dr, complex_mode, log_scale, diff_mode,
728
+ )
729
+ except Exception as e:
730
+ _write_json({"error": str(e)})
731
+ return
732
+
733
+ vmin = float(raw.min())
734
+ vmax = float(raw.max())
735
+ counts, edges = np.histogram(raw.ravel(), bins=bins)
736
+ _write_json({
737
+ "counts": counts.tolist(),
738
+ "edges": edges.tolist(),
739
+ "vmin": vmin,
740
+ "vmax": vmax,
741
+ })
742
+
743
+
488
744
  def _handle_fetch_proxy(msg: dict) -> None:
489
745
  """Handle proxied fetch requests from the viewer."""
490
746
  endpoint = msg.get("endpoint", "")
@@ -516,6 +772,14 @@ def _handle_fetch_proxy(msg: dict) -> None:
516
772
  _write_json({"status": "started"})
517
773
  elif route == "preload_status" and sid:
518
774
  _write_json({"done": 0, "total": 0, "skipped": True})
775
+ elif route == "vectorfield" and sid:
776
+ _handle_vectorfield_fetch(sid, params)
777
+ elif route == "pixel" and sid:
778
+ _handle_pixel(sid, params)
779
+ elif route == "diff" and len(parts) >= 3:
780
+ _handle_diff(parts[1], parts[2], params)
781
+ elif route == "diff-histogram" and len(parts) >= 3:
782
+ _handle_diff_histogram(parts[1], parts[2], params)
519
783
  else:
520
784
  _write_error(f"unsupported endpoint: {endpoint}")
521
785
 
@@ -637,7 +901,7 @@ def _handle_get_viewer_html(msg: dict) -> None:
637
901
  """Return the rendered viewer HTML with template substitutions."""
638
902
  from importlib.resources import files as _pkg_files
639
903
 
640
- from arrayview._config import get_viewer_colormaps
904
+ from arrayview._config import get_viewer_colormaps, get_viewer_theme
641
905
  from arrayview._render import COLORMAP_GRADIENT_STOPS, COMPLEX_MODES, REAL_MODES
642
906
  from arrayview._session import COLORMAPS
643
907
 
@@ -648,7 +912,24 @@ def _handle_get_viewer_html(msg: dict) -> None:
648
912
 
649
913
  sid = msg.get("sid", "")
650
914
 
651
- query_val = json.dumps(f"?sid={sid}&transport=postMessage") if sid else "null"
915
+ # Build query string from session info — the extension forwards the full
916
+ # SESSION: payload so new features (overlay_sid, compare, etc.) work
917
+ # without touching extension code.
918
+ qs = f"?sid={sid}&transport=postMessage"
919
+ overlay_sid = msg.get("overlay_sid")
920
+ if overlay_sid:
921
+ qs += f"&overlay_sid={overlay_sid}"
922
+ compare_sids = msg.get("compare_sids")
923
+ if compare_sids:
924
+ sids = compare_sids if isinstance(compare_sids, list) else [s.strip() for s in str(compare_sids).split(",") if s.strip()]
925
+ if sids:
926
+ qs += f"&compare_sid={sids[0]}"
927
+ qs += f"&compare_sids={','.join(sids)}"
928
+ query_val = json.dumps(qs) if sid else "null"
929
+
930
+ _theme_names = ["dark", "light", "solarized", "nord"]
931
+ _cfg_theme = get_viewer_theme()
932
+ _default_theme_idx = _theme_names.index(_cfg_theme) if _cfg_theme in _theme_names else 0
652
933
 
653
934
  html = (
654
935
  template.replace("__COLORMAPS__", str(_active_colormaps))
@@ -656,6 +937,7 @@ def _handle_get_viewer_html(msg: dict) -> None:
656
937
  .replace("__COMPLEX_MODES__", str(COMPLEX_MODES))
657
938
  .replace("__REAL_MODES__", str(REAL_MODES))
658
939
  .replace("__ARRAYVIEW_QUERY__", query_val)
940
+ .replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
659
941
  )
660
942
 
661
943
  _write_json({"html": html})