From 5120f185c13a989b0c590235c345a86b0babd790 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Sun, 26 Apr 2026 22:10:57 +0200 Subject: [PATCH 1/6] add validation CLI --- pyproject.toml | 19 ++- src/oz_viewer/_cli.py | 105 ++++++++++++++++ src/oz_viewer/_display.py | 249 ++++++++++++++++++++++++++++++++++++++ src/oz_viewer/_ping.py | 218 +++++++++++++++++++++++++++++++++ tests/conftest.py | 36 ++++++ tests/test_cli.py | 119 ++++++++++++++++++ 6 files changed, 741 insertions(+), 5 deletions(-) create mode 100644 src/oz_viewer/_cli.py create mode 100644 src/oz_viewer/_display.py create mode 100644 src/oz_viewer/_ping.py create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py diff --git a/pyproject.toml b/pyproject.toml index cc99937..d6fac02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,12 @@ classifiers = [ "Typing :: Typed", ] # add your package dependencies here -dependencies = [] +dependencies = [ + "yaozarrs[io] >= 0.3", + "typer >= 0.12", + "rich >= 13", + "numpy >= 1.24", +] # https://peps.python.org/pep-0621/#dependencies-optional-dependencies # add dependencies for "extra" features here. Not dev dependencies. @@ -47,9 +52,8 @@ repository = "https://github.com/kevinyamauchi/oz-viewer" # Entry points # https://peps.python.org/pep-0621/#entry-points -# same as console_scripts entry point -# [project.scripts] -# oz-viewer-cli = "oz_viewer:main_cli" +[project.scripts] +oz-viewer = "oz_viewer._cli:app" # [project.entry-points."some.group"] # tomatoes = "oz_viewer:main_tomatoes" @@ -57,7 +61,7 @@ repository = "https://github.com/kevinyamauchi/oz-viewer" # https://peps.python.org/pep-0735/ # setup with `uv sync` or `pip install -e . --group dev` [dependency-groups] -test = ["pytest", "pytest-cov"] +test = ["pytest", "pytest-cov", "zarr >= 3.1", "ome-zarr"] dev = [ { include-group = "test" }, "ipython", @@ -139,3 +143,8 @@ ignore = [ # https://github.com/crate-ci/typos/blob/master/docs/reference.md [tool.typos.default] extend-ignore-identifiers-re = [] + +[tool.typos.default.extend-words] +# OME (Open Microscopy Environment) is a domain term, not a typo for "SOME" +OME = "OME" +ome = "ome" diff --git a/src/oz_viewer/_cli.py b/src/oz_viewer/_cli.py new file mode 100644 index 0000000..1f4cc33 --- /dev/null +++ b/src/oz_viewer/_cli.py @@ -0,0 +1,105 @@ +"""CLI entry point for oz-viewer.""" + +from __future__ import annotations + +import sys +from typing import Annotated + +import typer + +from oz_viewer._display import ( + make_console, + make_ping_progress, + print_error_panel, + print_metadata_panel, + print_ping_header, + print_ping_results, + print_success_panel, +) + +app = typer.Typer( + name="oz-viewer", + help="Validate and inspect OME-Zarr stores.", + no_args_is_help=True, +) + + +@app.command() +def validate( + path: Annotated[str, typer.Argument(help="Path or URI to the OME-Zarr store.")], + verbose: Annotated[ + bool, + typer.Option( + "--verbose", + "-v", + help="Pretty-print the full metadata model after successful validation.", + ), + ] = False, +) -> None: + """Validate an OME-Zarr store.""" + from yaozarrs import validate_zarr_store + from yaozarrs._storage import StorageValidationError + + console = make_console() + try: + group = validate_zarr_store(path) + except StorageValidationError as e: + print_error_panel(path, e, console) + raise typer.Exit(code=1) from None + except ImportError as e: + print(str(e), file=sys.stderr) + raise typer.Exit(code=2) from None + except Exception as e: + print(str(e), file=sys.stderr) + raise typer.Exit(code=2) from None + + print_success_panel(path, group, console) + if verbose: + print_metadata_panel(group.ome_metadata(), console) + + +@app.command() +def ping( + path: Annotated[str, typer.Argument(help="Path or URI to the OME-Zarr store.")], + n_fetch: Annotated[ + int, + typer.Option( + "--n-fetch", + help="Number of chunk fetches to average over.", + min=1, + ), + ] = 5, + timeout: Annotated[ + float, + typer.Option( + "--timeout", + help="Per-fetch timeout in seconds.", + min=0.0, + ), + ] = 10.0, +) -> None: + """Measure chunk fetch latency for an OME-Zarr store.""" + from yaozarrs import validate_zarr_store + + from oz_viewer._ping import build_chunk_info, run_fetches + + console = make_console() + try: + group = validate_zarr_store(path) + except Exception as e: + print(str(e), file=sys.stderr) + raise typer.Exit(code=2) from None + + chunk_info = build_chunk_info(group, group.ome_metadata()) + print_ping_header(path, chunk_info, n_fetch, timeout, console) + + progress = make_ping_progress(console) + with progress: + task_id = progress.add_task("Fetching chunks…", total=n_fetch) + result = run_fetches(chunk_info, n_fetch, timeout, progress, task_id) + + print_ping_results(chunk_info, result, console) + + +if __name__ == "__main__": + app() diff --git a/src/oz_viewer/_display.py b/src/oz_viewer/_display.py new file mode 100644 index 0000000..ba5f597 --- /dev/null +++ b/src/oz_viewer/_display.py @@ -0,0 +1,249 @@ +"""Rich terminal rendering for oz-viewer.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from rich.console import Console +from rich.panel import Panel +from rich.pretty import Pretty +from rich.progress import ( + BarColumn, + Progress, + SpinnerColumn, + TaskProgressColumn, + TextColumn, +) +from rich.table import Table, box +from rich.theme import Theme + +if TYPE_CHECKING: + from oz_viewer._ping import ChunkInfo, FetchResult + +OZ_THEME = Theme({"repr.attrib_name": "bold blue"}) + + +def make_console() -> Console: + """Return a Console with the oz-viewer theme.""" + return Console(theme=OZ_THEME) + + +def print_success_panel(path: str, group: Any, console: Console) -> None: + """Print a green success panel for a valid OME-Zarr store. + + Parameters + ---------- + path : str + The store path that was validated. + group : Any + The validated ZarrGroup. + console : Console + Rich console to print to. + """ + version = group.ome_version() + meta_type = type(group.ome_metadata()).__name__ + content = ( + f"[bold green]✓ Valid OME-Zarr[/bold green]\n" + f"Path {path}\n" + f"Version {version}\n" + f"Type {meta_type}" + ) + console.print( + Panel(content, title="oz-viewer validate", border_style="green", expand=False) + ) + + +def print_metadata_panel(meta: Any, console: Console) -> None: + """Print the pydantic metadata model inside a blue panel. + + Parameters + ---------- + meta : Any + The OME metadata model instance. + console : Console + Rich console to print to. + """ + console.print( + Panel( + Pretty(meta, indent_size=2, expand_all=True, indent_guides=True), + title=f"[bold]Metadata Model[/bold] [dim]{type(meta).__name__}[/dim]", + border_style="blue", + ) + ) + + +def print_error_panel(path: str, error: Exception, console: Console) -> None: + """Print a red validation error panel. + + Parameters + ---------- + path : str + The store path that failed validation. + error : Exception + The StorageValidationError raised. + console : Console + Rich console to print to. + """ + content = f"[bold red]✗ Validation failed[/bold red]\nPath {path}\n\n{error}" + console.print( + Panel(content, title="oz-viewer validate", border_style="red", expand=False) + ) + + +def print_ping_header( + path: str, + chunk_info: ChunkInfo, + n_fetch: int, + timeout: float, + console: Console, +) -> None: + """Print the ping header panel before fetching begins. + + Parameters + ---------- + path : str + The store path being pinged. + chunk_info : ChunkInfo + Chunk metadata for display. + n_fetch : int + Number of fetches to perform. + timeout : float + Per-fetch timeout in seconds. + console : Console + Rich console to print to. + """ + chunk_line = ( + f"Chunk {chunk_info.origin_key}" + f" ({chunk_info.ndim}D, {chunk_info.level_path})" + ) + content = ( + f"Store {path}\n" + f"Driver {chunk_info.protocol}\n" + f"{chunk_line}\n" + f"Fetches {n_fetch} \N{MULTIPLICATION SIGN} timeout {timeout}s" + ) + console.print( + Panel(content, title="oz-viewer ping", border_style="blue", expand=False) + ) + + +def make_ping_progress(console: Console) -> Progress: + """Return a Rich Progress instance bound to the shared console. + + Parameters + ---------- + console : Console + Rich console to bind the progress bar to. + + Returns + ------- + Progress + Configured progress bar (not yet started). + """ + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TaskProgressColumn(), + console=console, + ) + + +def _human_bytes(n: int | float) -> str: + """Format a byte count as a human-readable string. + + Parameters + ---------- + n : int | float + Number of bytes. + + Returns + ------- + str + Human-readable size string, e.g. ``"4.0 KB"`` or ``"1.2 MB"``. + """ + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024: + return f"{n:.1f} {unit}" + n /= 1024 + return f"{n:.1f} PB" + + +def print_ping_results( + chunk_info: ChunkInfo, + result: FetchResult, + console: Console, +) -> None: + """Print the ping results table inside a panel. + + Parameters + ---------- + chunk_info : ChunkInfo + Chunk metadata. + result : FetchResult + Fetch timing results. + console : Console + Rich console to print to. + """ + import statistics + + n_success = len(result.latencies) + all_failed = n_success == 0 + has_issues = result.n_timeouts > 0 or result.n_errors > 0 + + if all_failed: + border_style = "red" + title = "✗ Failed" + elif has_issues: + border_style = "yellow" + title = "⚠ Complete with issues" + else: + border_style = "green" + title = "✓ Complete" + + if all_failed: + lines = [f"[bold red]All {result.n_attempted} fetch(es) failed.[/bold red]"] + if result.n_timeouts > 0: + lines.append(f"[yellow]Timeouts: {result.n_timeouts}[/yellow]") + if result.n_errors > 0: + lines.append(f"[red]Errors: {result.n_errors}[/red]") + content = "\n".join(lines) + console.print( + Panel(content, title=title, border_style=border_style, expand=False), + ) + return + + table = Table(box=box.ROUNDED, show_header=True) + table.add_column("Metric", style="bold") + table.add_column("Value", justify="right") + + table.add_row("Fetches attempted", str(result.n_attempted)) + table.add_row("Fetches completed", str(n_success)) + if result.n_timeouts > 0: + table.add_row("Timeouts", f"[yellow]{result.n_timeouts}[/yellow]") + if result.n_errors > 0: + table.add_row("Errors", f"[red]{result.n_errors}[/red]") + table.add_row("Chunk shape", str(chunk_info.chunk_shape)) + table.add_row("Dtype", chunk_info.dtype_str) + table.add_row("Uncompressed size", _human_bytes(chunk_info.uncompressed_bytes)) + if result.compressed_bytes is not None: + table.add_row("Compressed size", _human_bytes(result.compressed_bytes)) + ratio = chunk_info.uncompressed_bytes / result.compressed_bytes + table.add_row( + "Compression ratio", + f"{ratio:.1f} \N{MULTIPLICATION SIGN}", + ) + + if n_success >= 1: + mean = statistics.mean(result.latencies) + table.add_row("Mean latency", f"{mean * 1000:.1f} ms") + table.add_row("Min latency", f"{min(result.latencies) * 1000:.1f} ms") + table.add_row("Max latency", f"{max(result.latencies) * 1000:.1f} ms") + if n_success >= 2: + stdev = statistics.stdev(result.latencies) + table.add_row("Std dev", f"{stdev * 1000:.1f} ms") + if n_success >= 1 and result.compressed_bytes is not None: + throughput = result.compressed_bytes / mean + table.add_row("Throughput", f"{_human_bytes(throughput)}/s") + + console.print(Panel(table, title=title, border_style=border_style)) diff --git a/src/oz_viewer/_ping.py b/src/oz_viewer/_ping.py new file mode 100644 index 0000000..8e0a088 --- /dev/null +++ b/src/oz_viewer/_ping.py @@ -0,0 +1,218 @@ +"""Chunk fetch logic for the ping command.""" + +from __future__ import annotations + +import time +from concurrent.futures import Future, ThreadPoolExecutor +from concurrent.futures import TimeoutError as FuturesTimeoutError +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import fsspec +import numpy as np + +if TYPE_CHECKING: + from rich.progress import Progress, TaskID + + +@dataclass(frozen=True) +class ChunkInfo: + """All information needed to fetch and display a chunk. + + Attributes + ---------- + origin_key : str + e.g. ``"s1/c/0/0/0/0"`` + chunk_path : str + Full filesystem path for ``fsspec.cat()``. + store_root : str + Store root without trailing slash. + level_path : str + Coarsest dataset path, e.g. ``"s1"``. + ndim : int + Number of array dimensions. + chunk_shape : tuple[int, ...] + Voxel dimensions. + dtype_str : str + e.g. ``"uint16"`` + uncompressed_bytes : int + ``prod(chunk_shape) * dtype.itemsize`` + protocol : str + Normalised fsspec driver, e.g. ``"file"``, ``"https"``. + """ + + origin_key: str + chunk_path: str + store_root: str + level_path: str + ndim: int + chunk_shape: tuple[int, ...] + dtype_str: str + uncompressed_bytes: int + protocol: str + + +@dataclass(frozen=True) +class FetchResult: + """Results from a series of chunk fetches. + + Attributes + ---------- + latencies : tuple[float, ...] + Seconds, one per successful fetch. + compressed_bytes : int | None + From first successful fetch; ``None`` if all failed. + n_attempted : int + Total fetches attempted. + n_timeouts : int + Number of fetches that timed out. + n_errors : int + Number of fetches that errored (excluding timeouts). + """ + + latencies: tuple[float, ...] + compressed_bytes: int | None + n_attempted: int + n_timeouts: int + n_errors: int + + +def build_chunk_info(group: Any, meta: Any) -> ChunkInfo: + """Build a ChunkInfo from an open ZarrGroup and its metadata. + + Parameters + ---------- + group : Any + Open ZarrGroup from yaozarrs. + meta : Any + OME metadata model (e.g. Image or Plate). + + Returns + ------- + ChunkInfo + Frozen dataclass with all fetch parameters. + """ + coarsest = meta.multiscales[0].datasets[-1] + array = group[coarsest.path] + level_path = coarsest.path + + # Determine chunk key encoding prefix and separator + array_meta = array._metadata + chunk_key_encoding = array_meta.chunk_key_encoding + if isinstance(chunk_key_encoding, dict): + enc_name = chunk_key_encoding.get("name", "default") + enc_cfg = chunk_key_encoding.get("configuration", {}) or {} + sep = enc_cfg.get("separator", "/") if isinstance(enc_cfg, dict) else "/" + else: + enc_name = getattr(chunk_key_encoding, "name", "default") + sep = "/" + + if enc_name == "default": + prefix = "c" + sep + else: + prefix = "" + sep = "." + + origin_key = f"{array._path}/{prefix}{sep.join(['0'] * array.ndim)}" + + # Strip the array path from the store path to get store root + store_path = str(array.store_path) + array_rel = array._path.lstrip("/") + if store_path.endswith("/" + array_rel): + store_root = store_path[: -(len(array_rel) + 1)] + elif store_path.endswith(array_rel): + store_root = store_path[: -len(array_rel)] + else: + store_root = store_path + store_root = store_root.rstrip("/") + + chunk_path = store_root + "/" + origin_key + + fs, _ = fsspec.url_to_fs(store_root) + protocol = fs.protocol + if isinstance(protocol, tuple): + protocol = protocol[0] + + chunk_shape = tuple( + int(x) for x in array_meta.chunk_grid["configuration"]["chunk_shape"] + ) + if hasattr(array_meta, "data_type"): + dtype_str = str(array_meta.data_type) + else: + dtype_str = str(array.dtype) + # Strip module prefix if present (e.g. "DataType.uint16" -> "uint16") + if "." in dtype_str: + dtype_str = dtype_str.split(".")[-1] + + uncompressed_bytes = int(np.prod(chunk_shape)) * np.dtype(dtype_str).itemsize + + return ChunkInfo( + origin_key=origin_key, + chunk_path=chunk_path, + store_root=store_root, + level_path=level_path, + ndim=array.ndim, + chunk_shape=chunk_shape, + dtype_str=dtype_str, + uncompressed_bytes=uncompressed_bytes, + protocol=protocol, + ) + + +def run_fetches( + chunk_info: ChunkInfo, + n_fetch: int, + timeout: float, + progress: Progress, + task_id: TaskID, +) -> FetchResult: + """Fetch a chunk repeatedly and collect timing results. + + Parameters + ---------- + chunk_info : ChunkInfo + Chunk location and metadata. + n_fetch : int + Number of fetches to perform. + timeout : float + Per-fetch timeout in seconds. + progress : Progress + Rich Progress instance for advancing the bar. + task_id : TaskID + Task ID within the progress bar. + + Returns + ------- + FetchResult + Collected timing and error statistics. + """ + fs, _ = fsspec.url_to_fs(chunk_info.store_root) + latencies: list[float] = [] + compressed_bytes: int | None = None + n_timeouts = 0 + n_errors = 0 + + for _ in range(n_fetch): + t_start = time.perf_counter() + try: + with ThreadPoolExecutor(max_workers=1) as executor: + future: Future[bytes] = executor.submit(fs.cat, chunk_info.chunk_path) + data = future.result(timeout=timeout) + t_end = time.perf_counter() + latencies.append(t_end - t_start) + if compressed_bytes is None: + compressed_bytes = len(data) + except FuturesTimeoutError: + n_timeouts += 1 + except Exception: + n_errors += 1 + finally: + progress.advance(task_id) + + return FetchResult( + latencies=tuple(latencies), + compressed_bytes=compressed_bytes, + n_attempted=n_fetch, + n_timeouts=n_timeouts, + n_errors=n_errors, + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3b180e1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,36 @@ +"""Shared pytest fixtures for oz-viewer tests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import pytest + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +@pytest.fixture +def write_demo_ome(tmp_path: Path) -> Callable[[Literal["image", "plate"]], Path]: + """Return a factory that writes demo OME-Zarr stores to tmp_path. + + Skips the test if zarr or ome-zarr are not installed. + """ + try: + from yaozarrs._demo_data import write_ome_image, write_ome_plate + except ImportError: + pytest.skip("zarr and ome-zarr are required for demo data fixtures") + + def _factory(store_type: Literal["image", "plate"] = "image") -> Path: + if store_type == "image": + path = tmp_path / "demo_image.zarr" + write_ome_image(path) + elif store_type == "plate": + path = tmp_path / "demo_plate.zarr" + write_ome_plate(path) + else: + raise ValueError(f"Unknown store_type: {store_type!r}") + return path + + return _factory diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..6aa9782 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,119 @@ +"""Tests for oz-viewer CLI commands.""" + +from __future__ import annotations + +import shutil +from typing import TYPE_CHECKING + +from typer.testing import CliRunner + +if TYPE_CHECKING: + from pathlib import Path + +from oz_viewer._cli import app + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Stage 1 — validate command +# --------------------------------------------------------------------------- + + +def test_validate_valid_image(write_demo_ome): + path = write_demo_ome("image") + result = runner.invoke(app, ["validate", str(path)]) + assert result.exit_code == 0 + assert "✓ Valid OME-Zarr" in result.output + + +def test_validate_valid_plate(write_demo_ome): + path = write_demo_ome("plate") + result = runner.invoke(app, ["validate", str(path)]) + assert result.exit_code == 0 + assert "Plate" in result.output + + +def test_validate_verbose_image(write_demo_ome): + path = write_demo_ome("image") + result = runner.invoke(app, ["validate", "--verbose", str(path)]) + assert result.exit_code == 0 + assert "Image(" in result.output + + +def test_validate_verbose_plate(write_demo_ome): + path = write_demo_ome("plate") + result = runner.invoke(app, ["validate", "--verbose", str(path)]) + assert result.exit_code == 0 + assert "Plate(" in result.output + assert "PlateDef(" in result.output + + +def _break_store(path: Path) -> None: + """Remove the first level subdirectory to break a demo OME-Zarr store.""" + from yaozarrs import validate_zarr_store + + group = validate_zarr_store(str(path)) + meta = group.ome_metadata() + level_path = meta.multiscales[0].datasets[-1].path + shutil.rmtree(path / level_path) + + +def test_validate_invalid_store(write_demo_ome): + path = write_demo_ome("image") + _break_store(path) + result = runner.invoke(app, ["validate", str(path)]) + assert result.exit_code == 1 + assert "✗ Validation failed" in result.output + + +def test_validate_nonexistent_store(tmp_path): + path = tmp_path / "nonexistent.zarr" + result = runner.invoke(app, ["validate", str(path)]) + assert result.exit_code == 2 + + +def test_validate_verbose_does_not_run_on_failure(write_demo_ome): + path = write_demo_ome("image") + _break_store(path) + result = runner.invoke(app, ["validate", "--verbose", str(path)]) + assert result.exit_code == 1 + assert "Metadata Model" not in result.output + + +def test_no_args_shows_help(): + result = runner.invoke(app, []) + # typer exits with 2 for no-args help; just check output content + assert "validate" in result.output + + +# --------------------------------------------------------------------------- +# Stage 2 — ping command +# --------------------------------------------------------------------------- + + +def test_ping_local_store(write_demo_ome): + path = write_demo_ome("image") + result = runner.invoke(app, ["ping", str(path)]) + assert result.exit_code == 0 + assert "✓ Complete" in result.output + + +def test_ping_default_n_fetch(write_demo_ome): + path = write_demo_ome("image") + result = runner.invoke(app, ["ping", str(path)]) + assert result.exit_code == 0 + assert "5" in result.output + + +def test_ping_timeout(write_demo_ome): + path = write_demo_ome("image") + result = runner.invoke(app, ["ping", str(path), "--timeout", "0"]) + assert result.exit_code == 0 + assert "Timeouts" in result.output + + +def test_ping_invalid_store(tmp_path): + path = tmp_path / "nonexistent.zarr" + result = runner.invoke(app, ["ping", str(path)]) + assert result.exit_code == 2 From 0a1e41ebc5adf0c46a67088554fb42e76781a071 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 27 Apr 2026 08:22:29 +0200 Subject: [PATCH 2/6] add orthoviewer --- pyproject.toml | 4 + src/oz_viewer/_cli.py | 68 ++ src/oz_viewer/data/__init__.py | 1 + src/oz_viewer/data/_blobs.py | 143 +++ src/oz_viewer/viewer/__init__.py | 15 + src/oz_viewer/viewer/_orthoviewer.py | 1422 ++++++++++++++++++++++++++ 6 files changed, 1653 insertions(+) create mode 100644 src/oz_viewer/data/__init__.py create mode 100644 src/oz_viewer/data/_blobs.py create mode 100644 src/oz_viewer/viewer/__init__.py create mode 100644 src/oz_viewer/viewer/_orthoviewer.py diff --git a/pyproject.toml b/pyproject.toml index d6fac02..8ae082f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,8 @@ dependencies = [ "typer >= 0.12", "rich >= 13", "numpy >= 1.24", + "cellier[pyside]>=0.0.13", + "jupyterlab>=4.5.6", ] # https://peps.python.org/pep-0621/#dependencies-optional-dependencies @@ -148,3 +150,5 @@ extend-ignore-identifiers-re = [] # OME (Open Microscopy Environment) is a domain term, not a typo for "SOME" OME = "OME" ome = "ome" +# lod = Level of Detail (graphics term), not a typo for "load" +lod = "lod" diff --git a/src/oz_viewer/_cli.py b/src/oz_viewer/_cli.py index 1f4cc33..ec9b1ed 100644 --- a/src/oz_viewer/_cli.py +++ b/src/oz_viewer/_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +from pathlib import Path from typing import Annotated import typer @@ -101,5 +102,72 @@ def ping( print_ping_results(chunk_info, result, console) +def _resolve_zarr_uri(path: str) -> str: + """Convert a local path to a file:// URI; pass remote URIs through unchanged.""" + if "://" in path: + return path + local = Path(path) + if not local.exists(): + typer.echo(f"Error: OME-Zarr store not found at '{local}'", err=True) + raise typer.Exit(code=1) + return f"file://{local.resolve()}" + + +@app.command() +def ortho( + path: Annotated[ + str | None, + typer.Argument( + help="Path or URI to the OME-Zarr store (local path, s3://, gs://, https://).", + show_default=False, + ), + ] = None, + path_option: Annotated[ + str | None, + typer.Option( + "--path", + help=( + "Path or URI to the OME-Zarr store" + " (alternative to positional argument)." + ), + show_default=False, + ), + ] = None, + make_example: Annotated[ + bool, + typer.Option( + "--make-example", + help="Create a synthetic anisotropic OME-Zarr and open it in the viewer.", + ), + ] = False, +) -> None: + """Open an OME-Zarr store in the 4-panel orthoviewer.""" + from oz_viewer.viewer import launch_orthoviewer + + if make_example: + from oz_viewer.data._blobs import make_example_zarr + + zarr_path = make_example_zarr() + zarr_uri = f"file://{zarr_path}" + else: + raw = path or path_option + if raw is None: + typer.echo( + "Error: provide a path as a positional argument, via --path, " + "or use --make-example.", + err=True, + ) + raise typer.Exit(code=1) + if path is not None and path_option is not None: + typer.echo( + "Error: provide the path as a positional argument or --path, not both.", + err=True, + ) + raise typer.Exit(code=1) + zarr_uri = _resolve_zarr_uri(raw) + + launch_orthoviewer(zarr_uri) + + if __name__ == "__main__": app() diff --git a/src/oz_viewer/data/__init__.py b/src/oz_viewer/data/__init__.py new file mode 100644 index 0000000..b408669 --- /dev/null +++ b/src/oz_viewer/data/__init__.py @@ -0,0 +1 @@ +"""Example data for oz-viewer.""" diff --git a/src/oz_viewer/data/_blobs.py b/src/oz_viewer/data/_blobs.py new file mode 100644 index 0000000..55e9fd3 --- /dev/null +++ b/src/oz_viewer/data/_blobs.py @@ -0,0 +1,143 @@ +"""Synthetic anisotropic OME-Zarr example dataset (ExpA-like scale).""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np + +# ExpA: Z=5.0 µm, Y=X=6.55 µm; only Y/X are downsampled per level (Z fixed). +_SCALE_Z = 5.0 +_SCALE_YX = 6.550032422660492 +_SHAPE_ZYX = (200, 300, 300) +_N_LEVELS = 4 +_N_BLOBS = 12 +_BLOB_RADIUS_UM = 120.0 +_CHUNK_ZYX = (32, 32, 32) + +_DEFAULT_PATH = Path(__file__).parent / "example_anisotropic_blobs.ome.zarr" + + +def _make_blob_volume( + shape_zyx: tuple[int, int, int], + spacing_zyx: tuple[float, float, float], + n_blobs: int, + radius_um: float, + seed: int = 42, +) -> np.ndarray: + """Return a uint8 volume with blobs that are spherical in physical space.""" + nz, ny, nx = shape_zyx + sz, sy, sx = spacing_zyx + rng = np.random.default_rng(seed) + volume = np.zeros(shape_zyx, dtype=np.uint8) + + rz = int(np.ceil(radius_um / sz)) + ry = int(np.ceil(radius_um / sy)) + rx = int(np.ceil(radius_um / sx)) + + for _ in range(n_blobs): + cz = rng.integers(rz, nz - rz) + cy = rng.integers(ry, ny - ry) + cx = rng.integers(rx, nx - rx) + + lz = np.arange(-rz, rz + 1) + ly = np.arange(-ry, ry + 1) + lx = np.arange(-rx, rx + 1) + ZZ, YY, XX = np.meshgrid(lz, ly, lx, indexing="ij") + mask = (ZZ * sz) ** 2 + (YY * sy) ** 2 + (XX * sx) ** 2 <= radius_um**2 + + z0, z1 = max(0, cz - rz), min(nz, cz + rz + 1) + y0, y1 = max(0, cy - ry), min(ny, cy + ry + 1) + x0, x1 = max(0, cx - rx), min(nx, cx + rx + 1) + + mz0 = max(0, -(cz - rz)) + my0 = max(0, -(cy - ry)) + mx0 = max(0, -(cx - rx)) + mz1 = mz0 + (z1 - z0) + my1 = my0 + (y1 - y0) + mx1 = mx0 + (x1 - x0) + + volume[z0:z1, y0:y1, x0:x1] |= mask[mz0:mz1, my0:my1, mx0:mx1].astype(np.uint8) + + return volume + + +def make_example_zarr(output_path: Path = _DEFAULT_PATH) -> Path: + """Create a synthetic anisotropic OME-Zarr with spherical blobs. + + Uses the same Z/YX scale ratio as ExpA (5.0 : 6.55 µm). Only Y and X are + downsampled per level; Z stays fixed. Blobs appear as perfect spheres in + all slice planes when the viewer applies correct coordinate transforms. + + Parameters + ---------- + output_path : Path + Directory to write the OME-Zarr store. Created if absent. + + Returns + ------- + Path + Resolved path to the written store. + """ + import zarr + + output_path = Path(output_path) + if output_path.exists(): + print(f"Example dataset already exists at {output_path}") + return output_path.resolve() + + print(f"Creating example dataset at {output_path} ...") + sz, syx = _SCALE_Z, _SCALE_YX + nz, ny, nx = _SHAPE_ZYX + + data_l0 = _make_blob_volume(_SHAPE_ZYX, (sz, syx, syx), _N_BLOBS, _BLOB_RADIUS_UM) + + root = zarr.open_group(str(output_path), mode="w") + datasets_meta = [] + + for level in range(_N_LEVELS): + factor = 2**level + data = data_l0 if level == 0 else data_l0[:, ::factor, ::factor] + + arr = root.create_array( + f"s{level}", + shape=data.shape, + chunks=_CHUNK_ZYX, + dtype=np.uint8, + ) + arr[:] = data + + datasets_meta.append( + { + "path": f"s{level}", + "coordinateTransformations": [ + {"type": "scale", "scale": [sz, syx * factor, syx * factor]}, + ], + } + ) + print( + f" Level {level}: shape={data.shape} " + f"scale=(z={sz}, yx={syx * factor:.4f})" + ) + + root.attrs["ome"] = { + "version": "0.5", + "multiscales": [ + { + "axes": [ + {"name": "z", "type": "space", "unit": "micrometer"}, + {"name": "y", "type": "space", "unit": "micrometer"}, + {"name": "x", "type": "space", "unit": "micrometer"}, + ], + "datasets": datasets_meta, + "name": "blobs", + } + ], + } + + print( + f"Done. Physical size: " + f"z={nz * sz:.0f} µm, y={ny * syx:.0f} µm, x={nx * syx:.0f} µm" + ) + print(f"Blob radius: {_BLOB_RADIUS_UM} µm (spherical in world space)") + return output_path.resolve() diff --git a/src/oz_viewer/viewer/__init__.py b/src/oz_viewer/viewer/__init__.py new file mode 100644 index 0000000..f40b39f --- /dev/null +++ b/src/oz_viewer/viewer/__init__.py @@ -0,0 +1,15 @@ +"""Viewer modules for oz-viewer.""" + +from oz_viewer.viewer._orthoviewer import ( + OmeZarrOrthoViewer, + build_ortho_viewer_model, + launch_orthoviewer, + orthoviewer, +) + +__all__ = [ + "OmeZarrOrthoViewer", + "build_ortho_viewer_model", + "launch_orthoviewer", + "orthoviewer", +] diff --git a/src/oz_viewer/viewer/_orthoviewer.py b/src/oz_viewer/viewer/_orthoviewer.py new file mode 100644 index 0000000..19d0f79 --- /dev/null +++ b/src/oz_viewer/viewer/_orthoviewer.py @@ -0,0 +1,1422 @@ +"""OME-Zarr orthoviewer: 4-panel viewer (XY, XZ, YZ, 3D).""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import numpy as np + +# --------------------------------------------------------------------------- +# Slider color styles for each 2D panel +# --------------------------------------------------------------------------- + + +def _make_slider_style(color_a: str, color_b: str) -> str: + return f""" +QSlider::groove:horizontal {{ + border: 1px solid #bbb; + background: white; + height: 10px; + border-radius: 4px; +}} +QSlider::handle:horizontal {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #eee, stop:1 #ccc); + border: 1px solid #777; + width: 13px; + margin-top: -7px; + margin-bottom: -7px; + border-radius: 4px; +}} +QSlider::add-page:horizontal {{ + background: #fff; + border: 1px solid #777; + height: 10px; + border-radius: 4px; +}} +QSlider::sub-page:horizontal {{ + background: qlineargradient(x1:0, y1:0.2, x2:1, y2:1, + stop:0 {color_a}, stop:1 {color_b}); + border: 1px solid #777; + height: 10px; + border-radius: 4px; +}} +QSlider::handle:horizontal:hover {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #fff, stop:1 #ddd); + border: 1px solid #444; + border-radius: 4px; +}} +QLabel {{ font-size: 12px; }} +""" + + +_SLIDER_STYLE_XY = _make_slider_style("#bbf", "#55f") +_SLIDER_STYLE_XZ = _make_slider_style("#bfb", "#3a3") +_SLIDER_STYLE_YZ = _make_slider_style("#fdb", "#c60") + +# Colors matching the slider gradients. +_PLANE_COLOR_XY = (0.33, 0.33, 1.00) # blue +_PLANE_COLOR_XZ = (0.23, 0.67, 0.23) # green +_PLANE_COLOR_YZ = (0.80, 0.40, 0.00) # orange + +_AXIS_FRACTION: float = 0.15 +_AXIS_3D_LENGTH_FRACTION: float = 0.12 +_AXIS_3D_CUBE_SIDE_FRACTION: float = 0.024 +_AXIS_3D_PRISM_CROSS_SECTION_FRACTION: float = 0.020 +_AXIS_3D_CUBE_COLOUR: tuple[float, float, float, float] = (0.75, 0.75, 0.75, 1.0) +_N_FACES_PER_BOX: int = 12 + + +# --------------------------------------------------------------------------- +# Multi-visual control helpers +# --------------------------------------------------------------------------- + + +class _MultiVisualClimSlider: + """Contrast-limits range slider that updates multiple visuals at once.""" + + from psygnal import Signal + + changed = Signal(object) + closed = Signal() + + def __init__( + self, + visual_ids: list, + *, + clim_range: tuple[float, float], + initial_clim: tuple[float, float], + decimals: int = 2, + parent=None, + ) -> None: + from cellier.v2.events import AppearanceUpdateEvent + from qtpy.QtCore import Qt + from superqt import QLabeledDoubleRangeSlider + + self._id = uuid4() + self._visual_ids = visual_ids + self._AppearanceUpdateEvent = AppearanceUpdateEvent + + self._slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal, parent) + self._slider.setRange(*clim_range) + self._slider.setValue(initial_clim) + self._slider.setDecimals(decimals) + self._slider.valueChanged.connect(self._on_changed) + + def _on_changed(self, value: tuple[float, float]) -> None: + for vid in self._visual_ids: + self.changed.emit( + self._AppearanceUpdateEvent( + source_id=self._id, + visual_id=vid, + field="clim", + value=value, + ) + ) + + @property + def widget(self): + return self._slider + + def close(self) -> None: + self.closed.emit() + + +class _MultiVisualColormapCombo: + """Colormap combo box that updates multiple visuals at once.""" + + from psygnal import Signal + + changed = Signal(object) + closed = Signal() + + def __init__( + self, + visual_ids: list, + *, + initial_colormap, + parent=None, + ) -> None: + from cellier.v2.events import AppearanceUpdateEvent + from superqt import QColormapComboBox + + self._id = uuid4() + self._visual_ids = visual_ids + self._AppearanceUpdateEvent = AppearanceUpdateEvent + + self._combo = QColormapComboBox(parent) + self._combo.setCurrentColormap(initial_colormap) + self._combo.currentColormapChanged.connect(self._on_changed) + + def _on_changed(self, colormap) -> None: + for vid in self._visual_ids: + self.changed.emit( + self._AppearanceUpdateEvent( + source_id=self._id, + visual_id=vid, + field="color_map", + value=colormap, + ) + ) + + @property + def widget(self): + return self._combo + + def close(self) -> None: + self.closed.emit() + + +# --------------------------------------------------------------------------- +# Main viewer class +# --------------------------------------------------------------------------- + + +class OmeZarrOrthoViewer: + """4-panel orthoviewer window: XY, XZ, YZ slices and a 3D volume.""" + + def __init__( + self, + controller, + scenes: dict, + visuals: dict, + canvas_widgets: dict, + clim_range: tuple[float, float], + slider_decimals: int = 2, + plane_visual=None, + plane_store=None, + gfx_vol_visual=None, + initial_plane_opacity: float = 0.4, + axes_2d_overlay_ids: list | None = None, + orient_3d_visual_ids: list | None = None, + ): + from cellier.v2.gui.visuals._colormap import QtColormapComboBox + from cellier.v2.gui.visuals._contrast_limits import QtClimRangeSlider + from cellier.v2.gui.visuals._image import QtVolumeRenderControls + from PySide6 import QtCore, QtWidgets + + self._controller = controller + self._scenes = scenes + self._visuals = visuals + self._canvas_widgets = canvas_widgets + + xy_id = visuals["xy"].id + xz_id = visuals["xz"].id + yz_id = visuals["yz"].id + vol_id = visuals["vol"].id + + self._2d_clim = _MultiVisualClimSlider( + [xy_id, xz_id, yz_id], + clim_range=clim_range, + initial_clim=visuals["xy"].appearance.clim, + decimals=slider_decimals, + ) + controller.connect_widget(self._2d_clim) + self._2d_colormap = _MultiVisualColormapCombo( + [xy_id, xz_id, yz_id], + initial_colormap=visuals["xy"].appearance.color_map, + ) + controller.connect_widget(self._2d_colormap) + + self._3d_clim = QtClimRangeSlider( + vol_id, + clim_range=clim_range, + initial_clim=visuals["vol"].appearance.clim, + decimals=slider_decimals, + ) + controller.connect_widget( + self._3d_clim, subscription_specs=self._3d_clim.subscription_specs() + ) + self._3d_colormap = QtColormapComboBox( + vol_id, + initial_colormap=visuals["vol"].appearance.color_map, + ) + controller.connect_widget( + self._3d_colormap, + subscription_specs=self._3d_colormap.subscription_specs(), + ) + self._3d_render = QtVolumeRenderControls( + vol_id, + dtype_max=clim_range[1], + initial_render_mode=visuals["vol"].appearance.render_mode, + initial_threshold=visuals["vol"].appearance.iso_threshold, + decimals=slider_decimals, + ) + controller.connect_widget( + self._3d_render, subscription_specs=self._3d_render.subscription_specs() + ) + + self._window = QtWidgets.QMainWindow() + self._window.setWindowTitle("OME-Zarr Orthoviewer") + self._window.resize(1400, 900) + + central = QtWidgets.QWidget() + self._window.setCentralWidget(central) + root_layout = QtWidgets.QHBoxLayout(central) + + grid_widget = QtWidgets.QWidget() + grid = QtWidgets.QGridLayout(grid_widget) + grid.setSpacing(4) + grid.setContentsMargins(0, 0, 0, 0) + + panels = [ + (0, 0, "xy", "XY (slice Z)"), + (0, 1, "xz", "XZ (slice Y)"), + (1, 0, "yz", "YZ (slice X)"), + (1, 1, "vol", "3D Volume"), + ] + for row, col, key, label in panels: + cell = QtWidgets.QWidget() + cell_layout = QtWidgets.QVBoxLayout(cell) + cell_layout.setContentsMargins(0, 0, 0, 0) + cell_layout.setSpacing(0) + + lbl = QtWidgets.QLabel(label) + lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + lbl.setStyleSheet("font-weight: bold; font-size: 11px; padding: 2px;") + cell_layout.addWidget(lbl) + cell_layout.addWidget(canvas_widgets[key].widget, stretch=1) + grid.addWidget(cell, row, col) + + root_layout.addWidget(grid_widget, stretch=1) + + panel = QtWidgets.QWidget() + panel.setFixedWidth(300) + panel_layout = QtWidgets.QVBoxLayout(panel) + panel_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + root_layout.addWidget(panel) + + group_2d = QtWidgets.QGroupBox("2D Rendering") + layout_2d = QtWidgets.QVBoxLayout(group_2d) + + clim_2d_box = QtWidgets.QGroupBox("Contrast limits") + QtWidgets.QVBoxLayout(clim_2d_box).addWidget(self._2d_clim.widget) + layout_2d.addWidget(clim_2d_box) + + cmap_2d_box = QtWidgets.QGroupBox("Colormap") + QtWidgets.QVBoxLayout(cmap_2d_box).addWidget(self._2d_colormap.widget) + layout_2d.addWidget(cmap_2d_box) + + if axes_2d_overlay_ids: + from PySide6.QtWidgets import QCheckBox + + axes_2d_cb = QCheckBox("Show orientation axes") + axes_2d_cb.setChecked(True) + + def _on_axes_2d_toggled(checked: bool) -> None: + for oid in axes_2d_overlay_ids: + controller.set_overlay_visible(oid, checked) + + axes_2d_cb.toggled.connect(_on_axes_2d_toggled) + layout_2d.addWidget(axes_2d_cb) + + panel_layout.addWidget(group_2d) + + group_3d = QtWidgets.QGroupBox("3D Rendering") + layout_3d = QtWidgets.QVBoxLayout(group_3d) + + clim_3d_box = QtWidgets.QGroupBox("Contrast limits") + QtWidgets.QVBoxLayout(clim_3d_box).addWidget(self._3d_clim.widget) + layout_3d.addWidget(clim_3d_box) + + cmap_3d_box = QtWidgets.QGroupBox("Colormap") + QtWidgets.QVBoxLayout(cmap_3d_box).addWidget(self._3d_colormap.widget) + layout_3d.addWidget(cmap_3d_box) + + render_3d_box = QtWidgets.QGroupBox("Render mode") + QtWidgets.QVBoxLayout(render_3d_box).addWidget(self._3d_render.widget) + layout_3d.addWidget(render_3d_box) + + if orient_3d_visual_ids: + from cellier.v2.events import ( + AppearanceUpdateEvent as _AppearanceUpdateEvent, + ) + from PySide6.QtWidgets import QCheckBox + + orient_3d_cb = QCheckBox("Show orientation axes") + orient_3d_cb.setChecked(True) + _orient_3d_bid = uuid4() + + def _on_orient_3d_toggled(checked: bool) -> None: + for vid in orient_3d_visual_ids: + controller.incoming_events.emit( + _AppearanceUpdateEvent( + source_id=_orient_3d_bid, + visual_id=vid, + field="visible", + value=checked, + ) + ) + + orient_3d_cb.toggled.connect(_on_orient_3d_toggled) + layout_3d.addWidget(orient_3d_cb) + + panel_layout.addWidget(group_3d) + + if plane_visual is not None or gfx_vol_visual is not None: + from PySide6.QtCore import Qt + from superqt import QLabeledDoubleSlider + + group_planes = QtWidgets.QGroupBox("Plane Overlay") + layout_planes = QtWidgets.QVBoxLayout(group_planes) + + vol_opacity_label = QtWidgets.QLabel("Volume opacity") + vol_opacity_slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) + vol_opacity_slider.setRange(0.0, 1.0) + vol_opacity_slider.setValue(1.0) + vol_opacity_slider.setDecimals(2) + + def _on_vol_opacity_changed(value: float) -> None: + if ( + gfx_vol_visual is not None + and gfx_vol_visual.material_3d is not None + ): + gfx_vol_visual.material_3d.opacity = value + + vol_opacity_slider.valueChanged.connect(_on_vol_opacity_changed) + layout_planes.addWidget(vol_opacity_label) + layout_planes.addWidget(vol_opacity_slider) + + plane_opacity_label = QtWidgets.QLabel("Plane opacity") + plane_opacity_slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) + plane_opacity_slider.setRange(0.0, 1.0) + plane_opacity_slider.setValue(initial_plane_opacity) + plane_opacity_slider.setDecimals(2) + + def _on_plane_opacity_changed(value: float) -> None: + if plane_visual is not None: + controller.update_appearance_field( + plane_visual.id, "opacity", value + ) + if plane_store is not None: + plane_store.colors = _make_plane_colors(value) + controller.reslice_visual(plane_visual.id) + + plane_opacity_slider.valueChanged.connect(_on_plane_opacity_changed) + layout_planes.addWidget(plane_opacity_label) + layout_planes.addWidget(plane_opacity_slider) + + _on_plane_opacity_changed(initial_plane_opacity) + + panel_layout.addWidget(group_planes) + + panel_layout.addStretch() + + @property + def window(self): + return self._window + + def close_widgets(self) -> None: + for cw in self._canvas_widgets.values(): + cw.close() + self._2d_clim.close() + self._2d_colormap.close() + self._3d_clim.close() + self._3d_colormap.close() + self._3d_render.close() + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +def _box_faces_geometry( + centre_zyx: np.ndarray, + half_extents_zyx: np.ndarray, + vertex_offset: int, +) -> tuple[np.ndarray, np.ndarray]: + cz, cy, cx = float(centre_zyx[0]), float(centre_zyx[1]), float(centre_zyx[2]) + hz, hy, hx = ( + float(half_extents_zyx[0]), + float(half_extents_zyx[1]), + float(half_extents_zyx[2]), + ) + z0, z1 = cz - hz, cz + hz + y0, y1 = cy - hy, cy + hy + x0, x1 = cx - hx, cx + hx + + positions = np.array( + [ + [z0, y0, x0], + [z0, y1, x0], + [z0, y1, x1], + [z0, y0, x1], # Face 0: -Z + [z1, y0, x0], + [z1, y0, x1], + [z1, y1, x1], + [z1, y1, x0], # Face 1: +Z + [z0, y0, x0], + [z0, y0, x1], + [z1, y0, x1], + [z1, y0, x0], # Face 2: -Y + [z0, y1, x0], + [z1, y1, x0], + [z1, y1, x1], + [z0, y1, x1], # Face 3: +Y + [z0, y0, x0], + [z1, y0, x0], + [z1, y1, x0], + [z0, y1, x0], # Face 4: -X + [z0, y0, x1], + [z0, y1, x1], + [z1, y1, x1], + [z1, y0, x1], # Face 5: +X + ], + dtype=np.float32, + ) + + base_indices = np.array( + [ + [0, 1, 2], + [0, 2, 3], + [4, 5, 6], + [4, 6, 7], + [8, 9, 10], + [8, 10, 11], + [12, 13, 14], + [12, 14, 15], + [16, 17, 18], + [16, 18, 19], + [20, 21, 22], + [20, 22, 23], + ], + dtype=np.int32, + ) + return positions, base_indices + vertex_offset + + +def _make_axis_set_geometry( + axis_a: int, + axis_b: int, + axis_length: float, + cube_side: float, + prism_cross_section: float, +) -> tuple[np.ndarray, np.ndarray]: + half_cube = cube_side / 2.0 + half_length = axis_length / 2.0 + half_cross = prism_cross_section / 2.0 + + origin = np.zeros(3, dtype=np.float64) + cube_half_extents = np.full(3, half_cube, dtype=np.float64) + cube_positions, cube_indices = _box_faces_geometry(origin, cube_half_extents, 0) + + centre_a = np.zeros(3, dtype=np.float64) + centre_a[axis_a] = half_cube + half_length + half_extents_a = np.full(3, half_cross, dtype=np.float64) + half_extents_a[axis_a] = half_length + prism_a_positions, prism_a_indices = _box_faces_geometry( + centre_a, half_extents_a, 24 + ) + + centre_b = np.zeros(3, dtype=np.float64) + centre_b[axis_b] = half_cube + half_length + half_extents_b = np.full(3, half_cross, dtype=np.float64) + half_extents_b[axis_b] = half_length + prism_b_positions, prism_b_indices = _box_faces_geometry( + centre_b, half_extents_b, 48 + ) + + positions = np.concatenate([cube_positions, prism_a_positions, prism_b_positions]) + indices = np.concatenate([cube_indices, prism_a_indices, prism_b_indices]) + return positions, indices + + +def _make_axis_set_face_colors( + axis_a_color_rgb: tuple[float, float, float], + axis_b_color_rgb: tuple[float, float, float], +) -> np.ndarray: + cube_color = np.array(_AXIS_3D_CUBE_COLOUR, dtype=np.float32) + color_a = np.array([*axis_a_color_rgb, 1.0], dtype=np.float32) + color_b = np.array([*axis_b_color_rgb, 1.0], dtype=np.float32) + return np.concatenate( + [ + np.tile(cube_color, (_N_FACES_PER_BOX, 1)), + np.tile(color_a, (_N_FACES_PER_BOX, 1)), + np.tile(color_b, (_N_FACES_PER_BOX, 1)), + ] + ) + + +def _make_axis_meshes( + controller, + vol_scene, + initial_centre_zyx: np.ndarray, + world_min_extent: float, +) -> tuple: + from cellier.v2.data.mesh._mesh_memory_store import MeshMemoryStore + from cellier.v2.transform import AffineTransform + from cellier.v2.visuals._mesh_memory import MeshFlatAppearance + + color_z = _PLANE_COLOR_XY + color_y = _PLANE_COLOR_XZ + color_x = _PLANE_COLOR_YZ + + view_specifications = [ + ("xy_axis_set", 1, 2, color_y, color_x), + ("xz_axis_set", 0, 2, color_z, color_x), + ("yz_axis_set", 0, 1, color_z, color_y), + ] + + axis_length = _AXIS_3D_LENGTH_FRACTION * world_min_extent + cube_side = _AXIS_3D_CUBE_SIDE_FRACTION * world_min_extent + prism_cross_section = _AXIS_3D_PRISM_CROSS_SECTION_FRACTION * world_min_extent + + initial_translation = tuple(float(v) for v in initial_centre_zyx) + initial_transform = AffineTransform.from_translation(initial_translation) + + axis_visuals = [] + for view_name, axis_a, axis_b, color_a, color_b in view_specifications: + positions, indices = _make_axis_set_geometry( + axis_a, axis_b, axis_length, cube_side, prism_cross_section + ) + face_colors = _make_axis_set_face_colors(color_a, color_b) + store = MeshMemoryStore( + positions=positions, indices=indices, colors=face_colors, name=view_name + ) + appearance = MeshFlatAppearance( + color_mode="face", + side="both", + opacity=1.0, + render_order=1, + depth_test=False, + depth_write=False, + depth_compare="<=", + ) + visual = controller.add_mesh( + data=store, + scene_id=vol_scene.id, + appearance=appearance, + name=view_name, + transform=initial_transform, + ) + axis_visuals.append(visual) + + return tuple(axis_visuals) + + +def _make_plane_positions( + z_world: float, + y_world: float, + x_world: float, + world_max_zyx: np.ndarray, +) -> np.ndarray: + wz, wy, wx = ( + float(world_max_zyx[0]), + float(world_max_zyx[1]), + float(world_max_zyx[2]), + ) + z, y, x = float(z_world), float(y_world), float(x_world) + return np.array( + [ + [z, 0, 0], + [z, wy, 0], + [z, wy, wx], + [z, 0, wx], # XY plane + [0, y, 0], + [wz, y, 0], + [wz, y, wx], + [0, y, wx], # XZ plane + [0, 0, x], + [wz, 0, x], + [wz, wy, x], + [0, wy, x], # YZ plane + ], + dtype=np.float32, + ) + + +def _make_plane_colors(opacity: float) -> np.ndarray: + a = float(opacity) + return np.array( + [ + [*_PLANE_COLOR_XY, a], + [*_PLANE_COLOR_XY, a], + [*_PLANE_COLOR_XZ, a], + [*_PLANE_COLOR_XZ, a], + [*_PLANE_COLOR_YZ, a], + [*_PLANE_COLOR_YZ, a], + ], + dtype=np.float32, + ) + + +def _make_plane_mesh( + controller, + vol_scene, + z_world: float, + y_world: float, + x_world: float, + world_max_zyx: np.ndarray, + initial_opacity: float = 0.4, +): + from cellier.v2.data.mesh._mesh_memory_store import MeshMemoryStore + from cellier.v2.visuals._mesh_memory import MeshFlatAppearance + + positions = _make_plane_positions(z_world, y_world, x_world, world_max_zyx) + colors = _make_plane_colors(initial_opacity) + indices = np.array( + [[0, 1, 2], [0, 2, 3], [4, 5, 6], [4, 6, 7], [8, 9, 10], [8, 10, 11]], + dtype=np.int32, + ) + + store = MeshMemoryStore( + positions=positions, indices=indices, colors=colors, name="slice_planes" + ) + appearance = MeshFlatAppearance( + color_mode="face", side="both", opacity=initial_opacity, wireframe=False + ) + visual = controller.add_mesh( + data=store, scene_id=vol_scene.id, appearance=appearance, name="slice_planes" + ) + return store, visual + + +class _PlaneUpdater: + def __init__(self, controller, plane_store, plane_visual, world_max_zyx) -> None: + self._id = uuid4() + self._controller = controller + self._plane_store = plane_store + self._plane_visual = plane_visual + self._world_max_zyx = world_max_zyx + + positions = plane_store.positions + self._z_world = float(positions[0, 0]) + self._y_world = float(positions[4, 1]) + self._x_world = float(positions[8, 2]) + + def _update(self) -> None: + self._plane_store.positions = _make_plane_positions( + self._z_world, self._y_world, self._x_world, self._world_max_zyx + ) + self._controller.reslice_visual(self._plane_visual.id) + + def on_xy_dims_changed(self, event) -> None: + slice_indices = event.dims_state.selection.slice_indices + if 0 in slice_indices: + self._z_world = float(slice_indices[0]) + self._update() + + def on_xz_dims_changed(self, event) -> None: + slice_indices = event.dims_state.selection.slice_indices + if 1 in slice_indices: + self._y_world = float(slice_indices[1]) + self._update() + + def on_yz_dims_changed(self, event) -> None: + slice_indices = event.dims_state.selection.slice_indices + if 2 in slice_indices: + self._x_world = float(slice_indices[2]) + self._update() + + +class _OrientationUpdater: + def __init__( + self, + controller, + xy_axis_visual, + xz_axis_visual, + yz_axis_visual, + world_max_zyx: np.ndarray, + ): + self._id = uuid4() + self._controller = controller + self._xy_axis_visual_id = xy_axis_visual.id + self._xz_axis_visual_id = xz_axis_visual.id + self._yz_axis_visual_id = yz_axis_visual.id + + mid = world_max_zyx / 2.0 + self._xy_centre_zyx = mid.copy() + self._xz_centre_zyx = mid.copy() + self._yz_centre_zyx = mid.copy() + self._z_world = float(mid[0]) + self._y_world = float(mid[1]) + self._x_world = float(mid[2]) + + def _update_3d(self) -> None: + from cellier.v2.transform import AffineTransform + + for visual_id, centre_zyx in zip( + (self._xy_axis_visual_id, self._xz_axis_visual_id, self._yz_axis_visual_id), + (self._xy_centre_zyx, self._xz_centre_zyx, self._yz_centre_zyx), + strict=False, + ): + self._controller.set_visual_transform( + visual_id, + AffineTransform.from_translation(tuple(float(v) for v in centre_zyx)), + reslice=False, + ) + + def on_xy_camera_changed(self, event) -> None: + p = event.camera_state.position + self._xy_centre_zyx = np.array([self._z_world, p[1], p[0]], dtype=np.float64) + self._update_3d() + + def on_xz_camera_changed(self, event) -> None: + p = event.camera_state.position + self._xz_centre_zyx = np.array([p[1], self._y_world, p[0]], dtype=np.float64) + self._update_3d() + + def on_yz_camera_changed(self, event) -> None: + p = event.camera_state.position + self._yz_centre_zyx = np.array([p[1], p[0], self._x_world], dtype=np.float64) + self._update_3d() + + def on_xy_dims_changed(self, event) -> None: + slice_indices = event.dims_state.selection.slice_indices + if 0 in slice_indices: + self._z_world = float(slice_indices[0]) + self._xy_centre_zyx[0] = self._z_world + self._update_3d() + + def on_xz_dims_changed(self, event) -> None: + slice_indices = event.dims_state.selection.slice_indices + if 1 in slice_indices: + self._y_world = float(slice_indices[1]) + self._xz_centre_zyx[1] = self._y_world + self._update_3d() + + def on_yz_dims_changed(self, event) -> None: + slice_indices = event.dims_state.selection.slice_indices + if 2 in slice_indices: + self._x_world = float(slice_indices[2]) + self._yz_centre_zyx[2] = self._x_world + self._update_3d() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _dtype_clim_max(dtype: np.dtype) -> float: + if np.issubdtype(dtype, np.integer): + return float(np.iinfo(dtype).max) + return 1.0 + + +def _dtype_decimals(dtype: np.dtype) -> int: + return 0 if np.issubdtype(dtype, np.integer) else 2 + + +# --------------------------------------------------------------------------- +# Layer 1: ViewerModel builder +# --------------------------------------------------------------------------- + + +def build_ortho_viewer_model(zarr_uri: str): + """Build a ViewerModel for the orthoviewer without constructing any Qt objects. + + Parameters + ---------- + zarr_uri : str + Path or URI to the OME-Zarr store. + + Returns + ------- + cellier.v2.viewer_model.ViewerModel + Fully assembled model ready for ``CellierController.from_model``. + """ + import yaozarrs + from cellier.v2.data.image import OMEZarrImageDataStore + from cellier.v2.scene.cameras import ( + OrbitCameraController, + OrthographicCamera, + PanZoomCameraController, + PerspectiveCamera, + ) + from cellier.v2.scene.canvas import Canvas + from cellier.v2.scene.dims import ( + AxisAlignedSelection, + CoordinateSystem, + DimsManager, + ) + from cellier.v2.scene.scene import Scene + from cellier.v2.transform import AffineTransform + from cellier.v2.viewer_model import DataManager, ViewerModel + from cellier.v2.visuals._image import ( + ImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageVisual, + ) + + print(f"Opening OME-Zarr store: {zarr_uri}") + data_store = OMEZarrImageDataStore.from_path(zarr_uri) + print(f" {data_store.n_levels} levels found.") + for i, shape in enumerate(data_store.level_shapes): + print(f" Level {i}: shape={shape}") + print(f" Axes: {data_store.axis_names}") + print(f" Units: {data_store.axis_units}") + + group = yaozarrs.open_group(data_store.zarr_path) + ome_image = group.ome_metadata() + ms = ome_image.multiscales[data_store.multiscale_index] + level_0_scale_zyx = np.array(ms.datasets[0].scale_transform.scale, dtype=np.float64) + print(f"\n Level-0 physical scale (ZYX): {level_0_scale_zyx}") + + vox_shape_zyx = np.array(data_store.level_shapes[0], dtype=np.float64) + world_extents_zyx = vox_shape_zyx * level_0_scale_zyx + max_extent = float(world_extents_zyx.max()) + depth_range = (max(1.0, max_extent * 0.0001), max_extent * 10.0) + print(f" World extents (ZYX): {world_extents_zyx}") + print(f" Depth range: near={depth_range[0]:.2f} far={depth_range[1]:.0f}\n") + + cs = CoordinateSystem(name="world", axis_labels=("z", "y", "x")) + voxel_to_world = AffineTransform.from_scale_and_translation( + scale=tuple(level_0_scale_zyx) + ) + + initial_clim_max = _dtype_clim_max(data_store.dtype) + world_max_zyx = (vox_shape_zyx - 1) * level_0_scale_zyx + + z_mid_world = round(float(world_max_zyx[0]) / 2.0) + y_mid_world = round(float(world_max_zyx[1]) / 2.0) + x_mid_world = round(float(world_max_zyx[2]) / 2.0) + + coarsest_level = data_store.n_levels - 1 + + common_2d_appearance = ImageAppearance( + color_map="grays", + clim=(0.0, initial_clim_max), + lod_bias=1.0, + force_level=None, + frustum_cull=True, + iso_threshold=0.2, + render_mode="mip", + ) + common_render_config = MultiscaleImageRenderConfig( + block_size=32, + gpu_budget_bytes=512 * 1024**2, + gpu_budget_bytes_2d=64 * 1024**2, + use_brick_shader=True, + ) + + def _make_2d_canvas() -> Canvas: + return Canvas( + cameras={ + "2d": OrthographicCamera( + near_clipping_plane=depth_range[0], + far_clipping_plane=depth_range[1], + controller=PanZoomCameraController(enabled=True), + ) + } + ) + + def _make_2d_visual(name: str) -> MultiscaleImageVisual: + return MultiscaleImageVisual( + name=name, + data_store_id=str(data_store.id), + level_transforms=data_store.level_transforms, + appearance=common_2d_appearance, + render_config=common_render_config, + transform=voxel_to_world, + ) + + xy_visual = _make_2d_visual("xy_volume") + xy_canvas = _make_2d_canvas() + xy_scene = Scene( + name="xy", + dims=DimsManager( + coordinate_system=cs, + selection=AxisAlignedSelection( + displayed_axes=(1, 2), slice_indices={0: z_mid_world} + ), + ), + render_modes={"2d"}, + lighting="none", + visuals=[xy_visual], + canvases={xy_canvas.id: xy_canvas}, + ) + + xz_visual = _make_2d_visual("xz_volume") + xz_canvas = _make_2d_canvas() + xz_scene = Scene( + name="xz", + dims=DimsManager( + coordinate_system=cs, + selection=AxisAlignedSelection( + displayed_axes=(0, 2), slice_indices={1: y_mid_world} + ), + ), + render_modes={"2d"}, + lighting="none", + visuals=[xz_visual], + canvases={xz_canvas.id: xz_canvas}, + ) + + yz_visual = _make_2d_visual("yz_volume") + yz_canvas = _make_2d_canvas() + yz_scene = Scene( + name="yz", + dims=DimsManager( + coordinate_system=cs, + selection=AxisAlignedSelection( + displayed_axes=(0, 1), slice_indices={2: x_mid_world} + ), + ), + render_modes={"2d"}, + lighting="none", + visuals=[yz_visual], + canvases={yz_canvas.id: yz_canvas}, + ) + + vol_visual = MultiscaleImageVisual( + name="vol_volume", + data_store_id=str(data_store.id), + level_transforms=data_store.level_transforms, + appearance=ImageAppearance( + color_map="grays", + clim=(0.0, initial_clim_max), + lod_bias=1.0, + force_level=coarsest_level, + frustum_cull=False, + iso_threshold=0.2, + render_mode="iso", + ), + render_config=MultiscaleImageRenderConfig( + block_size=32, + gpu_budget_bytes=2048 * 1024**2, + gpu_budget_bytes_2d=64 * 1024**2, + use_brick_shader=True, + ), + transform=voxel_to_world, + ) + vol_visual.aabb.enabled = True + vol_visual.aabb.color = "#ff00ff" + + vol_canvas = Canvas( + cameras={ + "3d": PerspectiveCamera( + fov=70.0, + near_clipping_plane=depth_range[0], + far_clipping_plane=depth_range[1], + controller=OrbitCameraController(enabled=True), + ) + } + ) + vol_scene = Scene( + name="vol", + dims=DimsManager( + coordinate_system=cs, + selection=AxisAlignedSelection(displayed_axes=(0, 1, 2), slice_indices={}), + ), + render_modes={"3d"}, + lighting="none", + visuals=[vol_visual], + canvases={vol_canvas.id: vol_canvas}, + ) + + viewer_model = ViewerModel( + data=DataManager(stores={data_store.id: data_store}), + scenes={ + xy_scene.id: xy_scene, + xz_scene.id: xz_scene, + yz_scene.id: yz_scene, + vol_scene.id: vol_scene, + }, + ) + + return viewer_model + + +# --------------------------------------------------------------------------- +# Layer 2: Qt bootstrap helper +# --------------------------------------------------------------------------- + + +def _ensure_qt_app(): + """Return the active QApplication, creating one via IPython if needed. + + Returns None if not in an interactive environment and no QApplication + exists — callers should raise a useful error in that case. + """ + from PySide6.QtWidgets import QApplication + + if app := QApplication.instance(): + return app + + try: + import IPython + + ip = IPython.get_ipython() + if ip is not None: + ip.enable_gui("qt6") + return QApplication.instance() + except ImportError: + pass + + return None + + +# --------------------------------------------------------------------------- +# Layer 3: Non-blocking show (for interactive / Jupyter use) +# --------------------------------------------------------------------------- + + +def orthoviewer(zarr_uri: str) -> OmeZarrOrthoViewer: + """Open an orthoviewer window without blocking. + + Intended for interactive use (Jupyter Lab, IPython). The Qt event loop + must already be running or be startable via IPython's ``enable_gui``; this + function sets that up automatically. For scripts use ``launch_orthoviewer``. + + Parameters + ---------- + zarr_uri : str + Path or URI to the OME-Zarr store. + + Returns + ------- + OmeZarrOrthoViewer + The viewer window object. Keep a reference to prevent garbage collection. + """ + app = _ensure_qt_app() + if app is None: + raise RuntimeError( + "No Qt event loop is running. " + "Use launch_orthoviewer() for scripts, or run inside IPython/Jupyter." + ) + + return _build_and_show(zarr_uri) + + +# --------------------------------------------------------------------------- +# Layer 4: Private async core +# --------------------------------------------------------------------------- + + +def _asyncio_exception_handler(context: dict) -> None: + """Custom asyncio exception handler that works around two PySide6 bugs. + + Bug 1: PySide6's default_exception_handler unconditionally accesses + context['task'], but the asyncio spec makes 'task' optional, causing a + KeyError that swallows the original exception message. + + Bug 2: PySide6's QtAsyncio routes CancelledError to the exception handler + instead of letting it propagate as normal task cancellation. CancelledError + is how cellier cancels stale chunk fetches when the slice position changes — + it is expected and should be silently ignored. + """ + import traceback + + exc = context.get("exception") + if isinstance(exc, asyncio.CancelledError): + return + + msg = context.get("message", "unhandled exception in asyncio") + task = context.get("task") + handle = context.get("handle") + source = ( + f"task {task._name}" if task else (repr(handle) if handle else "unknown source") + ) + print(f"[asyncio] {msg} from {source}") + if exc is not None: + traceback.print_exception(type(exc), exc, exc.__traceback__) + + +async def _run_orthoviewer_async(zarr_uri: str) -> None: + import asyncio as _asyncio + + from PySide6.QtWidgets import QApplication + + _asyncio.get_event_loop().set_exception_handler(_asyncio_exception_handler) + + viewer = _build_and_show(zarr_uri) + + app = QApplication.instance() + close_event = asyncio.Event() + app.aboutToQuit.connect(close_event.set) + app.aboutToQuit.connect(viewer.close_widgets) + await close_event.wait() + + +# --------------------------------------------------------------------------- +# Layer 5: Blocking launcher (for scripts and CLI) +# --------------------------------------------------------------------------- + + +def launch_orthoviewer(zarr_uri: str) -> None: + """Open an orthoviewer window and block until it is closed. + + Creates a ``QApplication`` if one does not already exist, then runs the + Qt + asyncio event loop via ``QtAsyncio``. Intended for scripts and the + CLI. For interactive/Jupyter use, call ``orthoviewer()`` instead. + + Parameters + ---------- + zarr_uri : str + Path or URI to the OME-Zarr store. + """ + import sys + + import PySide6.QtAsyncio as QtAsyncio + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([sys.argv[0]]) # noqa: F841 + QtAsyncio.run(_run_orthoviewer_async(zarr_uri), handle_sigint=True) + + +# --------------------------------------------------------------------------- +# Shared builder (used by both orthoviewer and _run_orthoviewer_async) +# --------------------------------------------------------------------------- + + +def _build_and_show(zarr_uri: str) -> OmeZarrOrthoViewer: + """Build the full viewer from a zarr URI and show the window.""" + import yaozarrs + from cellier.v2.controller import CellierController + from cellier.v2.gui._scene import QtCanvasWidget, QtDimsSliders + from cellier.v2.render._config import ( + RenderManagerConfig, + SlicingConfig, + TemporalAccumulationConfig, + ) + from cellier.v2.visuals._canvas_overlay import ( + CenteredAxes2D, + CenteredAxes2DAppearance, + ) + + viewer_model = build_ortho_viewer_model(zarr_uri) + + controller = CellierController.from_model( + viewer_model, + render_config=RenderManagerConfig( + slicing=SlicingConfig(batch_size=32, render_every=4), + temporal=TemporalAccumulationConfig(enabled=False), + ), + widget_parent=None, + ) + + xy_scene = controller.get_scene_by_name("xy") + xz_scene = controller.get_scene_by_name("xz") + yz_scene = controller.get_scene_by_name("yz") + vol_scene = controller.get_scene_by_name("vol") + scenes = {"xy": xy_scene, "xz": xz_scene, "yz": yz_scene, "vol": vol_scene} + + # Retrieve visuals from the model scenes + def _first_visual(scene): + return next(iter(scene.visuals)) + + visuals = { + "xy": _first_visual(xy_scene), + "xz": _first_visual(xz_scene), + "yz": _first_visual(yz_scene), + "vol": _first_visual(vol_scene), + } + + # Rebuild world geometry parameters needed for widgets/overlays + data_store = next(iter(viewer_model.data.stores.values())) + group = yaozarrs.open_group(data_store.zarr_path) + ome_image = group.ome_metadata() + ms = ome_image.multiscales[data_store.multiscale_index] + level_0_scale_zyx = np.array(ms.datasets[0].scale_transform.scale, dtype=np.float64) + vox_shape_zyx = np.array(data_store.level_shapes[0], dtype=np.float64) + world_max_zyx = (vox_shape_zyx - 1) * level_0_scale_zyx + + initial_clim_max = _dtype_clim_max(data_store.dtype) + slider_decimals = _dtype_decimals(data_store.dtype) + clim_range = (0.0, initial_clim_max) + + level0_shape = data_store.level_shapes[0] + axis_ranges = { + i: (0, round(float(world_max_zyx[i]))) for i in range(len(level0_shape)) + } + + z_mid_world = round(float(world_max_zyx[0]) / 2.0) + y_mid_world = round(float(world_max_zyx[1]) / 2.0) + x_mid_world = round(float(world_max_zyx[2]) / 2.0) + + # 3D axis-set mesh overlays + initial_centre_zyx = np.array( + [z_mid_world, y_mid_world, x_mid_world], dtype=np.float64 + ) + xy_axis_visual, xz_axis_visual, yz_axis_visual = _make_axis_meshes( + controller=controller, + vol_scene=vol_scene, + initial_centre_zyx=initial_centre_zyx, + world_min_extent=float(world_max_zyx.min()), + ) + + # Slice plane mesh overlay + _INITIAL_PLANE_OPACITY = 1.0 + plane_store, plane_visual = _make_plane_mesh( + controller, + vol_scene, + z_mid_world, + y_mid_world, + x_mid_world, + world_max_zyx, + initial_opacity=_INITIAL_PLANE_OPACITY, + ) + + plane_updater = _PlaneUpdater( + controller=controller, + plane_store=plane_store, + plane_visual=plane_visual, + world_max_zyx=world_max_zyx, + ) + controller.on_dims_changed( + xy_scene.id, plane_updater.on_xy_dims_changed, owner_id=plane_updater._id + ) + controller.on_dims_changed( + xz_scene.id, plane_updater.on_xz_dims_changed, owner_id=plane_updater._id + ) + controller.on_dims_changed( + yz_scene.id, plane_updater.on_yz_dims_changed, owner_id=plane_updater._id + ) + + scene_mgr = controller._render_manager._scenes[vol_scene.id] + gfx_vol_visual = scene_mgr.get_visual(visuals["vol"].id) + + def _canvas_view(scene_id): + canvas_id = controller.get_canvas_ids(scene_id)[0] + return controller.get_canvas_view(canvas_id) + + def _make_canvas_widget(scene, slider_style): + canvas_view = _canvas_view(scene.id) + axis_labels = dict(enumerate(scene.dims.coordinate_system.axis_labels)) + selection = scene.dims.selection + dims_sliders = QtDimsSliders( + scene_id=scene.id, + axis_ranges=axis_ranges, + axis_labels=axis_labels, + initial_slice_indices=dict(getattr(selection, "slice_indices", {})), + initial_displayed_axes=getattr(selection, "displayed_axes", ()), + ) + dims_sliders.widget.setStyleSheet(slider_style) + cw = QtCanvasWidget(canvas_view=canvas_view, dims_sliders=dims_sliders) + controller.connect_widget( + dims_sliders, subscription_specs=dims_sliders.subscription_specs() + ) + return cw + + vol_cw = QtCanvasWidget.from_scene_and_canvas( + vol_scene, _canvas_view(vol_scene.id), axis_ranges=axis_ranges + ) + controller.connect_widget( + vol_cw.dims_sliders, subscription_specs=vol_cw.dims_sliders.subscription_specs() + ) + + canvas_widgets = { + "xy": _make_canvas_widget(xy_scene, _SLIDER_STYLE_XY), + "xz": _make_canvas_widget(xz_scene, _SLIDER_STYLE_XZ), + "yz": _make_canvas_widget(yz_scene, _SLIDER_STYLE_YZ), + "vol": vol_cw, + } + + # Screen-space 2D axis overlays + xy_axes_overlay = controller.add_canvas_overlay_model( + controller.get_canvas_ids(xy_scene.id)[0], + CenteredAxes2D( + name="xy_axes", + axis_a_direction=(0.0, 1.0, 0.0), + axis_a_label="Y", + axis_b_direction=(1.0, 0.0, 0.0), + axis_b_label="X", + appearance=CenteredAxes2DAppearance( + axis_a_color=(*_PLANE_COLOR_XZ, 1.0), + axis_b_color=(*_PLANE_COLOR_YZ, 1.0), + label_color=(1.0, 0.0, 1.0, 1.0), + ), + ), + ) + xz_axes_overlay = controller.add_canvas_overlay_model( + controller.get_canvas_ids(xz_scene.id)[0], + CenteredAxes2D( + name="xz_axes", + axis_a_direction=(0.0, 1.0, 0.0), + axis_a_label="Z", + axis_b_direction=(1.0, 0.0, 0.0), + axis_b_label="X", + appearance=CenteredAxes2DAppearance( + axis_a_color=(*_PLANE_COLOR_XY, 1.0), + axis_b_color=(*_PLANE_COLOR_YZ, 1.0), + label_color=(1.0, 0.0, 1.0, 1.0), + ), + ), + ) + yz_axes_overlay = controller.add_canvas_overlay_model( + controller.get_canvas_ids(yz_scene.id)[0], + CenteredAxes2D( + name="yz_axes", + axis_a_direction=(0.0, 1.0, 0.0), + axis_a_label="Z", + axis_b_direction=(1.0, 0.0, 0.0), + axis_b_label="Y", + appearance=CenteredAxes2DAppearance( + axis_a_color=(*_PLANE_COLOR_XY, 1.0), + axis_b_color=(*_PLANE_COLOR_XZ, 1.0), + label_color=(1.0, 0.0, 1.0, 1.0), + ), + ), + ) + + viewer = OmeZarrOrthoViewer( + controller, + scenes=scenes, + visuals=visuals, + canvas_widgets=canvas_widgets, + clim_range=clim_range, + slider_decimals=slider_decimals, + plane_visual=plane_visual, + plane_store=plane_store, + gfx_vol_visual=gfx_vol_visual, + initial_plane_opacity=_INITIAL_PLANE_OPACITY, + axes_2d_overlay_ids=[ + xy_axes_overlay.id, + xz_axes_overlay.id, + yz_axes_overlay.id, + ], + orient_3d_visual_ids=[xy_axis_visual.id, xz_axis_visual.id, yz_axis_visual.id], + ) + viewer.window.show() + + # 3D orientation overlay wiring + orient_updater = _OrientationUpdater( + controller=controller, + xy_axis_visual=xy_axis_visual, + xz_axis_visual=xz_axis_visual, + yz_axis_visual=yz_axis_visual, + world_max_zyx=world_max_zyx, + ) + + controller.on_camera_changed( + xy_scene.id, orient_updater.on_xy_camera_changed, owner_id=orient_updater._id + ) + controller.on_camera_changed( + xz_scene.id, orient_updater.on_xz_camera_changed, owner_id=orient_updater._id + ) + controller.on_camera_changed( + yz_scene.id, orient_updater.on_yz_camera_changed, owner_id=orient_updater._id + ) + controller.on_dims_changed( + xy_scene.id, orient_updater.on_xy_dims_changed, owner_id=orient_updater._id + ) + controller.on_dims_changed( + xz_scene.id, orient_updater.on_xz_dims_changed, owner_id=orient_updater._id + ) + controller.on_dims_changed( + yz_scene.id, orient_updater.on_yz_dims_changed, owner_id=orient_updater._id + ) + + for scene in scenes.values(): + controller.fit_camera(scene.id) + controller.reslice_scene(scene.id) + + # Seed 3D orientation with post-fit camera state + def _seed_camera_event(scene_id): + from cellier.v2.events._events import CameraChangedEvent + + canvas_view = controller.get_canvas_view(controller.get_canvas_ids(scene_id)[0]) + camera_state = canvas_view._capture_camera_state() + return CameraChangedEvent( + source_id=canvas_view._canvas_id, + scene_id=scene_id, + camera_state=camera_state, + ) + + orient_updater.on_xy_camera_changed(_seed_camera_event(xy_scene.id)) + orient_updater.on_xz_camera_changed(_seed_camera_event(xz_scene.id)) + orient_updater.on_yz_camera_changed(_seed_camera_event(yz_scene.id)) + + return viewer From d3bb48404820c9c6d6451af9be1c2cec531790da Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 27 Apr 2026 13:04:25 +0200 Subject: [PATCH 3/6] add themes --- scripts/theme_picker.py | 484 +++++++++++++++++++++++++++ src/oz_viewer/_cli.py | 30 +- src/oz_viewer/theme/__init__.py | 151 +++++++++ src/oz_viewer/theme/_convert.py | 141 ++++++++ src/oz_viewer/theme/_defaults.py | 61 ++++ src/oz_viewer/theme/_model.py | 146 ++++++++ src/oz_viewer/theme/_qss_fixes.py | 129 +++++++ src/oz_viewer/theme/_registry.py | 91 +++++ src/oz_viewer/viewer/_orthoviewer.py | 89 +++-- 9 files changed, 1290 insertions(+), 32 deletions(-) create mode 100644 scripts/theme_picker.py create mode 100644 src/oz_viewer/theme/__init__.py create mode 100644 src/oz_viewer/theme/_convert.py create mode 100644 src/oz_viewer/theme/_defaults.py create mode 100644 src/oz_viewer/theme/_model.py create mode 100644 src/oz_viewer/theme/_qss_fixes.py create mode 100644 src/oz_viewer/theme/_registry.py diff --git a/scripts/theme_picker.py b/scripts/theme_picker.py new file mode 100644 index 0000000..dbce09b --- /dev/null +++ b/scripts/theme_picker.py @@ -0,0 +1,484 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["cmap", "pydantic", "PySide6"] +# /// +"""Interactive theme designer for oz-viewer. + +Usage +----- + # Start with the built-in dark theme + uv run --script scripts/theme_picker.py + + # Load an existing theme JSON + uv run --script scripts/theme_picker.py --load path/to/theme.json + + # Pre-set the save path + uv run --script scripts/theme_picker.py --output my_theme.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +# Allow importing oz_viewer.theme from the local source tree when run as a +# uv script (oz_viewer is not a listed script dependency). +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from oz_viewer.theme._model import PaletteColorGroup, Theme, ThemePalette +from oz_viewer.theme._registry import get_theme, list_themes + +# --------------------------------------------------------------------------- +# Role metadata: display order, section grouping, human-readable labels +# --------------------------------------------------------------------------- + +_ROLES: list[tuple[str, str]] = [ + # (field_name, display_label) + ("window", "Window"), + ("base", "Base (inputs / lists)"), + ("alternate_base", "Alternate base"), + ("button", "Button"), + ("window_text", "Window text"), + ("text", "Text"), + ("bright_text", "Bright text"), + ("button_text", "Button text"), + ("link", "Link"), + ("link_visited", "Link visited"), + ("highlight", "Highlight (selection)"), + ("highlighted_text", "Highlighted text"), + ("tool_tip_base", "Tooltip base"), + ("tool_tip_text", "Tooltip text"), +] + +_SECTIONS: list[tuple[str, list[str]]] = [ + ("Backgrounds", ["window", "base", "alternate_base", "button"]), + ( + "Text", + ["window_text", "text", "bright_text", "button_text", "link", "link_visited"], + ), + ("Selection", ["highlight", "highlighted_text"]), + ("Tooltips", ["tool_tip_base", "tool_tip_text"]), +] + +_ROLE_LABEL: dict[str, str] = dict(_ROLES) + +# --------------------------------------------------------------------------- +# Helper: contrasting text colour for a background hex +# --------------------------------------------------------------------------- + + +def _contrasting_text(hex_color: str) -> str: + """Return '#000000' or '#ffffff' depending on luminance of *hex_color*.""" + h = hex_color.lstrip("#") + if len(h) == 3: + h = "".join(c * 2 for c in h) + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return "#000000" if luminance > 0.5 else "#ffffff" + + +# --------------------------------------------------------------------------- +# ColorButton +# --------------------------------------------------------------------------- + + +class ColorButton: + """A QPushButton that shows a solid color and opens QColorDialog on click.""" + + def __init__(self, field_name: str, initial_hex: str, parent=None) -> None: + from PySide6.QtWidgets import QPushButton + + self.field_name = field_name + self._hex = initial_hex + self._btn = QPushButton(parent) + self._btn.setFixedHeight(28) + self._btn.clicked.connect(self._pick_color) + self._refresh() + + # callback set by the editor after construction + on_changed: object = None + + def _refresh(self) -> None: + fg = _contrasting_text(self._hex) + self._btn.setStyleSheet( + f"background-color: {self._hex}; color: {fg};" + " border: 1px solid #555; border-radius: 3px;" + ) + self._btn.setText(self._hex.upper()) + + def _pick_color(self) -> None: + from PySide6.QtGui import QColor + from PySide6.QtWidgets import QColorDialog + + initial = QColor(self._hex) + color = QColorDialog.getColor(initial, self._btn, f"Pick {self.field_name}") + if color.isValid(): + self._hex = color.name().upper() + self._refresh() + if callable(self.on_changed): + self.on_changed(self.field_name, self._hex) + + @property + def widget(self): + """Return the underlying ``QPushButton`` widget. + + Returns + ------- + PySide6.QtWidgets.QPushButton + The push-button that displays the color swatch. + """ + return self._btn + + @property + def hex_value(self) -> str: + """Return the current color as an uppercase hex string. + + Returns + ------- + str + Six-digit hex color string, e.g. ``"#FF8800"``. + """ + return self._hex + + def set_hex(self, hex_color: str) -> None: + """Set the displayed color without opening the color dialog. + + Parameters + ---------- + hex_color : str + Six-digit hex color string accepted by ``QColor``. + """ + self._hex = hex_color.upper() + self._refresh() + + +# --------------------------------------------------------------------------- +# Widget gallery (live preview panel) +# --------------------------------------------------------------------------- + + +def _make_gallery(parent=None): + from PySide6.QtWidgets import ( + QCheckBox, + QGroupBox, + QLabel, + QLineEdit, + QListWidget, + QProgressBar, + QPushButton, + QVBoxLayout, + QWidget, + ) + + container = QWidget(parent) + layout = QVBoxLayout(container) + layout.setSpacing(8) + + layout.addWidget(QLabel("Widget preview")) + + btn_normal = QPushButton("Normal button") + layout.addWidget(btn_normal) + + btn_disabled = QPushButton("Disabled button") + btn_disabled.setEnabled(False) + layout.addWidget(btn_disabled) + + line_edit = QLineEdit() + line_edit.setPlaceholderText("Text input…") + layout.addWidget(line_edit) + + list_widget = QListWidget() + for item in ("Item A", "Item B (selected)", "Item C"): + list_widget.addItem(item) + list_widget.setCurrentRow(1) + list_widget.setFixedHeight(90) + layout.addWidget(list_widget) + + check = QCheckBox("Checkbox") + check.setChecked(True) + layout.addWidget(check) + + progress = QProgressBar() + progress.setValue(60) + layout.addWidget(progress) + + group = QGroupBox("Group box") + group_layout = QVBoxLayout(group) + group_layout.addWidget(QPushButton("Button inside group")) + group_layout.addWidget(QLineEdit("Text inside group")) + layout.addWidget(group) + + layout.addStretch() + return container + + +# --------------------------------------------------------------------------- +# Main editor window +# --------------------------------------------------------------------------- + + +class ThemePickerWindow: + """Interactive theme editor window. + + Displays a scrollable panel of colour pickers (grouped by role) on the + left and a live widget gallery on the right. A toolbar exposes controls + for naming the theme, loading built-in themes, loading/saving JSON files, + and printing the theme dict to the terminal. + + Parameters + ---------- + initial_theme : Theme + The theme to display on startup. + output_path : str or None + Pre-filled save path offered in the Save dialog. ``None`` defaults + to ``".json"``. + """ + + def __init__(self, initial_theme: Theme, output_path: str | None = None) -> None: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QFormLayout, + QGroupBox, + QLabel, + QLineEdit, + QMainWindow, + QPushButton, + QScrollArea, + QSplitter, + QToolBar, + QVBoxLayout, + QWidget, + ) + + self._app = QApplication.instance() + self._output_path = output_path + + # --- state --- + self._colors: dict[str, str] = self._theme_to_hex_dict(initial_theme) + self._name = initial_theme.name + + # --- main window --- + self._win = QMainWindow() + self._win.setWindowTitle("oz-viewer theme picker") + self._win.resize(960, 700) + + # --- toolbar --- + toolbar = QToolBar("Controls") + toolbar.setMovable(False) + self._win.addToolBar(toolbar) + + toolbar.addWidget(QLabel(" Theme name: ")) + self._name_edit = QLineEdit(self._name) + self._name_edit.setFixedWidth(160) + self._name_edit.textChanged.connect(self._on_name_changed) + toolbar.addWidget(self._name_edit) + + toolbar.addSeparator() + + # theme selector dropdown + toolbar.addWidget(QLabel(" Load built-in: ")) + self._builtin_combo = QComboBox() + self._builtin_combo.addItems(list_themes()) + self._builtin_combo.setCurrentText(initial_theme.name) + self._builtin_combo.currentTextChanged.connect(self._load_builtin) + toolbar.addWidget(self._builtin_combo) + + toolbar.addSeparator() + + load_btn = QPushButton("Load JSON…") + load_btn.clicked.connect(self._load_file) + toolbar.addWidget(load_btn) + + save_btn = QPushButton("Save JSON…") + save_btn.clicked.connect(self._save_file) + toolbar.addWidget(save_btn) + + toolbar.addSeparator() + + print_btn = QPushButton("Print dict to terminal") + print_btn.clicked.connect(self._print_dict) + toolbar.addWidget(print_btn) + + # --- central splitter --- + splitter = QSplitter(Qt.Orientation.Horizontal) + self._win.setCentralWidget(splitter) + + # --- left: scrollable color buttons --- + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFixedWidth(310) + + scroll_contents = QWidget() + scroll_layout = QVBoxLayout(scroll_contents) + scroll_layout.setSpacing(4) + scroll_layout.setContentsMargins(8, 8, 8, 8) + + self._buttons: dict[str, ColorButton] = {} + + for section_label, field_names in _SECTIONS: + group = QGroupBox(section_label) + form = QFormLayout(group) + form.setSpacing(4) + for field_name in field_names: + hex_val = self._colors.get(field_name, "#888888") + btn = ColorButton(field_name, hex_val) + btn.on_changed = self._on_color_changed + self._buttons[field_name] = btn + form.addRow(_ROLE_LABEL[field_name], btn.widget) + scroll_layout.addWidget(group) + + scroll_layout.addStretch() + scroll_area.setWidget(scroll_contents) + splitter.addWidget(scroll_area) + + # --- right: gallery --- + self._gallery = _make_gallery() + splitter.addWidget(self._gallery) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + + self._apply_to_app() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _theme_to_hex_dict(theme: Theme) -> dict[str, str]: + result: dict[str, str] = {} + group = theme.palette.active + for field_name, _ in _ROLES: + val = getattr(group, field_name, None) + if val is not None: + r8 = val.rgba8 + result[field_name] = f"#{r8.r:02X}{r8.g:02X}{r8.b:02X}" + else: + result[field_name] = "#888888" + return result + + def _hex_dict_to_theme(self) -> Theme: + from cmap import Color + + color_kwargs: dict[str, Color | None] = {} + for field_name, _ in _ROLES: + hex_val = self._colors.get(field_name) + color_kwargs[field_name] = Color(hex_val) if hex_val else None + + group = PaletteColorGroup(**color_kwargs) + return Theme(name=self._name, palette=ThemePalette(active=group)) + + def _apply_to_app(self) -> None: + theme = self._hex_dict_to_theme() + self._app.setStyle("Fusion") + self._app.setPalette(theme.palette.to_qpalette()) + + def _populate_buttons(self) -> None: + for field_name, btn in self._buttons.items(): + btn.set_hex(self._colors.get(field_name, "#888888")) + + # ------------------------------------------------------------------ + # Slots + # ------------------------------------------------------------------ + + def _on_color_changed(self, field_name: str, hex_val: str) -> None: + self._colors[field_name] = hex_val + self._apply_to_app() + + def _on_name_changed(self, text: str) -> None: + self._name = text + + def _load_builtin(self, name: str) -> None: + theme = get_theme(name) + self._colors = self._theme_to_hex_dict(theme) + self._name = theme.name + self._name_edit.setText(self._name) + self._populate_buttons() + self._apply_to_app() + + def _load_file(self) -> None: + from PySide6.QtWidgets import QFileDialog + + path, _ = QFileDialog.getOpenFileName( + self._win, "Load theme JSON", "", "JSON files (*.json)" + ) + if not path: + return + from oz_viewer.theme import load_theme_file + + theme = load_theme_file(path) + self._colors = self._theme_to_hex_dict(theme) + self._name = theme.name + self._name_edit.setText(self._name) + self._populate_buttons() + self._apply_to_app() + + def _save_file(self) -> None: + from PySide6.QtWidgets import QFileDialog + + default = self._output_path or f"{self._name}.json" + path, _ = QFileDialog.getSaveFileName( + self._win, "Save theme JSON", default, "JSON files (*.json)" + ) + if not path: + return + from oz_viewer.theme import save_theme_file + + save_theme_file(self._hex_dict_to_theme(), path) + print(f"Saved theme to {path}") + + def _print_dict(self) -> None: + theme = self._hex_dict_to_theme() + # model_dump_json → back to plain dict → pretty-print JSON + d = json.loads(theme.model_dump_json()) + print("\n# --- theme dict (paste into _defaults.py) ---") + print(json.dumps(d, indent=4)) + print("# ---\n") + + @property + def window(self): + """Return the top-level ``QMainWindow`` instance. + + Returns + ------- + PySide6.QtWidgets.QMainWindow + The editor's main window. + """ + return self._win + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + """Parse CLI arguments and launch the theme picker window.""" + parser = argparse.ArgumentParser(description="oz-viewer theme picker") + parser.add_argument( + "--load", metavar="PATH", help="Load a theme JSON file on startup" + ) + parser.add_argument("--output", metavar="PATH", help="Pre-set the save path") + args = parser.parse_args() + + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication(sys.argv) + + if args.load: + from oz_viewer.theme import load_theme_file + + initial = load_theme_file(args.load) + else: + initial = get_theme("dark") + + picker = ThemePickerWindow(initial, output_path=args.output) + picker.window.show() + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/src/oz_viewer/_cli.py b/src/oz_viewer/_cli.py index ec9b1ed..ba6c390 100644 --- a/src/oz_viewer/_cli.py +++ b/src/oz_viewer/_cli.py @@ -140,6 +140,16 @@ def ortho( help="Create a synthetic anisotropic OME-Zarr and open it in the viewer.", ), ] = False, + theme: Annotated[ + str, + typer.Option( + "--theme", + help=( + "Theme name to apply. Run 'oz-viewer theme list' to see" + " available themes." + ), + ), + ] = "dark", ) -> None: """Open an OME-Zarr store in the 4-panel orthoviewer.""" from oz_viewer.viewer import launch_orthoviewer @@ -166,7 +176,25 @@ def ortho( raise typer.Exit(code=1) zarr_uri = _resolve_zarr_uri(raw) - launch_orthoviewer(zarr_uri) + launch_orthoviewer(zarr_uri, theme=theme) + + +@app.command(name="theme") +def theme_cmd( + action: Annotated[ + str, + typer.Argument(help="Action to perform. Currently supports: list"), + ], +) -> None: + """Manage oz-viewer themes.""" + if action == "list": + from oz_viewer.theme import list_themes + + for name in list_themes(): + typer.echo(name) + else: + typer.echo(f"Unknown action {action!r}. Available actions: list", err=True) + raise typer.Exit(code=1) if __name__ == "__main__": diff --git a/src/oz_viewer/theme/__init__.py b/src/oz_viewer/theme/__init__.py new file mode 100644 index 0000000..91570f6 --- /dev/null +++ b/src/oz_viewer/theme/__init__.py @@ -0,0 +1,151 @@ +"""Theme system for oz-viewer. + +Provides palette-based theming for Qt applications using the Fusion style. +The public API covers four concerns: + +* **Theme registry** — :func:`register_theme`, :func:`get_theme`, + :func:`list_themes`. +* **QSS fix registry** — :func:`register_qss`, :func:`list_qss_fixes`. + Fixes are applied application-wide on every :func:`apply_theme` call to + correct Fusion-style rendering quirks in third-party widgets such as + superqt's ``QLabeledDoubleRangeSlider``. +* **File I/O** — :func:`load_theme_file`, :func:`save_theme_file`. +* **Application** — :func:`apply_theme`. + +Quick start +----------- +Apply the built-in dark theme:: + + from oz_viewer.theme import apply_theme + + apply_theme(app, "dark") + +Register a custom theme from a JSON file:: + + from oz_viewer.theme import load_theme_file, register_theme + + register_theme("my-theme", load_theme_file("my_theme.json")) + apply_theme(app, "my-theme") + +Register a custom QSS fix for a third-party widget:: + + from oz_viewer.theme import register_qss, apply_theme + + register_qss("my-widget", "MyWidget::handle { ... }") + apply_theme(app, "dark") + +List available themes and fixes:: + + from oz_viewer.theme import list_themes, list_qss_fixes + + print(list_themes()) + print(list_qss_fixes()) +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from PySide6.QtWidgets import QApplication + + from oz_viewer.theme._model import Theme + +from oz_viewer.theme._qss_fixes import list_qss_fixes, register_qss +from oz_viewer.theme._registry import get_theme, list_themes, register_theme + +__all__ = [ + "apply_theme", + "get_theme", + "list_qss_fixes", + "list_themes", + "load_theme_file", + "register_qss", + "register_theme", + "save_theme_file", +] + + +def load_theme_file(path: str | Path) -> Theme: + """Load and validate a :class:`~oz_viewer.theme._model.Theme` from a JSON file. + + Parameters + ---------- + path : str or Path + Path to a JSON file produced by :func:`save_theme_file` or + ``scripts/theme_picker.py``. + + Returns + ------- + Theme + Validated, frozen theme model. + """ + from oz_viewer.theme._model import Theme as _Theme + + return _Theme.model_validate_json(Path(path).read_text(encoding="utf-8")) + + +def save_theme_file(theme: Theme, path: str | Path) -> None: + """Serialize *theme* to a JSON file. + + The output format is compatible with :func:`load_theme_file` and with the + ``--load`` option of ``scripts/theme_picker.py``. + + Parameters + ---------- + theme : Theme + Theme to serialize. + path : str or Path + Destination file path. Parent directories must already exist. + """ + Path(path).write_text(theme.model_dump_json(indent=2), encoding="utf-8") + + +def apply_theme(app: QApplication, theme: str | Theme) -> None: + """Apply *theme* to *app*, updating the palette and application stylesheet. + + Three things happen in order: + + 1. ``app.setStyle("Fusion")`` — enables full palette respect across + platforms. + 2. ``app.setPalette(...)`` — propagates semantic colors to every widget. + 3. ``app.setStyleSheet(...)`` — applies all registered QSS fixes (see + :func:`register_qss`) to correct Fusion-style rendering quirks in + third-party widgets. + + Because the stylesheet uses ``palette()`` references, colors in QSS fixes + automatically reflect the palette set in step 2. + + Widget-level stylesheets (set via ``widget.setStyleSheet()``) take + precedence over the application-level stylesheet and are unaffected by + this call. + + Parameters + ---------- + app : PySide6.QtWidgets.QApplication + The running Qt application instance. + theme : str or Theme + A registered theme name (e.g. ``"dark"``) or a + :class:`~oz_viewer.theme._model.Theme` object. + + Raises + ------ + KeyError + When *theme* is a string not found in the registry. + TypeError + When *theme* is neither a string nor a + :class:`~oz_viewer.theme._model.Theme`. + """ + from oz_viewer.theme._model import Theme as _Theme + from oz_viewer.theme._qss_fixes import get_fusion_stylesheet + + if isinstance(theme, str): + theme = get_theme(theme) + + if not isinstance(theme, _Theme): + raise TypeError(f"Expected a Theme or theme name str, got {type(theme)!r}") + + app.setStyle("Fusion") + app.setPalette(theme.palette.to_qpalette()) + app.setStyleSheet(get_fusion_stylesheet()) diff --git a/src/oz_viewer/theme/_convert.py b/src/oz_viewer/theme/_convert.py new file mode 100644 index 0000000..22dc5e2 --- /dev/null +++ b/src/oz_viewer/theme/_convert.py @@ -0,0 +1,141 @@ +"""QPalette conversion helpers for the oz-viewer theme system. + +Translates :class:`~oz_viewer.theme._model.ThemePalette` instances into +``PySide6.QtGui.QPalette`` objects ready for ``QApplication.setPalette``. + +Notes +----- +``cmap.Color.rgba8`` returns an ``RGBA8`` named-tuple whose ``r``, ``g``, +``b`` fields are 0-255 integers but whose ``a`` field is a 0-1 float. +All colour construction in this module multiplies ``a`` by 255 before +passing it to ``QColor`` to avoid nearly-transparent rendering. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cmap import Color + from PySide6.QtGui import QPalette + + from oz_viewer.theme._model import PaletteColorGroup, ThemePalette + + +# Maps PaletteColorGroup field names to QPalette.ColorRole enum member names. +_ROLE_MAP: dict[str, str] = { + "window": "Window", + "window_text": "WindowText", + "base": "Base", + "alternate_base": "AlternateBase", + "text": "Text", + "bright_text": "BrightText", + "button": "Button", + "button_text": "ButtonText", + "highlight": "Highlight", + "highlighted_text": "HighlightedText", + "tool_tip_base": "ToolTipBase", + "tool_tip_text": "ToolTipText", + "link": "Link", + "link_visited": "LinkVisited", +} + +# Text roles whose alpha is halved when building the Disabled color group. +_DISABLED_TEXT_ROLES: frozenset[str] = frozenset( + {"window_text", "text", "button_text", "highlighted_text", "tool_tip_text"} +) + + +def _cmap_to_qcolor(color: Color): + """Convert a ``cmap.Color`` to a fully-opaque ``QColor``. + + Parameters + ---------- + color : cmap.Color + Source color. Any alpha information is preserved. + + Returns + ------- + PySide6.QtGui.QColor + Equivalent ``QColor`` with alpha in 0-255 integer range. + """ + from PySide6.QtGui import QColor + + r8 = color.rgba8 + # rgba8.a is a 0-1 float; r/g/b are 0-255 integers. + return QColor(r8.r, r8.g, r8.b, round(r8.a * 255)) + + +def _apply_group( + palette: QPalette, + group: object, + color_group: PaletteColorGroup, + *, + dimmed: bool = False, +) -> None: + """Write one color group's roles into *palette*. + + Parameters + ---------- + palette : PySide6.QtGui.QPalette + The palette to mutate in-place. + group : PySide6.QtGui.QPalette.ColorGroup + Which color group to populate (Active, Inactive, or Disabled). + color_group : PaletteColorGroup + Source color values. + dimmed : bool + When ``True``, text roles are set to 50 % alpha to represent the + disabled state. Non-text roles are written at full opacity. + """ + from PySide6.QtGui import QColor + from PySide6.QtGui import QPalette as _QPalette + + for field_name, role_name in _ROLE_MAP.items(): + color: Color | None = getattr(color_group, field_name) + if color is None: + continue + role = getattr(_QPalette.ColorRole, role_name) + r8 = color.rgba8 + # rgba8.a is 0-1 float; multiply by 255 for QColor's 0-255 scale. + a = round(r8.a * 255) + if dimmed and field_name in _DISABLED_TEXT_ROLES: + qc = QColor(r8.r, r8.g, r8.b, 128) + else: + qc = QColor(r8.r, r8.g, r8.b, a) + palette.setColor(group, role, qc) + + +def theme_palette_to_qpalette(theme_palette: ThemePalette) -> QPalette: + """Build a ``QPalette`` from a :class:`~oz_viewer.theme._model.ThemePalette`. + + Populates all three color groups. When ``theme_palette.disabled`` is + ``None``, the Disabled group is derived from ``active`` with text roles + dimmed to 50 % alpha. + + Parameters + ---------- + theme_palette : ThemePalette + Validated, frozen theme palette model. + + Returns + ------- + PySide6.QtGui.QPalette + Fully populated palette ready for ``QApplication.setPalette``. + """ + from PySide6.QtGui import QPalette as _QPalette + + pal = _QPalette() + _apply_group(pal, _QPalette.ColorGroup.Active, theme_palette.active) + _apply_group(pal, _QPalette.ColorGroup.Inactive, theme_palette.inactive) + + if theme_palette.disabled is not None: + _apply_group(pal, _QPalette.ColorGroup.Disabled, theme_palette.disabled) + else: + _apply_group( + pal, + _QPalette.ColorGroup.Disabled, + theme_palette.active, + dimmed=True, + ) + + return pal diff --git a/src/oz_viewer/theme/_defaults.py b/src/oz_viewer/theme/_defaults.py new file mode 100644 index 0000000..4e55e30 --- /dev/null +++ b/src/oz_viewer/theme/_defaults.py @@ -0,0 +1,61 @@ +"""Built-in theme definitions as plain Python dicts. + +Storing themes as dicts rather than JSON files avoids file I/O at import +time and removes the need for ``importlib.resources``. Pydantic validation +into :class:`~oz_viewer.theme._model.Theme` objects happens lazily on first +access via :func:`~oz_viewer.theme._registry.get_theme`. + +Color values follow the same rules as ``cmap.Color``: any CSS color name, +hex string, or ``rgb()``/``rgba()`` string is accepted. + +Notes +----- +The dark palette is modelled on a mid-grey charcoal scheme. +The light palette mirrors the Qt Fusion default light appearance. +Both can be used as starting points in +``scripts/theme_picker.py`` and saved to custom JSON theme files. +""" + +from __future__ import annotations + +DEFAULT_DARK: dict = { + "name": "dark", + "palette": { + "active": { + "window": "#2d2d2d", + "window_text": "#dcdcdc", + "base": "#1e1e1e", + "alternate_base": "#252525", + "text": "#dcdcdc", + "bright_text": "#ffffff", + "button": "#3c3c3c", + "button_text": "#dcdcdc", + "highlight": "#2a82da", + "highlighted_text": "#ffffff", + "tool_tip_base": "#3c3c3c", + "tool_tip_text": "#dcdcdc", + "link": "#5aabff", + } + }, +} + +DEFAULT_LIGHT: dict = { + "name": "light", + "palette": { + "active": { + "window": "#f0f0f0", + "window_text": "#1a1a1a", + "base": "#ffffff", + "alternate_base": "#f5f5f5", + "text": "#1a1a1a", + "bright_text": "#000000", + "button": "#e0e0e0", + "button_text": "#1a1a1a", + "highlight": "#2a82da", + "highlighted_text": "#ffffff", + "tool_tip_base": "#ffffdc", + "tool_tip_text": "#1a1a1a", + "link": "#0057ae", + } + }, +} diff --git a/src/oz_viewer/theme/_model.py b/src/oz_viewer/theme/_model.py new file mode 100644 index 0000000..fd25cd8 --- /dev/null +++ b/src/oz_viewer/theme/_model.py @@ -0,0 +1,146 @@ +"""Pydantic models for the oz-viewer theme system. + +Provides three frozen models that form a strict hierarchy: + +* :class:`PaletteColorGroup` — colors for one ``QPalette.ColorGroup``. +* :class:`ThemePalette` — active, inactive, and disabled groups. +* :class:`Theme` — a named theme containing one :class:`ThemePalette`. +""" + +from __future__ import annotations + +from cmap import Color # noqa: TC002 — Pydantic resolves annotations at runtime +from pydantic import BaseModel, ConfigDict, model_validator + + +class PaletteColorGroup(BaseModel): + """Color values for one ``QPalette.ColorGroup``. + + Each field maps directly to a ``QPalette.ColorRole`` (snake_case to + CamelCase). The derived roles ``Light``, ``Midlight``, ``Dark``, + ``Mid``, and ``Shadow`` are intentionally absent; the Fusion style + computes them automatically from ``window`` and ``button``. + + Attributes + ---------- + window : Color + Background of main windows, panels, and group boxes. + window_text : Color + Foreground text drawn directly on the window background. + base : Color + Background of input widgets (``QLineEdit``, ``QListWidget``, …). + alternate_base : Color + Alternating-row background in item views. + text : Color + Foreground text inside input widgets. + bright_text : Color + High-contrast foreground used for emphasis or warnings. + button : Color + Background of push buttons and related controls. + button_text : Color + Foreground text on buttons. + highlight : Color + Background of selected items and the active range in range sliders. + highlighted_text : Color + Foreground text of selected items. + tool_tip_base : Color + Background of tool-tip pop-ups. + tool_tip_text : Color + Foreground text of tool-tip pop-ups. + link : Color + Hyperlink text color. + link_visited : Color or None + Visited hyperlink text color. Falls back to ``link`` when ``None``. + """ + + model_config = ConfigDict(frozen=True) + + window: Color + window_text: Color + base: Color + alternate_base: Color + text: Color + bright_text: Color + button: Color + button_text: Color + highlight: Color + highlighted_text: Color + tool_tip_base: Color + tool_tip_text: Color + link: Color + link_visited: Color | None = None + + +class ThemePalette(BaseModel): + """Complete ``QPalette`` specification with active, inactive, and disabled groups. + + Only ``active`` is required. ``inactive`` mirrors ``active`` when omitted + (preventing the focus-loss colour shift that occurs when only the Active + group is set). ``disabled`` is auto-derived when omitted: all roles copy + ``active`` with text roles dimmed to 50 % alpha. + + Attributes + ---------- + active : PaletteColorGroup + Colors used when the widget's window has keyboard focus. + inactive : PaletteColorGroup or None + Colors used when the widget's window lacks focus. Mirrors ``active`` + when ``None``. + disabled : PaletteColorGroup or None + Colors used for disabled widgets. Auto-derived from ``active`` when + ``None``. + """ + + model_config = ConfigDict(frozen=True) + + active: PaletteColorGroup + inactive: PaletteColorGroup | None = None + disabled: PaletteColorGroup | None = None + + @model_validator(mode="after") + def _mirror_inactive(self) -> ThemePalette: + """Mirror ``active`` into ``inactive`` when the latter is absent. + + Returns + ------- + ThemePalette + The validated model instance, with ``inactive`` guaranteed + non-``None``. + """ + if self.inactive is None: + object.__setattr__(self, "inactive", self.active) + return self + + def to_qpalette(self): + """Convert this palette to a ``QPalette`` suitable for ``QApplication``. + + Returns + ------- + PySide6.QtGui.QPalette + A fully populated ``QPalette`` covering the Active, Inactive, and + Disabled color groups. + """ + from oz_viewer.theme._convert import theme_palette_to_qpalette + + return theme_palette_to_qpalette(self) + + +class Theme(BaseModel): + """A named theme containing a single :class:`ThemePalette`. + + Dark and light variants are represented as separate ``Theme`` instances + registered under distinct names (e.g. ``"dark"`` and + ``"light"``). + + Attributes + ---------- + name : str + Human-readable identifier, also used as the registry key. + palette : ThemePalette + The color data for this theme. + """ + + model_config = ConfigDict(frozen=True) + + name: str + palette: ThemePalette diff --git a/src/oz_viewer/theme/_qss_fixes.py b/src/oz_viewer/theme/_qss_fixes.py new file mode 100644 index 0000000..a480b36 --- /dev/null +++ b/src/oz_viewer/theme/_qss_fixes.py @@ -0,0 +1,129 @@ +"""QSS fix registry for Fusion-style compatibility corrections. + +When :func:`oz_viewer.theme.apply_theme` sets ``QStyle("Fusion")``, some +third-party and custom Qt widgets require explicit QSS geometry hints to +render correctly. This module maintains a registry of named QSS snippets +that are concatenated and applied at the application level by +:func:`oz_viewer.theme.apply_theme`. + +All color values in the built-in snippets use ``palette()`` references so +they resolve against whatever theme palette is active at render time—no +hex literals are hard-coded here. + +Built-in fixes +-------------- +slider + Fixes ``QSlider`` groove height and active-range vertical alignment for + both standard ``QSlider`` and superqt ``QLabeledDoubleRangeSlider`` under + Fusion. + +Adding a new fix +---------------- +Call :func:`register_qss` *before* :func:`~oz_viewer.theme.apply_theme`:: + + from oz_viewer.theme import register_qss, apply_theme + + register_qss("my-widget", "MyWidget::item { ... }") + apply_theme(app, "dark") + +Because the registry is module-level, fixes registered in library +initialisation code are automatically included without any extra wiring. +""" + +from __future__ import annotations + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +# Maps a fix name to its QSS snippet. Plain dict gives O(1) lookup and +# preserves insertion order, which keeps the concatenated output stable. +_fixes: dict[str, str] = {} + +# --------------------------------------------------------------------------- +# Built-in fix: QSlider / QLabeledDoubleRangeSlider +# --------------------------------------------------------------------------- + +# Under Fusion, QSlider sub-elements must all be styled via QSS for any +# individual override to take effect. This snippet establishes consistent +# groove geometry so superqt's custom active-range painting aligns with the +# groove rect. All colours use palette() references and therefore adapt to +# whatever Theme palette is active. +_SLIDER_QSS: str = """\ +QSlider::groove:horizontal { + height: 6px; + background: palette(mid); + border-radius: 3px; +} +QSlider::sub-page:horizontal { + height: 6px; + background: palette(highlight); + border-radius: 3px; +} +QSlider::add-page:horizontal { + height: 6px; + background: palette(mid); + border-radius: 3px; +} +QSlider::handle:horizontal { + width: 14px; + height: 14px; + margin: -4px 0; + border-radius: 7px; + background: palette(button); + border: 1px solid palette(shadow); +} +QSlider::handle:horizontal:hover { + background: palette(light); +}""" + +_fixes["slider"] = _SLIDER_QSS + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def register_qss(name: str, qss: str) -> None: + """Register a QSS snippet under *name*, replacing any existing entry. + + The snippet is included in the application-level stylesheet the next time + :func:`oz_viewer.theme.apply_theme` is called. Use ``palette()`` + references (e.g. ``palette(highlight)``) rather than literal hex values + so the snippet adapts automatically to any theme. + + Parameters + ---------- + name : str + Unique identifier for the snippet. Existing entries with the same + name are silently replaced. + qss : str + Valid Qt Style Sheet text to register. + """ + _fixes[name] = qss + + +def list_qss_fixes() -> list[str]: + """Return the names of all currently registered QSS fixes. + + Returns + ------- + list of str + Fix names in registration order. + """ + return list(_fixes.keys()) + + +def get_fusion_stylesheet() -> str: + """Return the full application-level stylesheet from all registered fixes. + + Snippets are joined with a blank line separator. The result is intended + to be passed directly to ``QApplication.setStyleSheet``. + + Returns + ------- + str + Concatenated QSS string for all registered fixes. + """ + return "\n\n".join(_fixes.values()) diff --git a/src/oz_viewer/theme/_registry.py b/src/oz_viewer/theme/_registry.py new file mode 100644 index 0000000..4006f98 --- /dev/null +++ b/src/oz_viewer/theme/_registry.py @@ -0,0 +1,91 @@ +"""Theme registry for oz-viewer. + +Maintains a module-level dictionary that maps theme names to either a +validated :class:`~oz_viewer.theme._model.Theme` instance or a raw ``dict`` +pending first-use validation. The lazy-validation approach keeps import +cost near zero: no pydantic models are instantiated until a theme is actually +requested. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from oz_viewer.theme._model import Theme + +from oz_viewer.theme._defaults import DEFAULT_DARK, DEFAULT_LIGHT + +# Values are either a validated Theme or a raw dict awaiting first-use +# validation. The dict form is replaced by the validated Theme on first +# access so subsequent calls are a plain dict lookup. +_registry: dict[str, Theme | dict] = { + "dark": DEFAULT_DARK, + "light": DEFAULT_LIGHT, +} + + +def register_theme(name: str, theme: Theme) -> None: + """Register *theme* under *name*, replacing any existing entry. + + Parameters + ---------- + name : str + Registry key used to retrieve the theme later. An existing entry + with the same name is silently replaced. + theme : Theme + Validated :class:`~oz_viewer.theme._model.Theme` instance. + """ + _registry[name] = theme + + +def get_theme(name: str) -> Theme: + """Return the :class:`~oz_viewer.theme._model.Theme` registered as *name*. + + Built-in themes are stored as raw dicts and validated into ``Theme`` + objects on first access. The validated object is cached so subsequent + calls for the same name incur no validation overhead. + + Parameters + ---------- + name : str + Registry key to look up. + + Returns + ------- + Theme + The validated, frozen theme model. + + Raises + ------ + KeyError + When *name* is not found in the registry. The error message lists + all available theme names. + """ + try: + entry = _registry[name] + except KeyError: + available = ", ".join(sorted(_registry)) + raise KeyError( + f"Unknown theme {name!r}. Available themes: {available}" + ) from None + + if isinstance(entry, dict): + from oz_viewer.theme._model import Theme as _Theme + + theme = _Theme.model_validate(entry) + _registry[name] = theme + return theme + + return entry + + +def list_themes() -> list[str]: + """Return the names of all registered themes in insertion order. + + Returns + ------- + list of str + Theme names in the order they were registered. + """ + return list(_registry.keys()) diff --git a/src/oz_viewer/viewer/_orthoviewer.py b/src/oz_viewer/viewer/_orthoviewer.py index 19d0f79..88d205c 100644 --- a/src/oz_viewer/viewer/_orthoviewer.py +++ b/src/oz_viewer/viewer/_orthoviewer.py @@ -13,39 +13,55 @@ def _make_slider_style(color_a: str, color_b: str) -> str: + """Return a widget-level QSS string for an axis-identification dims slider. + + All sub-controls are styled so that the Fusion style engine does not + override any individual element. Structural colors (groove, handle, + add-page) use ``palette()`` references so they adapt to whatever theme is + active. The ``sub-page`` gradient encodes the axis identity and is + intentionally fixed regardless of theme. + + Parameters + ---------- + color_a : str + CSS color for the left/start stop of the sub-page gradient. + color_b : str + CSS color for the right/end stop of the sub-page gradient. + + Returns + ------- + str + QSS string suitable for ``widget.setStyleSheet()``. + """ return f""" QSlider::groove:horizontal {{ - border: 1px solid #bbb; - background: white; - height: 10px; - border-radius: 4px; -}} -QSlider::handle:horizontal {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #eee, stop:1 #ccc); - border: 1px solid #777; - width: 13px; - margin-top: -7px; - margin-bottom: -7px; - border-radius: 4px; -}} -QSlider::add-page:horizontal {{ - background: #fff; - border: 1px solid #777; - height: 10px; - border-radius: 4px; + height: 6px; + background: palette(mid); + border-radius: 3px; }} QSlider::sub-page:horizontal {{ + height: 6px; background: qlineargradient(x1:0, y1:0.2, x2:1, y2:1, stop:0 {color_a}, stop:1 {color_b}); - border: 1px solid #777; - height: 10px; - border-radius: 4px; + border-radius: 3px; +}} +QSlider::add-page:horizontal {{ + height: 6px; + background: palette(mid); + border-radius: 3px; +}} +QSlider::handle:horizontal {{ + width: 20px; + height: 20px; + margin: -7px 0; + border-radius: 10px; + background: #2a82da; + border: 1px solid palette(shadow); }} QSlider::handle:horizontal:hover {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #fff, stop:1 #ddd); - border: 1px solid #444; - border-radius: 4px; + background: palette(light); }} +QSlider:horizontal {{ min-height: 20px; }} QLabel {{ font-size: 12px; }} """ @@ -1053,7 +1069,7 @@ def _ensure_qt_app(): # --------------------------------------------------------------------------- -def orthoviewer(zarr_uri: str) -> OmeZarrOrthoViewer: +def orthoviewer(zarr_uri: str, theme: str = "dark") -> OmeZarrOrthoViewer: """Open an orthoviewer window without blocking. Intended for interactive use (Jupyter Lab, IPython). The Qt event loop @@ -1064,6 +1080,9 @@ def orthoviewer(zarr_uri: str) -> OmeZarrOrthoViewer: ---------- zarr_uri : str Path or URI to the OME-Zarr store. + theme : str + Registered theme name. Defaults to ``"dark"``. + Use ``oz_viewer.theme.list_themes()`` to see available themes. Returns ------- @@ -1077,7 +1096,7 @@ def orthoviewer(zarr_uri: str) -> OmeZarrOrthoViewer: "Use launch_orthoviewer() for scripts, or run inside IPython/Jupyter." ) - return _build_and_show(zarr_uri) + return _build_and_show(zarr_uri, theme=theme) # --------------------------------------------------------------------------- @@ -1114,14 +1133,14 @@ def _asyncio_exception_handler(context: dict) -> None: traceback.print_exception(type(exc), exc, exc.__traceback__) -async def _run_orthoviewer_async(zarr_uri: str) -> None: +async def _run_orthoviewer_async(zarr_uri: str, theme: str = "dark") -> None: import asyncio as _asyncio from PySide6.QtWidgets import QApplication _asyncio.get_event_loop().set_exception_handler(_asyncio_exception_handler) - viewer = _build_and_show(zarr_uri) + viewer = _build_and_show(zarr_uri, theme=theme) app = QApplication.instance() close_event = asyncio.Event() @@ -1135,7 +1154,7 @@ async def _run_orthoviewer_async(zarr_uri: str) -> None: # --------------------------------------------------------------------------- -def launch_orthoviewer(zarr_uri: str) -> None: +def launch_orthoviewer(zarr_uri: str, theme: str = "dark") -> None: """Open an orthoviewer window and block until it is closed. Creates a ``QApplication`` if one does not already exist, then runs the @@ -1146,6 +1165,9 @@ def launch_orthoviewer(zarr_uri: str) -> None: ---------- zarr_uri : str Path or URI to the OME-Zarr store. + theme : str + Registered theme name. Defaults to ``"dark"``. + Use ``oz_viewer.theme.list_themes()`` to see available themes. """ import sys @@ -1153,7 +1175,7 @@ def launch_orthoviewer(zarr_uri: str) -> None: from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([sys.argv[0]]) # noqa: F841 - QtAsyncio.run(_run_orthoviewer_async(zarr_uri), handle_sigint=True) + QtAsyncio.run(_run_orthoviewer_async(zarr_uri, theme=theme), handle_sigint=True) # --------------------------------------------------------------------------- @@ -1161,8 +1183,13 @@ def launch_orthoviewer(zarr_uri: str) -> None: # --------------------------------------------------------------------------- -def _build_and_show(zarr_uri: str) -> OmeZarrOrthoViewer: +def _build_and_show(zarr_uri: str, theme: str = "dark") -> OmeZarrOrthoViewer: """Build the full viewer from a zarr URI and show the window.""" + from PySide6.QtWidgets import QApplication + + from oz_viewer.theme import apply_theme + + apply_theme(QApplication.instance(), theme) import yaozarrs from cellier.v2.controller import CellierController from cellier.v2.gui._scene import QtCanvasWidget, QtDimsSliders From 3daf01e9e363b7910dc1136812e7ba51e802a634 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 27 Apr 2026 16:42:39 +0200 Subject: [PATCH 4/6] use qtpy --- scripts/theme_picker.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/scripts/theme_picker.py b/scripts/theme_picker.py index dbce09b..8f45cc9 100644 --- a/scripts/theme_picker.py +++ b/scripts/theme_picker.py @@ -1,6 +1,6 @@ # /// script # requires-python = ">=3.11" -# dependencies = ["cmap", "pydantic", "PySide6"] +# dependencies = ["cmap", "pydantic", "qtpy", "PySide6"] # /// """Interactive theme designer for oz-viewer. @@ -88,7 +88,7 @@ class ColorButton: """A QPushButton that shows a solid color and opens QColorDialog on click.""" def __init__(self, field_name: str, initial_hex: str, parent=None) -> None: - from PySide6.QtWidgets import QPushButton + from qtpy.QtWidgets import QPushButton self.field_name = field_name self._hex = initial_hex @@ -109,8 +109,8 @@ def _refresh(self) -> None: self._btn.setText(self._hex.upper()) def _pick_color(self) -> None: - from PySide6.QtGui import QColor - from PySide6.QtWidgets import QColorDialog + from qtpy.QtGui import QColor + from qtpy.QtWidgets import QColorDialog initial = QColor(self._hex) color = QColorDialog.getColor(initial, self._btn, f"Pick {self.field_name}") @@ -126,7 +126,7 @@ def widget(self): Returns ------- - PySide6.QtWidgets.QPushButton + qtpy.QtWidgets.QPushButton The push-button that displays the color swatch. """ return self._btn @@ -160,7 +160,7 @@ def set_hex(self, hex_color: str) -> None: def _make_gallery(parent=None): - from PySide6.QtWidgets import ( + from qtpy.QtWidgets import ( QCheckBox, QGroupBox, QLabel, @@ -237,8 +237,8 @@ class ThemePickerWindow: """ def __init__(self, initial_theme: Theme, output_path: str | None = None) -> None: - from PySide6.QtCore import Qt - from PySide6.QtWidgets import ( + from qtpy.QtCore import Qt + from qtpy.QtWidgets import ( QApplication, QComboBox, QFormLayout, @@ -400,7 +400,7 @@ def _load_builtin(self, name: str) -> None: self._apply_to_app() def _load_file(self) -> None: - from PySide6.QtWidgets import QFileDialog + from qtpy.QtWidgets import QFileDialog path, _ = QFileDialog.getOpenFileName( self._win, "Load theme JSON", "", "JSON files (*.json)" @@ -417,7 +417,7 @@ def _load_file(self) -> None: self._apply_to_app() def _save_file(self) -> None: - from PySide6.QtWidgets import QFileDialog + from qtpy.QtWidgets import QFileDialog default = self._output_path or f"{self._name}.json" path, _ = QFileDialog.getSaveFileName( @@ -444,7 +444,7 @@ def window(self): Returns ------- - PySide6.QtWidgets.QMainWindow + qtpy.QtWidgets.QMainWindow The editor's main window. """ return self._win @@ -464,7 +464,7 @@ def main() -> None: parser.add_argument("--output", metavar="PATH", help="Pre-set the save path") args = parser.parse_args() - from PySide6.QtWidgets import QApplication + from qtpy.QtWidgets import QApplication app = QApplication.instance() or QApplication(sys.argv) From 43a74f4c4a5e6003034637f1a63bc39551e452ef Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 4 May 2026 18:54:33 +0200 Subject: [PATCH 5/6] add test fixture --- pyproject.toml | 5 + src/oz_viewer/_cli.py | 124 ++++++++ src/oz_viewer/_display.py | 44 +++ src/oz_viewer/_download.py | 602 +++++++++++++++++++++++++++++++++++++ tests/conftest.py | 37 ++- tests/test_cli.py | 5 +- 6 files changed, 814 insertions(+), 3 deletions(-) create mode 100644 src/oz_viewer/_download.py diff --git a/pyproject.toml b/pyproject.toml index 8ae082f..e412aae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,11 @@ dependencies = [ "numpy >= 1.24", "cellier[pyside]>=0.0.13", "jupyterlab>=4.5.6", + # download command + "aiohttp >= 3.9", + "zarr >= 3.0", + "s3fs", + "tensorstore", ] # https://peps.python.org/pep-0621/#dependencies-optional-dependencies diff --git a/src/oz_viewer/_cli.py b/src/oz_viewer/_cli.py index ba6c390..ae7637a 100644 --- a/src/oz_viewer/_cli.py +++ b/src/oz_viewer/_cli.py @@ -11,6 +11,7 @@ from oz_viewer._display import ( make_console, make_ping_progress, + print_download_complete, print_error_panel, print_metadata_panel, print_ping_header, @@ -197,5 +198,128 @@ def theme_cmd( raise typer.Exit(code=1) +@app.command() +def download( + url: Annotated[ + str, + typer.Argument( + help="Source URL: s3:///path/data.zarr or https://host/path/data.zarr" + ), + ], + output: Annotated[ + Path | None, + typer.Option( + "--output", + "-o", + help="Local output directory (default: basename of URL).", + show_default=False, + ), + ] = None, + concurrency: Annotated[ + int, + typer.Option( + "--concurrency", + "-c", + help="Maximum concurrent chunk transfers.", + min=1, + ), + ] = 32, + no_validate: Annotated[ + bool, + typer.Option( + "--no-validate", + help="Skip OME-Zarr validation after download.", + ), + ] = False, + anon: Annotated[ + bool, + typer.Option( + "--anon/--no-anon", + help="Anonymous S3 access (default: --anon).", + ), + ] = True, + overwrite: Annotated[ + bool, + typer.Option( + "--overwrite", + help="Remove and replace an existing output directory.", + ), + ] = False, + dry_run: Annotated[ + bool, + typer.Option( + "--dry-run", + help=( + "Enumerate keys and probe the first 3, but do not write anything. " + "Useful for diagnosing URL or auth issues." + ), + ), + ] = False, +) -> None: + """Download an OME-Zarr store from S3 or HTTPS to a local directory.""" + import asyncio + import shutil + from urllib.parse import urlparse + + from yaozarrs import validate_zarr_store + from yaozarrs._storage import StorageValidationError + + from oz_viewer._download import download as _download + + parsed = urlparse(url.rstrip("/")) + scheme = parsed.scheme.lower() + if scheme not in ("s3", "https", "http"): + typer.echo( + f"Error: unsupported URL scheme {scheme!r}. " + "Use s3://, https://, or http://.", + err=True, + ) + raise typer.Exit(code=1) + + default_name = url.rstrip("/").split("/")[-1] or "downloaded.zarr" + output_dir: Path = output if output is not None else Path(default_name) + + if output_dir.exists(): + if overwrite: + typer.echo(f"Removing existing {output_dir} ...") + shutil.rmtree(output_dir) + else: + typer.echo( + f"Error: {output_dir} already exists. Pass --overwrite to replace it.", + err=True, + ) + raise typer.Exit(code=1) + + output_dir.mkdir(parents=True, exist_ok=True) + console = make_console() + + try: + asyncio.run(_download(url, output_dir, concurrency, anon, dry_run, console)) + except (KeyboardInterrupt, asyncio.CancelledError): + typer.echo("\nInterrupted — cleaning up partial output ...", err=True) + shutil.rmtree(output_dir, ignore_errors=True) + raise typer.Exit(code=1) from None + except Exception as exc: + typer.echo(f"\nFailed: {exc}", err=True) + typer.echo("Cleaning up partial output ...", err=True) + shutil.rmtree(output_dir, ignore_errors=True) + raise typer.Exit(code=1) from None + + if dry_run: + return + + print_download_complete(output_dir, console) + + if not no_validate: + try: + validate_zarr_store(str(output_dir)) + console.print("[bold green]✓ Valid OME-Zarr store.[/bold green]") + except StorageValidationError as exc: + print_error_panel(str(output_dir), exc, console) + raise typer.Exit(code=1) from None + except Exception as exc: + typer.echo(f"⚠ Validation raised an unexpected error: {exc}", err=True) + + if __name__ == "__main__": app() diff --git a/src/oz_viewer/_display.py b/src/oz_viewer/_display.py index ba5f597..edcaecb 100644 --- a/src/oz_viewer/_display.py +++ b/src/oz_viewer/_display.py @@ -9,10 +9,12 @@ from rich.pretty import Pretty from rich.progress import ( BarColumn, + MofNCompleteColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn, + TimeElapsedColumn, ) from rich.table import Table, box from rich.theme import Theme @@ -149,6 +151,48 @@ def make_ping_progress(console: Console) -> Progress: ) +def make_download_progress(console: Console) -> Progress: + """Return a Rich Progress instance configured for chunk downloading. + + Shows a spinner, a dynamically updated description (used to embed the + running MB total), a bar, a keys-completed counter, and elapsed time. + + Parameters + ---------- + console : Console + Rich console to bind the progress bar to. + + Returns + ------- + Progress + Configured progress bar (not yet started). + """ + return Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + console=console, + ) + + +def print_download_complete(output_dir: object, console: Console) -> None: + """Print a green success panel after a completed download. + + Parameters + ---------- + output_dir : path-like + Local directory the store was downloaded to. + console : Console + Rich console to print to. + """ + content = f"[bold green]✓ Download complete[/bold green]\nOutput {output_dir}" + console.print( + Panel(content, title="oz-viewer download", border_style="green", expand=False) + ) + + def _human_bytes(n: int | float) -> str: """Format a byte count as a human-readable string. diff --git a/src/oz_viewer/_download.py b/src/oz_viewer/_download.py new file mode 100644 index 0000000..95fb758 --- /dev/null +++ b/src/oz_viewer/_download.py @@ -0,0 +1,602 @@ +"""Download an OME-Zarr store from S3 or HTTPS to a local directory.""" + +from __future__ import annotations + +import asyncio +import itertools +import math +import sys +from pathlib import Path # noqa: TC003 +from typing import TYPE_CHECKING +from urllib.parse import urlparse + +import aiohttp + +if TYPE_CHECKING: + import tensorstore as ts + import zarr + from rich.console import Console + from rich.progress import Progress, TaskID + + +def _meta_key(rel_path: str, node: zarr.Array | zarr.Group) -> str: + """Return the metadata store key for a zarr node. + + Parameters + ---------- + rel_path : str + Path of the node relative to the store root. Pass an empty string + for the root node itself. + node : zarr.Array or zarr.Group + The opened zarr node whose metadata key is needed. + + Returns + ------- + str + Store key for the node's metadata file, e.g. ``"zarr.json"`` for v3, + ``".zarray"`` or ``".zgroup"`` for v2. + """ + import zarr + + fmt = node.metadata.zarr_format + if fmt == 3: + fname = "zarr.json" + elif isinstance(node, zarr.Array): + fname = ".zarray" + else: + fname = ".zgroup" + return f"{rel_path}/{fname}" if rel_path else fname + + +def _child_paths_from_attrs(attrs: dict) -> list[tuple[str, str]]: + """Inspect OME-Zarr attributes and return child node descriptors. + + Reads both the ``ome`` sub-key (spec v0.5) and the top-level attributes + (older bioformats2raw / v0.4 stores) for maximum compatibility. + + Parameters + ---------- + attrs : dict + Attribute dictionary from a zarr Group node. + + Returns + ------- + list of tuple[str, str] + Each entry is ``(rel_path, hint)`` where *rel_path* is the child path + relative to the current node and *hint* is one of ``"array"``, + ``"group"``, or ``"maybe"`` (attempt to open; silently skip on 404). + + Notes + ----- + Handled OME node types: + + * **Image / LabelImage** — ``multiscales.datasets[*].path`` → arrays; + ``labels/`` → groups. + * **Bf2Raw** — ``series[*]`` → groups. + * **Plate** — ``plate.wells[*].path`` → groups. + * **Well** — ``well.images[*].path`` → groups. + * **LabelsGroup** — ``labels[*]`` → groups. + """ + ome = attrs.get("ome") or {} + merged = {**attrs, **ome} + + results: list[tuple[str, str]] = [] + + for ms in merged.get("multiscales", []): + for ds in ms.get("datasets", []): + path = ds.get("path", "") + if path: + results.append((path, "array")) + if merged.get("multiscales"): + results.append(("labels", "maybe")) + + for label_name in merged.get("labels", []): + results.append((str(label_name), "group")) + + for series_path in merged.get("series", []): + results.append((str(series_path).strip("/"), "group")) + + plate = merged.get("plate") or {} + for well in plate.get("wells", []): + path = well.get("path", "") + if path: + results.append((path, "group")) + + well_meta = merged.get("well") or {} + for img in well_meta.get("images", []): + path = img.get("path", "") + if path: + results.append((path, "group")) + + return results + + +def _array_chunk_keys(rel_path: str, array: zarr.Array) -> list[str]: + """Compute every storage key for a zarr array's chunks. + + Parameters + ---------- + rel_path : str + Path of the array relative to the store root. Pass an empty string + for a root-level array. + array : zarr.Array + The opened zarr array. + + Returns + ------- + list of str + All store keys for the array's chunk objects. Missing chunks + (fill-value only) simply won't exist on the server; the transfer + loop handles them gracefully. + + Notes + ----- + * zarr v3 with default encoding: keys like ``c/0/1/2``. + * zarr v3 with v2 encoding or zarr v2 arrays: keys like ``0.1.2``. + """ + shape = array.shape + chunk_shape = array.chunks + fmt = array.metadata.zarr_format + + if fmt == 3: + enc = array.metadata.chunk_key_encoding + sep: str = enc.separator + default_enc: bool = enc.name == "default" + else: + sep = getattr(array.metadata, "dimension_separator", ".") or "." + default_enc = False + + if not shape: + if fmt == 3 and default_enc: + chunk_key = "c" + elif fmt == 3: + chunk_key = "" + else: + chunk_key = "0" + return [f"{rel_path}/{chunk_key}" if rel_path else chunk_key] + + n_chunks = [ + max(1, math.ceil(s / c)) for s, c in zip(shape, chunk_shape, strict=False) + ] + + keys: list[str] = [] + for indices in itertools.product(*[range(n) for n in n_chunks]): + index_part = sep.join(str(i) for i in indices) + chunk_key = f"c{sep}{index_part}" if (fmt == 3 and default_enc) else index_part + full_key = f"{rel_path}/{chunk_key}" if rel_path else chunk_key + keys.append(full_key) + + return keys + + +def _enumerate_keys_via_zarr(url: str, storage_options: dict) -> list[str]: + """Walk the OME-Zarr hierarchy via BFS and return every store key. + + Child discovery is driven entirely by OME metadata read from individual + ``zarr.json`` / ``.zgroup`` files — ``group.members()`` is never called. + This makes it work over plain HTTPS where directory listing is impossible. + + Parameters + ---------- + url : str + Root URL of the OME-Zarr store. + storage_options : dict + Extra kwargs forwarded to :func:`zarr.open` (e.g. auth headers). + Pass an empty dict for public stores. + + Returns + ------- + list of str + Every store key in the hierarchy (metadata files + chunk objects). + + Notes + ----- + Intended to be called via :func:`asyncio.to_thread` from async code. + """ + import zarr + + keys: list[str] = [] + queue: list[tuple[str, str]] = [(url.rstrip("/"), "")] + visited: set[str] = set() + + while queue: + node_url, key_prefix = queue.pop(0) + + if key_prefix in visited: + continue + visited.add(key_prefix) + + open_kwargs: dict = ( + {"storage_options": storage_options} if storage_options else {} + ) + try: + node = zarr.open(node_url, mode="r", **open_kwargs) + except Exception: + continue + + fmt = node.metadata.zarr_format + keys.append(_meta_key(key_prefix, node)) + + if isinstance(node, zarr.Array): + keys.extend(_array_chunk_keys(key_prefix, node)) + if fmt == 2: + keys.append(f"{key_prefix}/.zattrs" if key_prefix else ".zattrs") + continue + + if fmt == 2: + attr_key = f"{key_prefix}/.zattrs" if key_prefix else ".zattrs" + meta_key = f"{key_prefix}/.zmetadata" if key_prefix else ".zmetadata" + keys.append(attr_key) + keys.append(meta_key) + + attrs = dict(node.attrs) + for child_rel, hint in _child_paths_from_attrs(attrs): + child_key = f"{key_prefix}/{child_rel}" if key_prefix else child_rel + child_url = f"{node_url}/{child_rel}" + if hint == "array": + if child_key in visited: + continue + visited.add(child_key) + try: + arr = zarr.open_array(child_url, mode="r", **open_kwargs) + keys.append(_meta_key(child_key, arr)) + keys.extend(_array_chunk_keys(child_key, arr)) + if arr.metadata.zarr_format == 2: + keys.append(f"{child_key}/.zattrs" if child_key else ".zattrs") + except Exception: + pass + else: + queue.append((child_url, child_key)) + + return keys + + +def _enumerate_keys_via_s3fs(bucket: str, path: str, anon: bool) -> list[str]: + """List every object under an S3 prefix using s3fs. + + Parameters + ---------- + bucket : str + S3 bucket name. + path : str + Object prefix path within the bucket (without leading slash). + anon : bool + ``True`` for anonymous (unsigned) access; ``False`` for credentialed + access using the default AWS credential chain. + + Returns + ------- + list of str + Store-relative keys for every object found under the prefix (the full + S3 object keys with the ``//`` prefix stripped). + + Notes + ----- + Intended to be called via :func:`asyncio.to_thread` from async code. + Requires the optional ``s3fs`` package. + """ + import s3fs + + fs = s3fs.S3FileSystem(anon=anon) + root = f"{bucket}/{path.strip('/')}" + all_paths: list[str] = fs.find(root, detail=False) + prefix = root + "/" + return [p.removeprefix(prefix) for p in all_paths if not p.endswith("/")] + + +async def enumerate_keys(url: str, scheme: str, anon: bool) -> list[str]: + """Enumerate all store keys for an OME-Zarr store. + + Dispatches to an S3 listing (fast, complete) for ``s3://`` URLs or a + zarr BFS hierarchy walk for ``https://`` / ``http://`` URLs. + + Parameters + ---------- + url : str + Root URL of the OME-Zarr store. + scheme : str + URL scheme, one of ``"s3"``, ``"https"``, or ``"http"``. + anon : bool + Anonymous S3 access. Ignored for non-S3 URLs. + + Returns + ------- + list of str + Every store key (metadata files + chunk objects) in the store. + """ + if scheme == "s3": + print("Enumerating keys via S3 listing...", flush=True) + parsed = urlparse(url) + bucket = parsed.netloc + path = parsed.path.lstrip("/") + keys = await asyncio.to_thread(_enumerate_keys_via_s3fs, bucket, path, anon) + else: + print("Enumerating keys via zarr hierarchy walk...", flush=True) + keys = await asyncio.to_thread(_enumerate_keys_via_zarr, url, {}) + + print(f" Found {len(keys):,} keys.", flush=True) + return keys + + +async def _open_src_kvstore(url: str, scheme: str) -> ts.KvStore: + """Open a read-only tensorstore KvStore for an S3 source. + + Parameters + ---------- + url : str + Root URL of the OME-Zarr store. Must be an ``s3://`` URL. + scheme : str + URL scheme; must be ``"s3"``. + + Returns + ------- + tensorstore.KvStore + An open, read-only KvStore pointing at the S3 prefix. + + Raises + ------ + AssertionError + If *scheme* is not ``"s3"``. + """ + import tensorstore as ts + + assert scheme == "s3", "_open_src_kvstore is S3-only" + parsed = urlparse(url) + bucket = parsed.netloc + path = parsed.path.lstrip("/").rstrip("/") + "/" + spec: dict = {"driver": "s3", "bucket": bucket, "path": path} + return await ts.KvStore.open(spec) + + +async def _open_dst_kvstore(output_dir: Path) -> ts.KvStore: + """Open a writable tensorstore KvStore for a local destination directory. + + Parameters + ---------- + output_dir : Path + Local directory that will receive the downloaded store. The + directory must already exist. + + Returns + ------- + tensorstore.KvStore + An open, writable KvStore backed by the local filesystem. + """ + import tensorstore as ts + + path = str(output_dir.resolve()).rstrip("/") + "/" + return await ts.KvStore.open({"driver": "file", "path": path}) + + +async def _transfer_http( + base_url: str, + dst_kv: ts.KvStore, + keys: list[str], + concurrency: int, + progress: Progress, + task_id: TaskID, +) -> None: + """Transfer keys from an HTTPS/HTTP source to a local KvStore. + + Uses :mod:`aiohttp` for downloading; tensorstore's HTTP KvStore driver + fails silently on some S3-compatible HTTPS endpoints so :mod:`aiohttp` + is used for all HTTP/HTTPS transfers. + + Parameters + ---------- + base_url : str + Root URL of the OME-Zarr store (HTTPS or HTTP). + dst_kv : tensorstore.KvStore + Open writable KvStore for the local destination. + keys : list of str + Store keys to transfer. + concurrency : int + Maximum number of simultaneous requests. + progress : rich.progress.Progress + Active Rich progress instance to update. + task_id : rich.progress.TaskID + Task identifier within *progress* to advance. + + Raises + ------ + RuntimeError + If any key fails to transfer. + """ + sem = asyncio.Semaphore(concurrency) + failures: list[tuple[str, Exception]] = [] + total_bytes = 0 + base = base_url.rstrip("/") + + async with aiohttp.ClientSession() as session: + + async def _fetch_one(key: str) -> None: + nonlocal total_bytes + async with sem: + try: + url = f"{base}/{key}" + async with session.get(url) as resp: + if resp.status == 200: + value = await resp.read() + await dst_kv.write(key, value) + total_bytes += len(value) + progress.update( + task_id, + advance=1, + description=( + f"Downloading {total_bytes / 1_000_000:.1f} MB" + ), + ) + elif resp.status == 404: + progress.update(task_id, advance=1) + else: + raise RuntimeError(f"HTTP {resp.status} for {url}") + except Exception as exc: + failures.append((key, exc)) + progress.update(task_id, advance=1) + + await asyncio.gather(*(_fetch_one(k) for k in keys)) + + if failures: + n = len(failures) + print(f"\n ⚠ {n} key(s) failed to transfer:", file=sys.stderr) + for key, exc in failures[:10]: + print(f" {key!r}: {exc}", file=sys.stderr) + if n > 10: + print(f" … and {n - 10} more.", file=sys.stderr) + raise RuntimeError(f"{n} key(s) failed during transfer.") + + +async def transfer( + src_kv: ts.KvStore, + dst_kv: ts.KvStore, + keys: list[str], + concurrency: int, + progress: Progress, + task_id: TaskID, +) -> None: + """Copy keys from an S3 KvStore to a local KvStore concurrently. + + Parameters + ---------- + src_kv : tensorstore.KvStore + Open read-only KvStore for the S3 source. + dst_kv : tensorstore.KvStore + Open writable KvStore for the local destination. + keys : list of str + Store keys to copy. + concurrency : int + Maximum number of simultaneous read/write pairs. + progress : rich.progress.Progress + Active Rich progress instance to update. + task_id : rich.progress.TaskID + Task identifier within *progress* to advance. + + Notes + ----- + Missing keys (fill-value chunks absent from the store) are silently + skipped — they need not be written locally either. + + Raises + ------ + RuntimeError + If any keys fail after a single attempt. + """ + sem = asyncio.Semaphore(concurrency) + failures: list[tuple[str, Exception]] = [] + total_bytes = 0 + + async def _copy_one(key: str) -> None: + nonlocal total_bytes + async with sem: + try: + result = await src_kv.read(key) + value = bytes(result.value) if result.value else b"" + if value: + nb = len(value) + await dst_kv.write(key, value) + total_bytes += nb + progress.update( + task_id, + advance=1, + description=f"Downloading {total_bytes / 1_000_000:.1f} MB", + ) + else: + progress.update(task_id, advance=1) + except Exception as exc: + failures.append((key, exc)) + progress.update(task_id, advance=1) + + await asyncio.gather(*(_copy_one(k) for k in keys)) + + if failures: + n = len(failures) + print(f"\n ⚠ {n} key(s) failed to transfer:", file=sys.stderr) + for key, exc in failures[:10]: + print(f" {key!r}: {exc}", file=sys.stderr) + if n > 10: + print(f" … and {n - 10} more.", file=sys.stderr) + raise RuntimeError(f"{n} key(s) failed during transfer.") + + +async def download( + url: str, + output_dir: Path, + concurrency: int = 32, + anon: bool = True, + dry_run: bool = False, + console: Console | None = None, +) -> None: + """Download an OME-Zarr store to a local directory. + + The caller is responsible for creating *output_dir* and handling any + overwrite logic before calling this function. On success the directory + contains a complete local copy of the store. + + Parameters + ---------- + url : str + Source URL — ``s3://``, ``https://``, or ``http://``. + output_dir : Path + Local destination directory. Must already exist. + concurrency : int, optional + Maximum simultaneous chunk transfers, by default 32. + anon : bool, optional + Use anonymous S3 access, by default ``True``. + dry_run : bool, optional + Enumerate keys and probe the first 3, but do not write anything, + by default ``False``. + console : rich.console.Console, optional + Console for progress output. A default console is created when + ``None``. + + Raises + ------ + RuntimeError + If any keys fail to transfer. + ValueError + If the URL scheme is not ``"s3"``, ``"https"``, or ``"http"``. + """ + from rich.console import Console as RichConsole + + from oz_viewer._display import make_download_progress + + if console is None: + console = RichConsole() + + parsed = urlparse(url.rstrip("/")) + scheme = parsed.scheme.lower() + + if scheme not in ("s3", "https", "http"): + raise ValueError( + f"Unsupported URL scheme {scheme!r}. Use s3://, https://, or http://." + ) + + keys = await enumerate_keys(url, scheme, anon=anon) + + src_kv = await _open_src_kvstore(url, scheme) if scheme == "s3" else None + dst_kv = await _open_dst_kvstore(output_dir) + + if dry_run: + console.print(" Dry run — probing first 3 keys...", style="dim") + async with aiohttp.ClientSession() as session: + for probe_key in keys[:3]: + if scheme == "s3" and src_kv is not None: + result = await src_kv.read(probe_key) + value = bytes(result.value) if result.value else b"" + else: + probe_url = f"{url.rstrip('/')}/{probe_key}" + async with session.get(probe_url) as resp: + value = await resp.read() if resp.status == 200 else b"" + status = f"{len(value):,} bytes" if value else "MISSING (0 bytes)" + console.print(f" {probe_key!r}: {status}", style="dim") + console.print(" Dry run complete. No files written.", style="dim") + return + + progress = make_download_progress(console) + with progress: + task_id = progress.add_task("Downloading", total=len(keys)) + if scheme != "s3": + await _transfer_http(url, dst_kv, keys, concurrency, progress, task_id) + else: + assert src_kv is not None + await transfer(src_kv, dst_kv, keys, concurrency, progress, task_id) diff --git a/tests/conftest.py b/tests/conftest.py index 3b180e1..133ee45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,16 @@ from __future__ import annotations +import functools +import threading +import time +from http.server import HTTPServer, SimpleHTTPRequestHandler from typing import TYPE_CHECKING, Literal import pytest if TYPE_CHECKING: - from collections.abc import Callable + from collections.abc import Callable, Generator from pathlib import Path @@ -34,3 +38,34 @@ def _factory(store_type: Literal["image", "plate"] = "image") -> Path: return path return _factory + + +@pytest.fixture +def slow_http_store(tmp_path: Path) -> Generator[Callable[[Path], str], None, None]: + """Serve tmp_path over HTTP with a per-request delay. + + Starts a local HTTP server that sleeps 100 ms before each response, making + a ``--timeout 0`` ping reliably time out without any monkeypatching. + + Yields a callable that converts a local path under tmp_path to its HTTP URL. + """ + + class _SlowHandler(SimpleHTTPRequestHandler): + def do_GET(self) -> None: + time.sleep(0.1) + super().do_GET() + + def log_message(self, *args: object) -> None: + pass + + handler = functools.partial(_SlowHandler, directory=str(tmp_path)) + server = HTTPServer(("127.0.0.1", 0), handler) + port: int = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + + def to_url(local_path: Path) -> str: + return f"http://127.0.0.1:{port}/{local_path.relative_to(tmp_path)}" + + yield to_url + + server.shutdown() diff --git a/tests/test_cli.py b/tests/test_cli.py index 6aa9782..bc4a7e5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -106,9 +106,10 @@ def test_ping_default_n_fetch(write_demo_ome): assert "5" in result.output -def test_ping_timeout(write_demo_ome): +def test_ping_timeout(write_demo_ome, slow_http_store): path = write_demo_ome("image") - result = runner.invoke(app, ["ping", str(path), "--timeout", "0"]) + url = slow_http_store(path) + result = runner.invoke(app, ["ping", url, "--timeout", "0", "--n-fetch", "1"]) assert result.exit_code == 0 assert "Timeouts" in result.output From 595e8d13cd0643c7062206de6ebcbedc72025f58 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 4 May 2026 18:57:17 +0200 Subject: [PATCH 6/6] update test matrix --- .github/workflows/ci.yml | 2 +- pyproject.toml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9dc093..4438e5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12", "3.13", "3.14"] + python-version: ["3.11", "3.12", "3.13",] platform: [ubuntu-latest, macos-latest, windows-latest] steps: diff --git a/pyproject.toml b/pyproject.toml index e412aae..2de0e36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Typing :: Typed", ] # add your package dependencies here @@ -41,7 +40,6 @@ dependencies = [ "numpy >= 1.24", "cellier[pyside]>=0.0.13", "jupyterlab>=4.5.6", - # download command "aiohttp >= 3.9", "zarr >= 3.0", "s3fs",