arrayview 0.16.0__tar.gz → 0.17.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.16.0 → arrayview-0.17.0}/PKG-INFO +1 -1
- {arrayview-0.16.0 → arrayview-0.17.0}/pyproject.toml +1 -1
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_server.py +560 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_stdio_server.py +1 -1
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_viewer.html +2649 -321
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_browser.py +13 -5
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_interactions.py +14 -26
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_mode_roundtrip.py +6 -3
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/visual_smoke.py +19 -23
- {arrayview-0.16.0 → arrayview-0.17.0}/uv.lock +1 -1
- {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/invocation-consistency/SKILL.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/modes-consistency/SKILL.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.github/copilot-instructions.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.github/workflows/docs.yml +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.github/workflows/python-publish.yml +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.gitignore +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.ignore +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/AGENTS.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/ROUTER.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/SETUP.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/SYNC.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/architecture.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/conventions.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/decisions.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/frontend.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/project-state.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/render-pipeline.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/setup.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/context/stack.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/INDEX.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/README.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/add-file-format.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/add-server-endpoint.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/debug-render.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.mex/patterns/frontend-change.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.opencode/opencode.json +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.python-version +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/.vscode/settings.json +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/AGENTS.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/CONTRIBUTING.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/IMMERSIVE_ANIMATION.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/LICENSE +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/README.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/comparing.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/configuration.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/display.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/index.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/loading.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/logo.png +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/measurement.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/remote.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/stylesheets/extra.css +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/docs/viewing.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/matlab/arrayview.m +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/mkdocs.yml +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/plans/2026-04-14-immersive-animation.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/plans/webview/LOG.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/scripts/demo.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/scripts/release.sh +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_io.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_launcher.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_platform.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_session.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/conftest.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_api.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_cli.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_command_reachability.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_config.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_cross_mode_parametrized.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_torch.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_view_component_integration.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/test_view_component_unit.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/tests/ui_audit.py +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/AGENTS.md +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/LICENSE +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/extension.js +0 -0
- {arrayview-0.16.0 → arrayview-0.17.0}/vscode-extension/package.json +0 -0
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
import math
|
|
13
13
|
import os
|
|
14
14
|
import threading
|
|
15
|
+
from datetime import datetime, timezone
|
|
15
16
|
|
|
16
17
|
import numpy as np
|
|
17
18
|
from fastapi import (
|
|
@@ -254,6 +255,243 @@ def _composite_overlays(
|
|
|
254
255
|
return rgba
|
|
255
256
|
|
|
256
257
|
|
|
258
|
+
# ── Oblique Preset Helpers ────────────────────────────────────────
|
|
259
|
+
#
|
|
260
|
+
# Clients in 3-plane multiview can persist the current oblique basis as a
|
|
261
|
+
# "recent" preset that survives across sessions. Storage is a single JSON file
|
|
262
|
+
# under ``~/.arrayview/oblique_recent.json`` — independent of any per-session
|
|
263
|
+
# state so the preset can be reused across arrays with compatible shapes.
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
_OBLIQUE_RECENT_FILE = os.path.expanduser("~/.arrayview/oblique_recent.json")
|
|
267
|
+
_OBLIQUE_LOCK = threading.Lock()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _utc_now_iso() -> str:
|
|
271
|
+
return datetime.now(timezone.utc).isoformat()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _clamp_int(value, lo: int, hi: int, fallback: int) -> int:
|
|
275
|
+
try:
|
|
276
|
+
v = int(value)
|
|
277
|
+
except Exception:
|
|
278
|
+
v = int(fallback)
|
|
279
|
+
if v < lo:
|
|
280
|
+
return lo
|
|
281
|
+
if v > hi:
|
|
282
|
+
return hi
|
|
283
|
+
return v
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _normalize_oblique_preset(
|
|
287
|
+
preset: dict | None,
|
|
288
|
+
*,
|
|
289
|
+
ndim: int,
|
|
290
|
+
shape: tuple[int, ...],
|
|
291
|
+
) -> dict | None:
|
|
292
|
+
"""Validate + clamp an oblique preset payload against the session shape.
|
|
293
|
+
|
|
294
|
+
Returns the sanitized preset, or None if required fields are missing /
|
|
295
|
+
malformed. Accepts any shape whose rank matches ``ndim`` — the caller
|
|
296
|
+
may enforce stricter shape-compatibility if desired.
|
|
297
|
+
"""
|
|
298
|
+
if not isinstance(preset, dict):
|
|
299
|
+
return None
|
|
300
|
+
try:
|
|
301
|
+
shape_out = [int(v) for v in preset.get("shape", list(shape))]
|
|
302
|
+
except Exception:
|
|
303
|
+
return None
|
|
304
|
+
if len(shape_out) != ndim:
|
|
305
|
+
return None
|
|
306
|
+
if any(int(v) <= 0 for v in shape_out):
|
|
307
|
+
return None
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
mv_dims = [int(v) for v in preset.get("mv_dims", [])]
|
|
311
|
+
except Exception:
|
|
312
|
+
return None
|
|
313
|
+
if len(mv_dims) != 3 or len(set(mv_dims)) != 3:
|
|
314
|
+
return None
|
|
315
|
+
if any((d < 0 or d >= ndim) for d in mv_dims):
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
indices = [int(v) for v in preset.get("indices", [])]
|
|
320
|
+
except Exception:
|
|
321
|
+
return None
|
|
322
|
+
if len(indices) != ndim:
|
|
323
|
+
return None
|
|
324
|
+
for d in range(ndim):
|
|
325
|
+
n = max(1, int(shape_out[d]))
|
|
326
|
+
indices[d] = _clamp_int(indices[d], 0, n - 1, n // 2)
|
|
327
|
+
|
|
328
|
+
vecs_raw = preset.get("oblique_vecs")
|
|
329
|
+
if not isinstance(vecs_raw, list) or len(vecs_raw) != 3:
|
|
330
|
+
return None
|
|
331
|
+
vecs_out: list[dict[str, list[float]]] = []
|
|
332
|
+
for item in vecs_raw:
|
|
333
|
+
if not isinstance(item, dict):
|
|
334
|
+
return None
|
|
335
|
+
try:
|
|
336
|
+
bh = [float(v) for v in item.get("bh", [])]
|
|
337
|
+
bv = [float(v) for v in item.get("bv", [])]
|
|
338
|
+
nn = [float(v) for v in item.get("n", [])]
|
|
339
|
+
except Exception:
|
|
340
|
+
return None
|
|
341
|
+
if len(bh) != 3 or len(bv) != 3 or len(nn) != 3:
|
|
342
|
+
return None
|
|
343
|
+
vecs_out.append({"bh": bh, "bv": bv, "n": nn})
|
|
344
|
+
|
|
345
|
+
pane_labels = preset.get("pane_labels")
|
|
346
|
+
if not isinstance(pane_labels, list) or len(pane_labels) != 3:
|
|
347
|
+
pane_labels = ["Oblique A", "Oblique B", "Oblique C"]
|
|
348
|
+
pane_labels = [str(v) for v in pane_labels]
|
|
349
|
+
|
|
350
|
+
pane_defs_raw = preset.get("pane_defs")
|
|
351
|
+
pane_defs_out = None
|
|
352
|
+
if isinstance(pane_defs_raw, list) and len(pane_defs_raw) == 3:
|
|
353
|
+
tmp_defs: list[dict[str, int]] = []
|
|
354
|
+
ok_defs = True
|
|
355
|
+
for pd in pane_defs_raw:
|
|
356
|
+
if not isinstance(pd, dict):
|
|
357
|
+
ok_defs = False
|
|
358
|
+
break
|
|
359
|
+
try:
|
|
360
|
+
dx = int(pd.get("dim_x"))
|
|
361
|
+
dy = int(pd.get("dim_y"))
|
|
362
|
+
sd = int(pd.get("slice_dir"))
|
|
363
|
+
except Exception:
|
|
364
|
+
ok_defs = False
|
|
365
|
+
break
|
|
366
|
+
if any((d < 0 or d >= ndim) for d in (dx, dy, sd)):
|
|
367
|
+
ok_defs = False
|
|
368
|
+
break
|
|
369
|
+
if len({dx, dy, sd}) != 3:
|
|
370
|
+
ok_defs = False
|
|
371
|
+
break
|
|
372
|
+
tmp_defs.append({"dim_x": dx, "dim_y": dy, "slice_dir": sd})
|
|
373
|
+
if ok_defs:
|
|
374
|
+
pane_defs_out = tmp_defs
|
|
375
|
+
|
|
376
|
+
out = {
|
|
377
|
+
"version": int(preset.get("version", 1)),
|
|
378
|
+
"shape": shape_out,
|
|
379
|
+
"mv_dims": mv_dims,
|
|
380
|
+
"indices": indices,
|
|
381
|
+
"oblique_vecs": vecs_out,
|
|
382
|
+
"pane_labels": pane_labels,
|
|
383
|
+
}
|
|
384
|
+
if pane_defs_out is not None:
|
|
385
|
+
out["pane_defs"] = pane_defs_out
|
|
386
|
+
lock_raw = preset.get("oblique_ortho_lock")
|
|
387
|
+
if isinstance(lock_raw, bool):
|
|
388
|
+
out["oblique_ortho_lock"] = lock_raw
|
|
389
|
+
return out
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _load_recent_oblique_file(path: str, *, ndim: int, shape: tuple[int, ...]) -> dict | None:
|
|
393
|
+
try:
|
|
394
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
395
|
+
data = json.load(f)
|
|
396
|
+
except Exception:
|
|
397
|
+
return None
|
|
398
|
+
return _normalize_oblique_preset(data, ndim=ndim, shape=shape)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _write_recent_oblique_file(
|
|
402
|
+
path: str, session: "Session", preset: dict
|
|
403
|
+
) -> tuple[bool, str | None]:
|
|
404
|
+
try:
|
|
405
|
+
folder = os.path.dirname(path)
|
|
406
|
+
if folder:
|
|
407
|
+
os.makedirs(folder, exist_ok=True)
|
|
408
|
+
payload = dict(preset)
|
|
409
|
+
payload["saved_at"] = _utc_now_iso()
|
|
410
|
+
payload["sid"] = getattr(session, "sid", None)
|
|
411
|
+
payload["name"] = getattr(session, "name", "") or ""
|
|
412
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
413
|
+
json.dump(payload, f, indent=2)
|
|
414
|
+
return True, None
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
return False, str(exc)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ── Crop Plugin State ─────────────────────────────────────────────
|
|
420
|
+
#
|
|
421
|
+
# The crop plugin tracks per-session state (selected x-range along the readout
|
|
422
|
+
# dim, visualisation slice indices, confirmation flag, optional recent-file
|
|
423
|
+
# path). State is held in memory keyed by sid; on confirm it may persist to
|
|
424
|
+
# ``~/.arrayview/crop_recent.json`` so external pipelines (e.g.
|
|
425
|
+
# reconstruction scripts) can reuse the last user-confirmed crop.
|
|
426
|
+
|
|
427
|
+
_CROP_RECENT_FILE = os.path.expanduser(
|
|
428
|
+
"~/.arrayview/crop_recent.json"
|
|
429
|
+
)
|
|
430
|
+
_CROP_LOCK = threading.Lock()
|
|
431
|
+
_CROP_STATE: dict[str, dict] = {}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _clamp_crop_range(x_start, x_end, nx: int) -> tuple[int, int]:
|
|
435
|
+
nx = max(1, int(nx))
|
|
436
|
+
default_start = max(0, nx // 4)
|
|
437
|
+
default_end = min(nx, (3 * nx) // 4)
|
|
438
|
+
if default_end <= default_start:
|
|
439
|
+
default_end = min(nx, default_start + 1)
|
|
440
|
+
xs = _clamp_int(x_start, 0, nx - 1, default_start)
|
|
441
|
+
xe = _clamp_int(x_end, 1, nx, default_end)
|
|
442
|
+
if xe <= xs:
|
|
443
|
+
xe = min(nx, xs + 1)
|
|
444
|
+
if xs >= xe:
|
|
445
|
+
xs = max(0, xe - 1)
|
|
446
|
+
return int(xs), int(xe)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _load_recent_crop_file(path: str, nx: int) -> dict | None:
|
|
450
|
+
try:
|
|
451
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
452
|
+
data = json.load(f)
|
|
453
|
+
if not isinstance(data, dict):
|
|
454
|
+
return None
|
|
455
|
+
xs, xe = _clamp_crop_range(data.get("x_start"), data.get("x_end"), nx)
|
|
456
|
+
out: dict = {"x_start": xs, "x_end": xe}
|
|
457
|
+
if "viz_z" in data:
|
|
458
|
+
try:
|
|
459
|
+
out["viz_z"] = int(data["viz_z"])
|
|
460
|
+
except Exception:
|
|
461
|
+
pass
|
|
462
|
+
if "viz_y" in data:
|
|
463
|
+
try:
|
|
464
|
+
out["viz_y"] = int(data["viz_y"])
|
|
465
|
+
except Exception:
|
|
466
|
+
pass
|
|
467
|
+
return out
|
|
468
|
+
except Exception:
|
|
469
|
+
return None
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _write_recent_crop_file(path: str, state: dict) -> tuple[bool, str | None]:
|
|
473
|
+
try:
|
|
474
|
+
folder = os.path.dirname(path)
|
|
475
|
+
if folder:
|
|
476
|
+
os.makedirs(folder, exist_ok=True)
|
|
477
|
+
payload = {
|
|
478
|
+
"x_start": int(state["x_start"]),
|
|
479
|
+
"x_end": int(state["x_end"]),
|
|
480
|
+
"nx": int(state["nx"]),
|
|
481
|
+
"readout_dim": int(state.get("readout_dim", -1)),
|
|
482
|
+
"viz_z": int(state.get("viz_z", -1)),
|
|
483
|
+
"viz_y": int(state.get("viz_y", -1)),
|
|
484
|
+
"saved_at": _utc_now_iso(),
|
|
485
|
+
"sid": state.get("sid"),
|
|
486
|
+
"name": state.get("name", ""),
|
|
487
|
+
}
|
|
488
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
489
|
+
json.dump(payload, f, indent=2)
|
|
490
|
+
return True, None
|
|
491
|
+
except Exception as exc:
|
|
492
|
+
return False, str(exc)
|
|
493
|
+
|
|
494
|
+
|
|
257
495
|
# ── FastAPI Application ───────────────────────────────────────────
|
|
258
496
|
|
|
259
497
|
app = FastAPI()
|
|
@@ -333,6 +571,8 @@ async def shell_websocket(ws: WebSocket):
|
|
|
333
571
|
SESSIONS[sid].reset_caches()
|
|
334
572
|
SESSIONS[sid].data = None
|
|
335
573
|
del SESSIONS[sid]
|
|
574
|
+
with _CROP_LOCK:
|
|
575
|
+
_CROP_STATE.pop(sid, None)
|
|
336
576
|
except Exception:
|
|
337
577
|
pass
|
|
338
578
|
finally:
|
|
@@ -3648,6 +3888,326 @@ async def load_bytes_endpoint(request: Request):
|
|
|
3648
3888
|
return {"sid": session.sid, "url": url}
|
|
3649
3889
|
|
|
3650
3890
|
|
|
3891
|
+
# ── Oblique Preset Routes ─────────────────────────────────────────
|
|
3892
|
+
#
|
|
3893
|
+
# These are intentionally NOT namespaced under any plugin — the oblique basis
|
|
3894
|
+
# is a property of the 3-plane multiview mode itself. Presets persist to a
|
|
3895
|
+
# single JSON file under ~/.arrayview so users can share an orientation
|
|
3896
|
+
# across sessions / arrays that happen to have the same rank.
|
|
3897
|
+
|
|
3898
|
+
|
|
3899
|
+
@app.post("/oblique/save")
|
|
3900
|
+
async def oblique_save(request: Request):
|
|
3901
|
+
"""Persist the current oblique basis as the 'recent' preset.
|
|
3902
|
+
|
|
3903
|
+
Body: ``{"sid": "<sid>", "preset": {...}}``.
|
|
3904
|
+
"""
|
|
3905
|
+
body = await request.json()
|
|
3906
|
+
sid = str(body.get("sid") or "").strip()
|
|
3907
|
+
if not sid:
|
|
3908
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
3909
|
+
session = SESSIONS.get(sid)
|
|
3910
|
+
if session is None:
|
|
3911
|
+
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
3912
|
+
shape = tuple(int(v) for v in getattr(session, "shape", ()) or ())
|
|
3913
|
+
ndim = len(shape)
|
|
3914
|
+
if ndim <= 0:
|
|
3915
|
+
return JSONResponse({"error": "invalid_session_shape"}, status_code=500)
|
|
3916
|
+
preset = _normalize_oblique_preset(body.get("preset"), ndim=ndim, shape=shape)
|
|
3917
|
+
if preset is None:
|
|
3918
|
+
return JSONResponse({"error": "invalid_oblique_preset"}, status_code=400)
|
|
3919
|
+
with _OBLIQUE_LOCK:
|
|
3920
|
+
ok, err = _write_recent_oblique_file(_OBLIQUE_RECENT_FILE, session, preset)
|
|
3921
|
+
if not ok:
|
|
3922
|
+
return JSONResponse(
|
|
3923
|
+
{"error": "oblique_save_failed", "detail": str(err)}, status_code=500
|
|
3924
|
+
)
|
|
3925
|
+
return JSONResponse(
|
|
3926
|
+
{
|
|
3927
|
+
"ok": True,
|
|
3928
|
+
"sid": sid,
|
|
3929
|
+
"saved_path": _OBLIQUE_RECENT_FILE,
|
|
3930
|
+
"preset": preset,
|
|
3931
|
+
}
|
|
3932
|
+
)
|
|
3933
|
+
|
|
3934
|
+
|
|
3935
|
+
@app.post("/oblique/load_recent")
|
|
3936
|
+
async def oblique_load_recent(request: Request):
|
|
3937
|
+
"""Return the most recently saved oblique preset, clamped to this session's shape.
|
|
3938
|
+
|
|
3939
|
+
Body: ``{"sid": "<sid>"}``.
|
|
3940
|
+
"""
|
|
3941
|
+
body = await request.json()
|
|
3942
|
+
sid = str(body.get("sid") or "").strip()
|
|
3943
|
+
if not sid:
|
|
3944
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
3945
|
+
session = SESSIONS.get(sid)
|
|
3946
|
+
if session is None:
|
|
3947
|
+
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
3948
|
+
shape = tuple(int(v) for v in getattr(session, "shape", ()) or ())
|
|
3949
|
+
ndim = len(shape)
|
|
3950
|
+
if ndim <= 0:
|
|
3951
|
+
return JSONResponse({"error": "invalid_session_shape"}, status_code=500)
|
|
3952
|
+
with _OBLIQUE_LOCK:
|
|
3953
|
+
preset = _load_recent_oblique_file(
|
|
3954
|
+
_OBLIQUE_RECENT_FILE, ndim=ndim, shape=shape
|
|
3955
|
+
)
|
|
3956
|
+
if preset is None:
|
|
3957
|
+
return JSONResponse(
|
|
3958
|
+
{"error": "oblique_recent_file_missing_or_invalid"}, status_code=404
|
|
3959
|
+
)
|
|
3960
|
+
return JSONResponse(
|
|
3961
|
+
{
|
|
3962
|
+
"ok": True,
|
|
3963
|
+
"sid": sid,
|
|
3964
|
+
"path": _OBLIQUE_RECENT_FILE,
|
|
3965
|
+
"preset": preset,
|
|
3966
|
+
}
|
|
3967
|
+
)
|
|
3968
|
+
|
|
3969
|
+
|
|
3970
|
+
# ── Crop Plugin Routes ────────────────────────────────────────────
|
|
3971
|
+
#
|
|
3972
|
+
# The crop plugin lets users pick an x-range along one dimension (the "readout
|
|
3973
|
+
# dim") and a pair of visualisation slice indices. External pipelines read the
|
|
3974
|
+
# confirmed state to crop raw data before reconstruction. State lives in
|
|
3975
|
+
# :data:`_CROP_STATE` keyed by sid; confirmed crops optionally persist
|
|
3976
|
+
# to :data:`_CROP_RECENT_FILE` so follow-up runs can reuse the choice.
|
|
3977
|
+
|
|
3978
|
+
|
|
3979
|
+
@app.post("/crop/register")
|
|
3980
|
+
async def crop_register(request: Request):
|
|
3981
|
+
"""Start (or re-start) a crop session for ``sid``.
|
|
3982
|
+
|
|
3983
|
+
Body: ``{"sid": "<sid>", "readout_dim": int?, "x_start": int?, "x_end": int?,
|
|
3984
|
+
"viz_z": int?, "viz_y": int?, "recent_file": str?}``. Values are clamped
|
|
3985
|
+
to the session's shape; when ``recent_file`` is provided and parsable the
|
|
3986
|
+
saved ``x_start/x_end/viz_z/viz_y`` seed the new state.
|
|
3987
|
+
"""
|
|
3988
|
+
body = await request.json()
|
|
3989
|
+
sid = str(body.get("sid") or "").strip()
|
|
3990
|
+
if not sid:
|
|
3991
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
3992
|
+
session = SESSIONS.get(sid)
|
|
3993
|
+
if session is None:
|
|
3994
|
+
return JSONResponse({"error": "session_not_found"}, status_code=404)
|
|
3995
|
+
|
|
3996
|
+
shape = tuple(int(s) for s in getattr(session, "spatial_shape", ()) or ())
|
|
3997
|
+
if not shape:
|
|
3998
|
+
shape = tuple(int(s) for s in getattr(session, "shape", ()) or ())
|
|
3999
|
+
ndim = len(shape)
|
|
4000
|
+
if ndim <= 0:
|
|
4001
|
+
return JSONResponse({"error": "invalid_session_shape"}, status_code=500)
|
|
4002
|
+
|
|
4003
|
+
readout_dim = _clamp_int(
|
|
4004
|
+
body.get("readout_dim", ndim - 1), 0, max(0, ndim - 1), ndim - 1
|
|
4005
|
+
)
|
|
4006
|
+
nx = int(shape[readout_dim])
|
|
4007
|
+
xs, xe = _clamp_crop_range(body.get("x_start"), body.get("x_end"), nx)
|
|
4008
|
+
|
|
4009
|
+
non_readout_dims = [d for d in range(ndim) if d != readout_dim]
|
|
4010
|
+
default_viz_z_dim = non_readout_dims[0] if non_readout_dims else -1
|
|
4011
|
+
default_viz_y_dim = non_readout_dims[1] if len(non_readout_dims) > 1 else -1
|
|
4012
|
+
viz_z = int(
|
|
4013
|
+
body.get(
|
|
4014
|
+
"viz_z",
|
|
4015
|
+
shape[default_viz_z_dim] // 2 if default_viz_z_dim >= 0 else -1,
|
|
4016
|
+
)
|
|
4017
|
+
)
|
|
4018
|
+
viz_y = int(
|
|
4019
|
+
body.get(
|
|
4020
|
+
"viz_y",
|
|
4021
|
+
shape[default_viz_y_dim] // 2 if default_viz_y_dim >= 0 else -1,
|
|
4022
|
+
)
|
|
4023
|
+
)
|
|
4024
|
+
|
|
4025
|
+
raw_recent = body.get("recent_file")
|
|
4026
|
+
recent_file = str(raw_recent).strip() if raw_recent is not None else ""
|
|
4027
|
+
if not recent_file:
|
|
4028
|
+
recent_file = _CROP_RECENT_FILE
|
|
4029
|
+
loaded_recent = False
|
|
4030
|
+
if recent_file:
|
|
4031
|
+
recent = _load_recent_crop_file(recent_file, nx)
|
|
4032
|
+
if recent is not None:
|
|
4033
|
+
xs, xe = int(recent["x_start"]), int(recent["x_end"])
|
|
4034
|
+
if "viz_z" in recent:
|
|
4035
|
+
viz_z = int(recent["viz_z"])
|
|
4036
|
+
if "viz_y" in recent:
|
|
4037
|
+
viz_y = int(recent["viz_y"])
|
|
4038
|
+
loaded_recent = True
|
|
4039
|
+
|
|
4040
|
+
state = {
|
|
4041
|
+
"sid": sid,
|
|
4042
|
+
"name": str(body.get("name") or getattr(session, "name", "") or ""),
|
|
4043
|
+
"shape": list(shape),
|
|
4044
|
+
"readout_dim": int(readout_dim),
|
|
4045
|
+
"nx": int(nx),
|
|
4046
|
+
"x_start": int(xs),
|
|
4047
|
+
"x_end": int(xe),
|
|
4048
|
+
"viz_z": int(viz_z),
|
|
4049
|
+
"viz_y": int(viz_y),
|
|
4050
|
+
"confirmed": False,
|
|
4051
|
+
"save_requested": False,
|
|
4052
|
+
"saved_recent": False,
|
|
4053
|
+
"saved_recent_path": None,
|
|
4054
|
+
"recent_file": recent_file,
|
|
4055
|
+
"loaded_recent": bool(loaded_recent),
|
|
4056
|
+
"updated_at": _utc_now_iso(),
|
|
4057
|
+
}
|
|
4058
|
+
with _CROP_LOCK:
|
|
4059
|
+
_CROP_STATE[sid] = state
|
|
4060
|
+
return JSONResponse(state)
|
|
4061
|
+
|
|
4062
|
+
|
|
4063
|
+
@app.get("/crop/state/{sid}")
|
|
4064
|
+
def crop_state(sid: str):
|
|
4065
|
+
"""Return the current in-memory crop state for ``sid``."""
|
|
4066
|
+
with _CROP_LOCK:
|
|
4067
|
+
state = _CROP_STATE.get(sid)
|
|
4068
|
+
if state is None:
|
|
4069
|
+
return JSONResponse(
|
|
4070
|
+
{"error": "crop_session_not_found"}, status_code=404
|
|
4071
|
+
)
|
|
4072
|
+
return JSONResponse(dict(state))
|
|
4073
|
+
|
|
4074
|
+
|
|
4075
|
+
@app.post("/crop/update")
|
|
4076
|
+
async def crop_update(request: Request):
|
|
4077
|
+
"""Update the crop range (and viz slice indices) for ``sid``.
|
|
4078
|
+
|
|
4079
|
+
Body: ``{"sid": "<sid>", "x_start": int, "x_end": int, "viz_z": int?,
|
|
4080
|
+
"viz_y": int?, "readout_dim": int?}``.
|
|
4081
|
+
|
|
4082
|
+
When ``readout_dim`` changes, ``nx`` is re-derived from the session's
|
|
4083
|
+
shape along the new dim and the range is re-seeded to the full extent
|
|
4084
|
+
(``0..nx``) unless explicit ``x_start``/``x_end`` are provided in the
|
|
4085
|
+
same body.
|
|
4086
|
+
"""
|
|
4087
|
+
body = await request.json()
|
|
4088
|
+
sid = str(body.get("sid") or "").strip()
|
|
4089
|
+
if not sid:
|
|
4090
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
4091
|
+
with _CROP_LOCK:
|
|
4092
|
+
state = _CROP_STATE.get(sid)
|
|
4093
|
+
if state is None:
|
|
4094
|
+
return JSONResponse(
|
|
4095
|
+
{"error": "crop_session_not_found"}, status_code=404
|
|
4096
|
+
)
|
|
4097
|
+
# Optional readout-dim change — re-derive nx from session shape and
|
|
4098
|
+
# reset the range to full extent unless the client provides one.
|
|
4099
|
+
if "readout_dim" in body:
|
|
4100
|
+
shape = tuple(int(s) for s in state.get("shape", ()) or ())
|
|
4101
|
+
ndim = len(shape)
|
|
4102
|
+
if ndim > 0:
|
|
4103
|
+
new_rd = _clamp_int(
|
|
4104
|
+
body.get("readout_dim"),
|
|
4105
|
+
0,
|
|
4106
|
+
max(0, ndim - 1),
|
|
4107
|
+
int(state.get("readout_dim", ndim - 1)),
|
|
4108
|
+
)
|
|
4109
|
+
if new_rd != int(state.get("readout_dim", -1)):
|
|
4110
|
+
new_nx = int(shape[new_rd])
|
|
4111
|
+
state["readout_dim"] = int(new_rd)
|
|
4112
|
+
state["nx"] = int(new_nx)
|
|
4113
|
+
# Re-seed to full range; client can override via x_start/x_end.
|
|
4114
|
+
if "x_start" not in body and "x_end" not in body:
|
|
4115
|
+
state["x_start"] = 0
|
|
4116
|
+
state["x_end"] = int(new_nx)
|
|
4117
|
+
state["confirmed"] = False
|
|
4118
|
+
state["loaded_recent"] = False
|
|
4119
|
+
xs, xe = _clamp_crop_range(
|
|
4120
|
+
body.get("x_start", state.get("x_start")),
|
|
4121
|
+
body.get("x_end", state.get("x_end")),
|
|
4122
|
+
int(state["nx"]),
|
|
4123
|
+
)
|
|
4124
|
+
state["x_start"] = int(xs)
|
|
4125
|
+
state["x_end"] = int(xe)
|
|
4126
|
+
if "viz_z" in body:
|
|
4127
|
+
state["viz_z"] = int(body.get("viz_z", state.get("viz_z", -1)))
|
|
4128
|
+
if "viz_y" in body:
|
|
4129
|
+
state["viz_y"] = int(body.get("viz_y", state.get("viz_y", -1)))
|
|
4130
|
+
state["updated_at"] = _utc_now_iso()
|
|
4131
|
+
return JSONResponse(dict(state))
|
|
4132
|
+
|
|
4133
|
+
|
|
4134
|
+
@app.post("/crop/load_recent")
|
|
4135
|
+
async def crop_load_recent(request: Request):
|
|
4136
|
+
"""Overlay the saved recent crop onto the current state (if any).
|
|
4137
|
+
|
|
4138
|
+
Body: ``{"sid": "<sid>"}``. Returns 404 if no saved crop is available.
|
|
4139
|
+
"""
|
|
4140
|
+
body = await request.json()
|
|
4141
|
+
sid = str(body.get("sid") or "").strip()
|
|
4142
|
+
if not sid:
|
|
4143
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
4144
|
+
with _CROP_LOCK:
|
|
4145
|
+
state = _CROP_STATE.get(sid)
|
|
4146
|
+
if state is None:
|
|
4147
|
+
return JSONResponse(
|
|
4148
|
+
{"error": "crop_session_not_found"}, status_code=404
|
|
4149
|
+
)
|
|
4150
|
+
recent_file = state.get("recent_file") or _CROP_RECENT_FILE
|
|
4151
|
+
recent = _load_recent_crop_file(str(recent_file), int(state["nx"]))
|
|
4152
|
+
if recent is None:
|
|
4153
|
+
return JSONResponse(
|
|
4154
|
+
{"error": "recent_file_missing_or_invalid"}, status_code=404
|
|
4155
|
+
)
|
|
4156
|
+
state["x_start"] = int(recent["x_start"])
|
|
4157
|
+
state["x_end"] = int(recent["x_end"])
|
|
4158
|
+
if "viz_z" in recent:
|
|
4159
|
+
state["viz_z"] = int(recent["viz_z"])
|
|
4160
|
+
if "viz_y" in recent:
|
|
4161
|
+
state["viz_y"] = int(recent["viz_y"])
|
|
4162
|
+
state["loaded_recent"] = True
|
|
4163
|
+
state["updated_at"] = _utc_now_iso()
|
|
4164
|
+
return JSONResponse(dict(state))
|
|
4165
|
+
|
|
4166
|
+
|
|
4167
|
+
@app.post("/crop/confirm")
|
|
4168
|
+
async def crop_confirm(request: Request):
|
|
4169
|
+
"""Mark the current crop as confirmed and persist to the recent-file.
|
|
4170
|
+
|
|
4171
|
+
Body: ``{"sid": "<sid>"}``. Confirm always writes the recent-file —
|
|
4172
|
+
the UI no longer offers a confirm-without-save path.
|
|
4173
|
+
"""
|
|
4174
|
+
body = await request.json()
|
|
4175
|
+
sid = str(body.get("sid") or "").strip()
|
|
4176
|
+
if not sid:
|
|
4177
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
4178
|
+
with _CROP_LOCK:
|
|
4179
|
+
state = _CROP_STATE.get(sid)
|
|
4180
|
+
if state is None:
|
|
4181
|
+
return JSONResponse(
|
|
4182
|
+
{"error": "crop_session_not_found"}, status_code=404
|
|
4183
|
+
)
|
|
4184
|
+
state["confirmed"] = True
|
|
4185
|
+
state["save_requested"] = True
|
|
4186
|
+
state["saved_recent"] = False
|
|
4187
|
+
state["saved_recent_path"] = None
|
|
4188
|
+
state["updated_at"] = _utc_now_iso()
|
|
4189
|
+
target = state.get("recent_file") or _CROP_RECENT_FILE
|
|
4190
|
+
ok, err = _write_recent_crop_file(str(target), state)
|
|
4191
|
+
if ok:
|
|
4192
|
+
state["saved_recent"] = True
|
|
4193
|
+
state["saved_recent_path"] = str(target)
|
|
4194
|
+
else:
|
|
4195
|
+
state["save_error"] = err
|
|
4196
|
+
return JSONResponse(dict(state))
|
|
4197
|
+
|
|
4198
|
+
|
|
4199
|
+
@app.post("/crop/clear")
|
|
4200
|
+
async def crop_clear(request: Request):
|
|
4201
|
+
"""Drop the in-memory crop state for ``sid`` (no-op if absent)."""
|
|
4202
|
+
body = await request.json()
|
|
4203
|
+
sid = str(body.get("sid") or "").strip()
|
|
4204
|
+
if not sid:
|
|
4205
|
+
return JSONResponse({"error": "missing_sid"}, status_code=400)
|
|
4206
|
+
with _CROP_LOCK:
|
|
4207
|
+
_CROP_STATE.pop(sid, None)
|
|
4208
|
+
return JSONResponse({"ok": True, "sid": sid})
|
|
4209
|
+
|
|
4210
|
+
|
|
3651
4211
|
# ── Root UI Route ─────────────────────────────────────────────────
|
|
3652
4212
|
|
|
3653
4213
|
|
|
@@ -925,7 +925,7 @@ def _handle_get_viewer_html(msg: dict) -> None:
|
|
|
925
925
|
# not the FastAPI server. Inline the vendored copy instead.
|
|
926
926
|
gsap_js = _pkg_files("arrayview").joinpath("gsap.min.js").read_text(encoding="utf-8")
|
|
927
927
|
template = template.replace(
|
|
928
|
-
'<script src="
|
|
928
|
+
'<script src="gsap.min.js"></script>',
|
|
929
929
|
f"<script>{gsap_js}</script>",
|
|
930
930
|
)
|
|
931
931
|
|