Skip to content

Commit 21b2590

Browse files
Update orthoviewer (#7)
* intermediate transparenecy ui * add startup logging * improve GUI * add perf monitoring * initial multichannel support * improve multiscale support * asyncio compat * compat with 0.0.18 * bump cellier * add lod bias control * update api
1 parent e7200b5 commit 21b2590

6 files changed

Lines changed: 2240 additions & 366 deletions

File tree

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@
88

99
A viewer for ome-zarr images.
1010

11+
## Orthoviewer startup performance diagnostics
12+
13+
Use the orthoviewer performance flags to print startup timings
14+
with step and cumulative durations:
15+
16+
```sh
17+
oz-viewer ortho /path/to/data.zarr --perf-startup --perf-table --perf-table-title "My Startup Profile"
18+
```
19+
1120
## Development
1221

1322
The easiest way to get started is to use the [github cli](https://cli.github.com)

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ dependencies = [
3838
"typer >= 0.12",
3939
"rich >= 13",
4040
"numpy >= 1.24",
41-
"cellier[pyside]>=0.0.13",
41+
"cellier[pyside]>=0.0.18",
4242
"jupyterlab>=4.5.6",
4343
"aiohttp >= 3.9",
4444
"zarr >= 3.0",
@@ -155,3 +155,5 @@ OME = "OME"
155155
ome = "ome"
156156
# lod = Level of Detail (graphics term), not a typo for "load"
157157
lod = "lod"
158+
# nd = N-dimensional (numpy/array convention), not a typo for "and"
159+
nd = "nd"

src/oz_viewer/_cli.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
print_ping_results,
1919
print_success_panel,
2020
)
21+
from oz_viewer._perf import (
22+
StartupPerfTracer,
23+
configure_perf_logging,
24+
perf_enabled_from_env,
25+
)
2126

2227
app = typer.Typer(
2328
name="oz-viewer",
@@ -141,6 +146,17 @@ def ortho(
141146
help="Create a synthetic anisotropic OME-Zarr and open it in the viewer.",
142147
),
143148
] = False,
149+
multichannel: Annotated[
150+
int | None,
151+
typer.Option(
152+
"--multichannel",
153+
help=(
154+
"Dimension index to treat as the channel axis, enabling multichannel"
155+
" mode. If omitted, single-channel mode is used."
156+
),
157+
show_default=False,
158+
),
159+
] = None,
144160
theme: Annotated[
145161
str,
146162
typer.Option(
@@ -151,15 +167,57 @@ def ortho(
151167
),
152168
),
153169
] = "dark",
170+
perf_startup: Annotated[
171+
bool,
172+
typer.Option(
173+
"--perf-startup/--no-perf-startup",
174+
help="Enable startup performance logging diagnostics.",
175+
),
176+
] = False,
177+
perf_log_file: Annotated[
178+
Path | None,
179+
typer.Option(
180+
"--perf-log-file",
181+
help="Write startup performance logs to a file instead of stderr.",
182+
show_default=False,
183+
),
184+
] = None,
185+
perf_table: Annotated[
186+
bool,
187+
typer.Option(
188+
"--perf-table/--no-perf-table",
189+
help="Display startup timings as a Rich table at startup completion.",
190+
),
191+
] = False,
192+
perf_table_title: Annotated[
193+
str,
194+
typer.Option(
195+
"--perf-table-title",
196+
help="Title used for the startup performance Rich table.",
197+
),
198+
] = "Orthoviewer startup timings",
154199
) -> None:
155200
"""Open an OME-Zarr store in the 4-panel orthoviewer."""
156201
from oz_viewer.viewer import launch_orthoviewer
157202

203+
perf_enabled = perf_startup or perf_enabled_from_env()
204+
configure_perf_logging(
205+
enabled=perf_enabled,
206+
log_file=str(perf_log_file) if perf_log_file is not None else None,
207+
)
208+
perf = StartupPerfTracer(
209+
enabled=perf_enabled,
210+
show_table=perf_table,
211+
table_title=perf_table_title,
212+
)
213+
perf.mark("cli.ortho.start", make_example=make_example, theme=theme)
214+
158215
if make_example:
159216
from oz_viewer.data._blobs import make_example_zarr
160217

161218
zarr_path = make_example_zarr()
162219
zarr_uri = f"file://{zarr_path}"
220+
perf.mark("cli.ortho.example_created", zarr_path=zarr_path)
163221
else:
164222
raw = path or path_option
165223
if raw is None:
@@ -176,8 +234,10 @@ def ortho(
176234
)
177235
raise typer.Exit(code=1)
178236
zarr_uri = _resolve_zarr_uri(raw)
237+
perf.mark("cli.ortho.uri_resolved", zarr_uri=zarr_uri)
179238

180-
launch_orthoviewer(zarr_uri, theme=theme)
239+
perf.mark("cli.ortho.launch")
240+
launch_orthoviewer(zarr_uri, channel_axis=multichannel, theme=theme, perf=perf)
181241

182242

183243
@app.command(name="theme")

src/oz_viewer/_perf.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""Performance logging utilities for opt-in startup diagnostics."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
import os
7+
from dataclasses import dataclass, field
8+
from time import perf_counter
9+
from uuid import uuid4
10+
11+
_PERF_LOGGER_NAME = "oz_viewer.perf"
12+
_ENV_VAR_PERF = "OZ_VIEWER_PERF"
13+
_TRUTHY = {"1", "true", "yes", "on"}
14+
15+
16+
@dataclass(slots=True)
17+
class _PerfEvent:
18+
step: str
19+
elapsed_s: float
20+
delta_s: float
21+
fields: dict[str, object]
22+
23+
24+
def perf_enabled_from_env() -> bool:
25+
"""Return whether performance logging is enabled through an env var."""
26+
return os.getenv(_ENV_VAR_PERF, "").strip().lower() in _TRUTHY
27+
28+
29+
def configure_perf_logging(
30+
*, enabled: bool, log_file: str | None = None
31+
) -> logging.Logger:
32+
"""Configure the dedicated performance logger.
33+
34+
The logger is isolated from the default logging tree so users only see
35+
performance output when this explicit configuration is enabled.
36+
"""
37+
logger = logging.getLogger(_PERF_LOGGER_NAME)
38+
logger.propagate = False
39+
40+
for handler in list(logger.handlers):
41+
handler.close()
42+
logger.removeHandler(handler)
43+
44+
if not enabled:
45+
logger.setLevel(logging.CRITICAL + 1)
46+
return logger
47+
48+
logger.setLevel(logging.INFO)
49+
handler: logging.Handler
50+
if log_file:
51+
handler = logging.FileHandler(log_file)
52+
else:
53+
handler = logging.StreamHandler()
54+
handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
55+
logger.addHandler(handler)
56+
return logger
57+
58+
59+
@dataclass(slots=True)
60+
class StartupPerfTracer:
61+
"""Lightweight startup milestone tracer for perf diagnostics."""
62+
63+
enabled: bool
64+
show_table: bool = False
65+
table_title: str = "Orthoviewer startup timings"
66+
run_id: str = field(default_factory=lambda: uuid4().hex[:8])
67+
_t0: float = field(default_factory=perf_counter)
68+
_last_elapsed_s: float = 0.0
69+
_events: list[_PerfEvent] = field(default_factory=list)
70+
_table_reported: bool = False
71+
72+
def mark(self, step: str, /, **fields: object) -> None:
73+
"""Emit a perf milestone with elapsed startup time."""
74+
if not self.enabled:
75+
return
76+
77+
logger = logging.getLogger(_PERF_LOGGER_NAME)
78+
if not logger.isEnabledFor(logging.INFO):
79+
return
80+
81+
elapsed = perf_counter() - self._t0
82+
delta = max(0.0, elapsed - self._last_elapsed_s)
83+
self._last_elapsed_s = elapsed
84+
self._events.append(
85+
_PerfEvent(step=step, elapsed_s=elapsed, delta_s=delta, fields=dict(fields))
86+
)
87+
88+
suffix = ""
89+
if fields:
90+
details = " ".join(f"{key}={value}" for key, value in fields.items())
91+
suffix = f" {details}"
92+
logger.info("run=%s +%.3fs step=%s%s", self.run_id, elapsed, step, suffix)
93+
94+
def report_rich_table(self, *, title: str | None = None) -> None:
95+
"""Render a Rich table of step and cumulative startup timings."""
96+
if not self.enabled or not self.show_table or self._table_reported:
97+
return
98+
99+
from rich.console import Console
100+
from rich.table import Table
101+
102+
table = Table(title=title or self.table_title)
103+
table.add_column("#", justify="right", no_wrap=True)
104+
table.add_column("Step")
105+
table.add_column("Step (ms)", justify="right")
106+
table.add_column("Cumulative (ms)", justify="right")
107+
table.add_column("Details")
108+
109+
for idx, event in enumerate(self._events, start=1):
110+
details = " ".join(
111+
f"{key}={value}" for key, value in sorted(event.fields.items())
112+
)
113+
table.add_row(
114+
str(idx),
115+
event.step,
116+
f"{event.delta_s * 1000:.1f}",
117+
f"{event.elapsed_s * 1000:.1f}",
118+
details,
119+
)
120+
121+
Console(stderr=True).print(table)
122+
self._table_reported = True

0 commit comments

Comments
 (0)