diff --git a/pyproject.toml b/pyproject.toml index a944276..bb027e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "typer >= 0.12", "rich >= 13", "numpy >= 1.24", - "cellier[pyside]>=0.0.18", + "cellier[pyside]>=0.0.19", "jupyterlab>=4.5.6", "aiohttp >= 3.9", "zarr >= 3.0", diff --git a/src/oz_viewer/_cli.py b/src/oz_viewer/_cli.py index 170bdee..39e51be 100644 --- a/src/oz_viewer/_cli.py +++ b/src/oz_viewer/_cli.py @@ -240,6 +240,128 @@ def ortho( launch_orthoviewer(zarr_uri, channel_axis=multichannel, theme=theme, perf=perf) +@app.command() +def view( + 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, + multichannel: Annotated[ + int | None, + typer.Option( + "--multichannel", + help=( + "Dimension index to treat as the channel axis, enabling multichannel" + " mode. If omitted, the channel axis is auto-detected from the" + " OME-Zarr metadata when present." + ), + show_default=False, + ), + ] = None, + theme: Annotated[ + str, + typer.Option( + "--theme", + help=( + "Theme name to apply. Run 'oz-viewer theme list' to see" + " available themes." + ), + ), + ] = "dark", + perf_startup: Annotated[ + bool, + typer.Option( + "--perf-startup/--no-perf-startup", + help="Enable startup performance logging diagnostics.", + ), + ] = False, + perf_log_file: Annotated[ + Path | None, + typer.Option( + "--perf-log-file", + help="Write startup performance logs to a file instead of stderr.", + show_default=False, + ), + ] = None, + perf_table: Annotated[ + bool, + typer.Option( + "--perf-table/--no-perf-table", + help="Display startup timings as a Rich table at startup completion.", + ), + ] = False, + perf_table_title: Annotated[ + str, + typer.Option( + "--perf-table-title", + help="Title used for the startup performance Rich table.", + ), + ] = "Viewer startup timings", +) -> None: + """Open an OME-Zarr store in the 2D / 3D toggle viewer.""" + from oz_viewer.viewer import launch_viewer + + perf_enabled = perf_startup or perf_enabled_from_env() + configure_perf_logging( + enabled=perf_enabled, + log_file=str(perf_log_file) if perf_log_file is not None else None, + ) + perf = StartupPerfTracer( + enabled=perf_enabled, + show_table=perf_table, + table_title=perf_table_title, + ) + perf.mark("cli.view.start", make_example=make_example, theme=theme) + + if make_example: + from oz_viewer.data._blobs import make_example_zarr + + zarr_path = make_example_zarr() + zarr_uri = f"file://{zarr_path}" + perf.mark("cli.view.example_created", zarr_path=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) + perf.mark("cli.view.uri_resolved", zarr_uri=zarr_uri) + + perf.mark("cli.view.launch") + launch_viewer(zarr_uri, channel_axis=multichannel, theme=theme, perf=perf) + + @app.command(name="theme") def theme_cmd( action: Annotated[ diff --git a/src/oz_viewer/viewer/__init__.py b/src/oz_viewer/viewer/__init__.py index f40b39f..9c3f386 100644 --- a/src/oz_viewer/viewer/__init__.py +++ b/src/oz_viewer/viewer/__init__.py @@ -6,10 +6,20 @@ launch_orthoviewer, orthoviewer, ) +from oz_viewer.viewer._viewer import ( + OmeZarrViewer, + build_viewer_model, + launch_viewer, + viewer, +) __all__ = [ "OmeZarrOrthoViewer", + "OmeZarrViewer", "build_ortho_viewer_model", + "build_viewer_model", "launch_orthoviewer", + "launch_viewer", "orthoviewer", + "viewer", ] diff --git a/src/oz_viewer/viewer/_orthoviewer.py b/src/oz_viewer/viewer/_orthoviewer.py index ef13af6..0430900 100644 --- a/src/oz_viewer/viewer/_orthoviewer.py +++ b/src/oz_viewer/viewer/_orthoviewer.py @@ -14,6 +14,21 @@ from oz_viewer._perf import StartupPerfTracer +from oz_viewer.viewer._utils import ( + _asyncio_exception_handler, + _dtype_clim_max, + _dtype_decimals, + _ensure_qt_app, + _perf_mark, +) +from oz_viewer.viewer._widgets import ( + _DEFAULT_COLORMAPS, + _MultiVisualClimSlider, + _MultiVisualColormapCombo, + _MultiVisualLodBiasSlider, + build_channel_list_widget, +) + # --------------------------------------------------------------------------- # Slider color styles for each 2D panel # --------------------------------------------------------------------------- @@ -108,31 +123,6 @@ class _WorldGeometry(NamedTuple): n_channels: int -_DEFAULT_COLORMAPS: list[str] = [ - "viridis", - "plasma", - "white", - "green", - "blue", - "red", - "magenta", - "cyan", - "bop_blue", - "bop_orange", - "bop_purple", - "i_blue", - "i_bordeaux", - "i_cyan", - "i_forest", - "i_green", - "i_magenta", - "i_orange", - "i_purple", - "i_red", - "i_yellow", -] - - @dataclass class _VolTransparencyProfile: transparency_mode: str @@ -298,226 +288,6 @@ def update_opacity(self, opacity: float) -> None: self.apply() -# --------------------------------------------------------------------------- -# 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, - ) - ) - - def _on_visual_changed(self, event) -> None: - if event.source_id == self._id: - return - if event.field_name != "clim": - return - self._slider.blockSignals(True) - self._slider.setValue(event.new_value) - self._slider.blockSignals(False) - - def subscription_specs(self) -> list: - from cellier.v2.events import AppearanceChangedEvent, SubscriptionSpec - - if not self._visual_ids: - return [] - return [ - SubscriptionSpec( - event_type=AppearanceChangedEvent, - handler=self._on_visual_changed, - entity_id=self._visual_ids[0], - ) - ] - - @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.addColormaps(_DEFAULT_COLORMAPS) - 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, - ) - ) - - def _on_visual_changed(self, event) -> None: - if event.source_id == self._id: - return - if event.field_name != "color_map": - return - self._combo.blockSignals(True) - self._combo.setCurrentColormap(event.new_value) - self._combo.blockSignals(False) - - def subscription_specs(self) -> list: - from cellier.v2.events import AppearanceChangedEvent, SubscriptionSpec - - if not self._visual_ids: - return [] - return [ - SubscriptionSpec( - event_type=AppearanceChangedEvent, - handler=self._on_visual_changed, - entity_id=self._visual_ids[0], - ) - ] - - @property - def widget(self): - return self._combo - - def close(self) -> None: - self.closed.emit() - - -class _MultiVisualLodBiasSlider: - """LOD-bias slider that updates multiple visuals at once.""" - - from psygnal import Signal - - changed = Signal(object) - closed = Signal() - - def __init__( - self, - visual_ids: list, - *, - initial_lod_bias: float = 1.0, - lod_range: tuple[float, float] = (1e-6, 5.0), - decimals: int = 2, - parent=None, - ) -> None: - from cellier.v2.events import AppearanceUpdateEvent - from qtpy.QtCore import Qt - from superqt import QLabeledDoubleSlider - - self._id = uuid4() - self._visual_ids = visual_ids - self._AppearanceUpdateEvent = AppearanceUpdateEvent - - self._slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal, parent) - self._slider.setRange(*lod_range) - self._slider.setDecimals(decimals) - self._slider.setValue(initial_lod_bias) - - # Fire only on release to avoid a reslice on every drag tick. - self._slider.sliderReleased.connect(self._on_released) - - def _on_released(self) -> None: - value = self._slider.value() - for vid in self._visual_ids: - self.changed.emit( - self._AppearanceUpdateEvent( - source_id=self._id, - visual_id=vid, - field="lod_bias", - value=value, - ) - ) - - def _on_visual_changed(self, event) -> None: - if event.source_id == self._id: - return - if event.field_name != "lod_bias": - return - self._slider.blockSignals(True) - self._slider.setValue(event.new_value) - self._slider.blockSignals(False) - - def subscription_specs(self) -> list: - from cellier.v2.events import AppearanceChangedEvent, SubscriptionSpec - - if not self._visual_ids: - return [] - return [ - SubscriptionSpec( - event_type=AppearanceChangedEvent, - handler=self._on_visual_changed, - entity_id=self._visual_ids[0], - ) - ] - - @property - def widget(self): - return self._slider - - def close(self) -> None: - self.closed.emit() - - # --------------------------------------------------------------------------- # Main viewer class # --------------------------------------------------------------------------- @@ -876,8 +646,11 @@ def _populate_mc_page(self, mc_page_layout) -> None: from PySide6.QtCore import Qt as _Qt mc_page_layout.setAlignment(_Qt.AlignmentFlag.AlignTop) - for i, ch in self._channel_appearances.items(): - mc_page_layout.addWidget(self._build_channel_group(i, ch)) + mc_page_layout.addWidget( + build_channel_list_widget( + self._channel_appearances, self._clim_range, self._slider_decimals + ) + ) if self._spatial_ndim == 3: mc_page_layout.addWidget(self._build_mc_3d_group()) mc_page_layout.addStretch() @@ -968,7 +741,7 @@ def _build_multichannel(self) -> None: self._channel_appearances = { i: ChannelAppearance( - colormap=colormaps[i % len(colormaps)], + color_map=colormaps[i % len(colormaps)], clim=(0.0, initial_clim_max), ) for i in range(self._n_channels) @@ -1168,78 +941,6 @@ def _on_plane_opacity(value: float) -> None: return group - def _build_channel_group(self, ch_idx: int, ch_appearance) -> QtWidgets.QGroupBox: - from PySide6 import QtWidgets - from PySide6.QtCore import Qt - from superqt import QLabeledDoubleRangeSlider, QLabeledDoubleSlider - from superqt.cmap import QColormapComboBox - - group = QtWidgets.QGroupBox(f"Channel {ch_idx}") - layout = QtWidgets.QVBoxLayout(group) - - # Visibility - vis_cb = QtWidgets.QCheckBox("Visible") - vis_cb.setChecked(ch_appearance.visible) - vis_cb.stateChanged.connect( - lambda state, _ch=ch_appearance: setattr(_ch, "visible", bool(state)) - ) - ch_appearance.events.visible.connect( - lambda v, _cb=vis_cb: ( - _cb.blockSignals(True), - _cb.setChecked(v), - _cb.blockSignals(False), - ) - ) - layout.addWidget(vis_cb) - - # Colormap - combo = QColormapComboBox() - combo.addColormaps(_DEFAULT_COLORMAPS) - combo.setCurrentColormap(ch_appearance.colormap) - combo.currentColormapChanged.connect( - lambda cmap, _ch=ch_appearance: setattr(_ch, "colormap", cmap) - ) - ch_appearance.events.colormap.connect( - lambda v, _c=combo: _c.setCurrentColormap(v) - ) - layout.addWidget(combo) - - # Contrast limits - clim_slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal) - clim_slider.setDecimals(self._slider_decimals) - clim_slider.setRange(*self._clim_range) - clim_slider.setValue(ch_appearance.clim) - clim_slider.valueChanged.connect( - lambda v, _ch=ch_appearance: setattr(_ch, "clim", tuple(v)) - ) - ch_appearance.events.clim.connect( - lambda v, _s=clim_slider: ( - _s.blockSignals(True), - _s.setValue(v), - _s.blockSignals(False), - ) - ) - layout.addWidget(clim_slider) - - # Opacity - opacity_slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) - opacity_slider.setRange(0.0, 1.0) - opacity_slider.setSingleStep(0.05) - opacity_slider.setValue(ch_appearance.opacity) - opacity_slider.valueChanged.connect( - lambda v, _ch=ch_appearance: setattr(_ch, "opacity", v) - ) - ch_appearance.events.opacity.connect( - lambda v, _s=opacity_slider: ( - _s.blockSignals(True), - _s.setValue(v), - _s.blockSignals(False), - ) - ) - layout.addWidget(opacity_slider) - - return group - # ------------------------------------------------------------------ @property @@ -1862,31 +1563,6 @@ def on_yz_dims_changed(self, event) -> None: self._propagate(self._yz_scene_id, event) -# --------------------------------------------------------------------------- -# 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 - - -def _perf_mark( - perf: StartupPerfTracer | None, - step: str, - /, - **fields: object, -) -> None: - if perf is not None: - perf.mark(step, **fields) - - # --------------------------------------------------------------------------- # Layer 1: ViewerModel builder # --------------------------------------------------------------------------- @@ -2269,34 +1945,9 @@ def _make_2d_visual(name: str) -> MultiscaleImageVisual: # --------------------------------------------------------------------------- -# Layer 2: Qt bootstrap helper +# Layer 2: Qt bootstrap helper (imported from _utils) # --------------------------------------------------------------------------- - -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) # --------------------------------------------------------------------------- @@ -2345,35 +1996,6 @@ def orthoviewer( # --------------------------------------------------------------------------- -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, theme: str = "dark", @@ -2552,7 +2174,7 @@ def _first_visual(scene): colormaps = _DEFAULT_COLORMAPS initial_channel_appearances = { i: ChannelAppearance( - colormap=colormaps[i % len(colormaps)], + color_map=colormaps[i % len(colormaps)], clim=(0.0, initial_clim_max), ) for i in range(n_channels) diff --git a/src/oz_viewer/viewer/_utils.py b/src/oz_viewer/viewer/_utils.py new file mode 100644 index 0000000..645db8b --- /dev/null +++ b/src/oz_viewer/viewer/_utils.py @@ -0,0 +1,79 @@ +"""Shared utilities used by both the single-panel viewer and the orthoviewer.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from oz_viewer._perf import StartupPerfTracer + + +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 + + +def _perf_mark(perf: StartupPerfTracer | None, step: str, /, **fields: object) -> None: + if perf is not None: + perf.mark(step, **fields) + + +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 + + +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__) diff --git a/src/oz_viewer/viewer/_viewer.py b/src/oz_viewer/viewer/_viewer.py new file mode 100644 index 0000000..e4dc7b1 --- /dev/null +++ b/src/oz_viewer/viewer/_viewer.py @@ -0,0 +1,947 @@ +"""OmeZarrViewer: single-panel 2D / 3D toggle viewer for OME-Zarr images.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, NamedTuple + +import numpy as np + +if TYPE_CHECKING: + from oz_viewer._perf import StartupPerfTracer + +from oz_viewer.viewer._utils import ( + _asyncio_exception_handler, + _dtype_clim_max, + _dtype_decimals, + _ensure_qt_app, + _perf_mark, +) +from oz_viewer.viewer._widgets import ( + _DEFAULT_COLORMAPS, + build_channel_list_widget, +) + +# --------------------------------------------------------------------------- +# Geometry descriptor +# --------------------------------------------------------------------------- + + +class _ViewerGeometry(NamedTuple): + """Scene geometry derived from OME-Zarr metadata; no Qt objects.""" + + spatial_indices: list[int] + spatial_ndim: int + displayed_axes_2d: tuple[int, ...] + displayed_axes_3d: tuple[int, ...] # equals displayed_axes_2d when spatial_ndim < 3 + world_max_spatial: np.ndarray + voxel_to_world: object + axis_ranges: dict[int, tuple[float, float]] + initial_slice_indices_2d: dict[int, float] + initial_slice_indices_3d: dict[int, float] + initial_clim_max: float + clim_range: tuple[float, float] + slider_decimals: int + channel_axis: int | None + n_channels: int + + +# --------------------------------------------------------------------------- +# Main viewer class +# --------------------------------------------------------------------------- + + +class OmeZarrViewer: + """Single-panel 2D / 3D toggle viewer for OME-Zarr images. + + For interactive use (IPython / Jupyter) call :func:`viewer`. + For scripts and the CLI call :func:`launch_viewer`. + """ + + def __init__( + self, + controller, + scene, + canvas_widget, + visual_model, + geometry: _ViewerGeometry, + data_store=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 + from PySide6.QtWidgets import QStackedWidget + + self._controller = controller + self._scene = scene + self._canvas_widget = canvas_widget + self._visual_model = visual_model + self._geo = geometry + self._data_store = data_store + + si = geometry.spatial_indices + # The axis that moves between displayed and sliced on 2D ↔ 3D toggle. + self._sz0: int | None = si[-3] if geometry.spatial_ndim >= 3 else None + self._displayed_axes_2d = geometry.displayed_axes_2d + self._displayed_axes_3d = geometry.displayed_axes_3d + self._active_mode = "2d" + + # Independent LOD bias values per mode. + self._lod_bias_2d: float = visual_model.appearance.lod_bias + self._lod_bias_3d: float = visual_model.appearance.lod_bias + + # Z world-coord to restore when re-entering 2D mode. + self._saved_z: float = ( + float(geometry.initial_slice_indices_2d.get(self._sz0, 0)) + if self._sz0 is not None + else 0.0 + ) + + # Multichannel state. + self._mode_channel: str = "single" + self._mc_visual_id = None + self._channel_appearances: dict | None = None + self._mc_built: bool = False + # Saved channel index to restore when switching back to SC. + self._saved_channel: float = ( + float(geometry.initial_slice_indices_2d.get(geometry.channel_axis, 0)) + if geometry.channel_axis is not None + else 0.0 + ) + + # ── Shared controls (single visual → auto-synced across modes) ─── + clim_range = geometry.clim_range + slider_decimals = geometry.slider_decimals + + self._clim_slider = QtClimRangeSlider( + visual_model.id, + clim_range=clim_range, + initial_clim=visual_model.appearance.clim, + decimals=slider_decimals, + ) + controller.connect_widget( + self._clim_slider, + subscription_specs=self._clim_slider.subscription_specs(), + ) + + self._colormap_combo = QtColormapComboBox( + visual_model.id, + initial_colormap=visual_model.appearance.color_map, + ) + self._colormap_combo.add_colormaps(_DEFAULT_COLORMAPS) + controller.connect_widget( + self._colormap_combo, + subscription_specs=self._colormap_combo.subscription_specs(), + ) + + # ── 3D-only render controls ─────────────────────────────────────── + self._render_controls = QtVolumeRenderControls( + visual_model.id, + dtype_max=clim_range[1], + initial_render_mode=visual_model.appearance.render_mode, + initial_threshold=visual_model.appearance.iso_threshold, + initial_attenuation=visual_model.appearance.attenuation, + decimals=slider_decimals, + ) + controller.connect_widget( + self._render_controls, + subscription_specs=self._render_controls.subscription_specs(), + ) + + # ── Qt window ───────────────────────────────────────────────────── + self._window = QtWidgets.QMainWindow() + self._window.setWindowTitle("OME-Zarr Viewer") + self._window.resize(1100, 750) + + central = QtWidgets.QWidget() + self._window.setCentralWidget(central) + root_layout = QtWidgets.QHBoxLayout(central) + + # Side panel on the left. + panel = QtWidgets.QWidget() + panel.setFixedWidth(300) + panel_layout = QtWidgets.QVBoxLayout(panel) + panel_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + root_layout.addWidget(panel) + root_layout.addWidget(canvas_widget.widget, stretch=1) + + # ── 2D/3D toggle button ─────────────────────────────────────────── + self._toggle_btn = QtWidgets.QPushButton("Switch to 3D") + self._toggle_btn.clicked.connect(self._on_toggle_clicked) + if geometry.spatial_ndim < 3: + self._toggle_btn.setEnabled(False) + self._toggle_btn.setToolTip( + "3D view requires at least 3 spatial dimensions" + ) + panel_layout.addWidget(self._toggle_btn) + + self._mode_label = QtWidgets.QLabel("Mode: 2D") + panel_layout.addWidget(self._mode_label) + + # ── SC/MC toggle button (only when a channel axis exists) ───────── + if geometry.channel_axis is not None: + self._toggle_mc_btn = QtWidgets.QPushButton("Switch to Multichannel") + self._toggle_mc_btn.clicked.connect(self._on_mc_toggle_clicked) + panel_layout.addWidget(self._toggle_mc_btn) + else: + self._toggle_mc_btn = None + + # ── Outer stacked widget: page 0 = single-channel, page 1 = MC ─── + self._channel_stack = QStackedWidget() + panel_layout.addWidget(self._channel_stack) + + # ── SC page (page 0) ────────────────────────────────────────────── + sc_page = QtWidgets.QWidget() + sc_layout = QtWidgets.QVBoxLayout(sc_page) + sc_layout.setContentsMargins(0, 0, 0, 0) + sc_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + + clim_group = QtWidgets.QGroupBox("Contrast limits") + QtWidgets.QVBoxLayout(clim_group).addWidget(self._clim_slider.widget) + sc_layout.addWidget(clim_group) + + cmap_group = QtWidgets.QGroupBox("Colormap") + QtWidgets.QVBoxLayout(cmap_group).addWidget(self._colormap_combo.widget) + sc_layout.addWidget(cmap_group) + + # Inner stacked widget: 2D/3D mode-specific controls. + self._mode_stack = QStackedWidget() + sc_layout.addWidget(self._mode_stack) + + # Page 0 — 2D controls + page_2d = QtWidgets.QWidget() + layout_2d = QtWidgets.QVBoxLayout(page_2d) + layout_2d.setContentsMargins(0, 0, 0, 0) + layout_2d.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + lod_2d_group = QtWidgets.QGroupBox("Fine-coarse tile bias") + lod_2d_group.setToolTip("Bigger values use coarser tiles") + self._lod_bias_2d_slider = self._make_lod_slider("2d") + QtWidgets.QVBoxLayout(lod_2d_group).addWidget(self._lod_bias_2d_slider) + layout_2d.addWidget(lod_2d_group) + self._mode_stack.addWidget(page_2d) + + # Page 1 — 3D controls + page_3d = QtWidgets.QWidget() + layout_3d = QtWidgets.QVBoxLayout(page_3d) + layout_3d.setContentsMargins(0, 0, 0, 0) + layout_3d.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + render_group = QtWidgets.QGroupBox("Render mode") + QtWidgets.QVBoxLayout(render_group).addWidget(self._render_controls.widget) + layout_3d.addWidget(render_group) + lod_3d_group = QtWidgets.QGroupBox("Fine-coarse tile bias") + lod_3d_group.setToolTip("Bigger values use coarser tiles") + self._lod_bias_3d_slider = self._make_lod_slider("3d") + QtWidgets.QVBoxLayout(lod_3d_group).addWidget(self._lod_bias_3d_slider) + layout_3d.addWidget(lod_3d_group) + self._mode_stack.addWidget(page_3d) + + self._mode_stack.setCurrentIndex(0) + self._channel_stack.addWidget(sc_page) + + # ── MC page (page 1) — populated lazily on first toggle ─────────── + self._mc_page = QtWidgets.QWidget() + self._mc_page_layout = QtWidgets.QVBoxLayout(self._mc_page) + self._mc_page_layout.setContentsMargins(0, 0, 0, 0) + self._mc_page_layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignTop) + self._channel_stack.addWidget(self._mc_page) + + self._channel_stack.setCurrentIndex(0) + + # ── Camera settle threshold ─────────────────────────────────────────── + settle_group = QtWidgets.QGroupBox("Camera settle (ms)") + settle_layout = QtWidgets.QVBoxLayout(settle_group) + from PySide6.QtWidgets import QDoubleSpinBox + + self._settle_sb = QDoubleSpinBox() + self._settle_sb.setRange(50.0, 2000.0) + self._settle_sb.setSingleStep(50.0) + self._settle_sb.setDecimals(0) + self._settle_sb.setValue(300.0) + self._settle_sb.valueChanged.connect( + lambda v: setattr(controller, "camera_settle_threshold_s", v / 1000.0) + ) + settle_layout.addWidget(self._settle_sb) + panel_layout.addWidget(settle_group) + + panel_layout.addStretch() + + # ------------------------------------------------------------------ + # LOD bias helpers + # ------------------------------------------------------------------ + + def _make_lod_slider(self, mode: str): + from qtpy.QtCore import Qt + from superqt import QLabeledDoubleSlider + + initial = self._lod_bias_2d if mode == "2d" else self._lod_bias_3d + slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) + slider.setRange(1e-6, 5.0) + slider.setDecimals(2) + slider.setValue(initial) + + def _on_released() -> None: + if self._active_mode != mode: + return + value = slider.value() + if mode == "2d": + self._lod_bias_2d = value + else: + self._lod_bias_3d = value + self._controller.update_appearance_field( + self._visual_model.id, "lod_bias", value + ) + + slider.sliderReleased.connect(_on_released) + return slider + + # ------------------------------------------------------------------ + # Mode toggle + # ------------------------------------------------------------------ + + def _on_toggle_clicked(self) -> None: + from cellier.v2.events import DimsUpdateEvent + + current_slice = dict(self._scene.dims.selection.slice_indices) + self._controller.cancel_pending_slices(self._scene.id) + in_sc = self._mode_channel == "single" + stacked = ( + (self._geo.channel_axis,) + if not in_sc and self._geo.channel_axis is not None + else None + ) + + if self._active_mode == "2d": + # Save the Z world-coord before it leaves slice_indices. + if self._sz0 is not None: + self._saved_z = float( + current_slice.get( + self._sz0, + self._geo.initial_slice_indices_2d.get(self._sz0, 0), + ) + ) + # sz0 becomes displayed in 3D — drop it from slice_indices. + new_slice = {k: v for k, v in current_slice.items() if k != self._sz0} + + # Update LOD bias only when the SC visual is active. + if in_sc: + self._lod_bias_2d = self._lod_bias_2d_slider.value() + self._controller.update_appearance_field( + self._visual_model.id, "lod_bias", self._lod_bias_3d + ) + self._lod_bias_3d_slider.blockSignals(True) + self._lod_bias_3d_slider.setValue(self._lod_bias_3d) + self._lod_bias_3d_slider.blockSignals(False) + + self._controller.incoming_events.emit( + DimsUpdateEvent( + source_id=self._controller._id, + scene_id=self._scene.id, + slice_indices=new_slice, + displayed_axes=self._displayed_axes_3d, + stacked_axes=stacked, + ) + ) + self._active_mode = "3d" + self._mode_label.setText("Mode: 3D") + self._toggle_btn.setText("Switch to 2D") + if in_sc: + self._mode_stack.setCurrentIndex(1) + + else: + # Restore sz0 into slice_indices. + new_slice = dict(current_slice) + if self._sz0 is not None: + new_slice[self._sz0] = self._saved_z + + # Update LOD bias only when the SC visual is active. + if in_sc: + self._lod_bias_3d = self._lod_bias_3d_slider.value() + self._controller.update_appearance_field( + self._visual_model.id, "lod_bias", self._lod_bias_2d + ) + self._lod_bias_2d_slider.blockSignals(True) + self._lod_bias_2d_slider.setValue(self._lod_bias_2d) + self._lod_bias_2d_slider.blockSignals(False) + + self._controller.incoming_events.emit( + DimsUpdateEvent( + source_id=self._controller._id, + scene_id=self._scene.id, + slice_indices=new_slice, + displayed_axes=self._displayed_axes_2d, + stacked_axes=stacked, + ) + ) + self._active_mode = "2d" + self._mode_label.setText("Mode: 2D") + self._toggle_btn.setText("Switch to 3D") + if in_sc: + self._mode_stack.setCurrentIndex(0) + + # ------------------------------------------------------------------ + # Multichannel toggle + # ------------------------------------------------------------------ + + def _on_mc_toggle_clicked(self) -> None: + to_mc = self._mode_channel == "single" + + if to_mc: + if not self._mc_built: + self._build_mc_visual() + self._build_mc_page() + # Hide SC visual (has .appearance), show each MC channel. + self._controller.update_appearance_field( + self._visual_model.id, "visible", False + ) + self._set_mc_visible(True) + # Move channel axis from slice_indices → stacked_axes so the slider hides. + ch = self._geo.channel_axis + if ch is not None: + current = dict(self._scene.dims.selection.slice_indices) + self._saved_channel = float(current.get(ch, self._saved_channel)) + current.pop(ch, None) + self._controller.update_slice_indices(self._scene.id, current) + self._controller.update_stacked_axes(self._scene.id, (ch,)) + self._channel_stack.setCurrentIndex(1) + self._mode_channel = "multichannel" + if self._toggle_mc_btn is not None: + self._toggle_mc_btn.setText("Switch to Single Channel") + else: + # Hide each MC channel, show SC visual. + self._set_mc_visible(False) + self._controller.update_appearance_field( + self._visual_model.id, "visible", True + ) + # Move channel axis from stacked_axes → slice_indices + # so the slider reappears. + ch = self._geo.channel_axis + if ch is not None: + self._controller.update_stacked_axes(self._scene.id, ()) + current = dict(self._scene.dims.selection.slice_indices) + current[ch] = self._saved_channel + self._controller.update_slice_indices(self._scene.id, current) + self._channel_stack.setCurrentIndex(0) + # Sync the 2D/3D inner stack to the current active mode — it + # was not updated while _channel_stack was on the MC page. + self._mode_stack.setCurrentIndex(0 if self._active_mode == "2d" else 1) + self._mode_channel = "single" + if self._toggle_mc_btn is not None: + self._toggle_mc_btn.setText("Switch to Multichannel") + + def _set_mc_visible(self, visible: bool) -> None: + """Set visible on every ChannelAppearance of the MC visual.""" + mc_model = self._controller.get_visual_model(self._mc_visual_id) + for ch in mc_model.channels.values(): + ch.visible = visible + + def _build_mc_visual(self) -> None: + from cellier.v2.visuals._channel_appearance import ChannelAppearance + from cellier.v2.visuals._image import MultiscaleImageRenderConfig + + geo = self._geo + channel_appearances = { + i: ChannelAppearance( + color_map=_DEFAULT_COLORMAPS[i % len(_DEFAULT_COLORMAPS)], + clim=(0.0, geo.initial_clim_max), + visible=False, + ) + for i in range(geo.n_channels) + } + render_config = MultiscaleImageRenderConfig( + block_size=32, + gpu_budget_bytes=512 * 1024**2, + gpu_budget_bytes_2d=64 * 1024**2, + ) + mc_visual = self._controller.add_multichannel_image_multiscale( + data=self._data_store, + scene_id=self._scene.id, + channel_axis=geo.channel_axis, + channels=channel_appearances, + name="multichannel_volume", + render_config=render_config, + transform=geo.voxel_to_world, + ) + self._mc_visual_id = mc_visual.id + self._channel_appearances = channel_appearances + self._mc_built = True + + def _build_mc_page(self) -> None: + geo = self._geo + self._mc_page_layout.addWidget( + build_channel_list_widget( + self._channel_appearances, geo.clim_range, geo.slider_decimals + ) + ) + + # ------------------------------------------------------------------ + + @property + def window(self): + return self._window + + def close_widgets(self) -> None: + self._canvas_widget.close() + self._clim_slider.close() + self._colormap_combo.close() + self._render_controls.close() + + +# --------------------------------------------------------------------------- +# Layer 1: ViewerModel builder (no Qt) +# --------------------------------------------------------------------------- + + +def build_viewer_model( + zarr_uri: str, + *, + channel_axis: int | None = None, + perf: StartupPerfTracer | None = None, +) -> tuple: + """Build a ViewerModel for the viewer without constructing any Qt objects. + + Parameters + ---------- + zarr_uri : str + Path or URI to the OME-Zarr store. + channel_axis : int or None, optional + Axis index to treat as the channel dimension. When ``None`` (default), + the channel axis is auto-detected from the OME-Zarr axis metadata. + perf : StartupPerfTracer | None, optional + Optional startup performance tracer. + + Returns + ------- + tuple[cellier.v2.viewer_model.ViewerModel, _ViewerGeometry] + """ + 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 ( + MultiscaleImageAppearance, + MultiscaleImageRenderConfig, + MultiscaleImageVisual, + ) + from rich.console import Console + from rich.table import Table + + _perf_mark(perf, "viewer.model.start", zarr_uri=zarr_uri) + data_store = OMEZarrImageDataStore.from_path(zarr_uri) + _perf_mark(perf, "viewer.model.data_store_ready", n_levels=data_store.n_levels) + + group = yaozarrs.open_group(data_store.zarr_path) + ome_image = group.ome_metadata() + ms = ome_image.multiscales[data_store.multiscale_index] + + n_dims = len(data_store.level_shapes[0]) + + # Detect channel axis from OME-Zarr axis metadata when not explicitly set. + if channel_axis is None: + effective_channel_axis: int | None = None + for idx, ax in enumerate(ms.axes): + if getattr(ax, "type", None) == "channel": + effective_channel_axis = idx + break + else: + effective_channel_axis = channel_axis + channel_axis = effective_channel_axis + + spatial_indices: list[int] = [i for i in range(n_dims) if i != channel_axis] + spatial_ndim = len(spatial_indices) + + level_0_scale_full = np.array( + ms.datasets[0].scale_transform.scale, dtype=np.float64 + ) + level_0_scale_spatial = level_0_scale_full[spatial_indices] + + vox_shape_spatial = np.array( + [data_store.level_shapes[0][i] for i in spatial_indices], dtype=np.float64 + ) + world_extents_spatial = vox_shape_spatial * level_0_scale_spatial + max_extent = float(world_extents_spatial.max()) + depth_range = (max(1.0, max_extent * 0.0001), max_extent * 10.0) + + vox_shape_full = np.array(data_store.level_shapes[0], dtype=np.float64) + world_max_full = (vox_shape_full - 1) * level_0_scale_full + world_max_spatial = (vox_shape_spatial - 1) * level_0_scale_spatial + + # Print startup summary. + spatial_label = "spatial" if channel_axis is not None else "ZYX"[-spatial_ndim:] + table = Table( + title=f"OME-Zarr [dim]{zarr_uri}[/dim]", + show_header=False, + box=None, + padding=(0, 2), + ) + table.add_column("Field", style="bold cyan", no_wrap=True) + table.add_column("Value") + table.add_row("dtype", str(data_store.dtype)) + table.add_row("axes", " ".join(data_store.axis_names)) + table.add_row("units", " ".join(str(u) for u in data_store.axis_units)) + table.add_row("levels", str(data_store.n_levels)) + for i, shape in enumerate(data_store.level_shapes): + table.add_row(f" level {i}", str(list(shape))) + table.add_row( + f"scale ({spatial_label})", + " ".join(f"{v:.4g}" for v in level_0_scale_spatial), + ) + table.add_row( + f"world extents ({spatial_label})", + " ".join(f"{v:.4g}" for v in world_extents_spatial), + ) + table.add_row("depth range", f"near={depth_range[0]:.2f} far={depth_range[1]:.0f}") + Console().print(table) + _perf_mark(perf, "viewer.model.metadata_printed") + + cs = CoordinateSystem(name="world", axis_labels=tuple(data_store.axis_names)) + voxel_to_world = AffineTransform.from_scale_and_translation( + scale=tuple(level_0_scale_full) + ) + + initial_clim_max = _dtype_clim_max(data_store.dtype) + slider_decimals = _dtype_decimals(data_store.dtype) + n_channels = ( + int(data_store.level_shapes[0][channel_axis]) if channel_axis is not None else 0 + ) + + # axis_ranges covers all axes; dims sliders appear for non-displayed axes. + axis_ranges = {i: (0.0, round(float(world_max_full[i]))) for i in range(n_dims)} + + def _mid(ax: int) -> float: + return round(float(world_max_full[ax]) / 2.0) + + # 2D: display last 2 spatial axes; 3D: display last 3 spatial axes. + if spatial_ndim >= 2: + displayed_axes_2d: tuple[int, ...] = tuple(spatial_indices[-2:]) + else: + displayed_axes_2d = tuple(spatial_indices) + + if spatial_ndim >= 3: + displayed_axes_3d: tuple[int, ...] = tuple(spatial_indices[-3:]) + else: + displayed_axes_3d = displayed_axes_2d + + _set_2d = set(displayed_axes_2d) + _set_3d = set(displayed_axes_3d) + initial_slice_indices_2d = {i: _mid(i) for i in range(n_dims) if i not in _set_2d} + initial_slice_indices_3d = {i: _mid(i) for i in range(n_dims) if i not in _set_3d} + + # Single visual for both 2D and 3D rendering. + visual = MultiscaleImageVisual( + name="volume", + data_store_id=str(data_store.id), + level_transforms=data_store.level_transforms, + appearance=MultiscaleImageAppearance( + color_map="viridis", + clim=(0.0, initial_clim_max), + lod_bias=1.5, + force_level=None, + frustum_cull=True, + iso_threshold=initial_clim_max / 2.0, + render_mode="mip", + attenuation=1.0, + ), + render_config=MultiscaleImageRenderConfig( + block_size=32, + gpu_budget_bytes=2048 * 1024**2, + gpu_budget_bytes_2d=64 * 1024**2, + ), + transform=voxel_to_world, + ) + + # Single canvas: orthographic camera for 2D, perspective for 3D. + canvas = Canvas( + cameras={ + "2d": OrthographicCamera( + near_clipping_plane=depth_range[0], + far_clipping_plane=depth_range[1], + controller=PanZoomCameraController(enabled=True), + ), + "3d": PerspectiveCamera( + fov=70.0, + near_clipping_plane=depth_range[0], + far_clipping_plane=depth_range[1], + controller=OrbitCameraController(enabled=True), + ), + } + ) + + # Scene supports both render modes; starts in 2D. + scene = Scene( + name="main", + dims=DimsManager( + coordinate_system=cs, + selection=AxisAlignedSelection( + displayed_axes=displayed_axes_2d, + slice_indices=initial_slice_indices_2d, + ), + ), + render_modes={"2d", "3d"}, + lighting="none", + visuals=[visual], + canvases={canvas.id: canvas}, + ) + + viewer_model = ViewerModel( + data=DataManager(stores={data_store.id: data_store}), + scenes={scene.id: scene}, + ) + _perf_mark(perf, "viewer.model.ready") + + geometry = _ViewerGeometry( + spatial_indices=spatial_indices, + spatial_ndim=spatial_ndim, + displayed_axes_2d=displayed_axes_2d, + displayed_axes_3d=displayed_axes_3d, + world_max_spatial=world_max_spatial, + voxel_to_world=voxel_to_world, + axis_ranges=axis_ranges, + initial_slice_indices_2d=initial_slice_indices_2d, + initial_slice_indices_3d=initial_slice_indices_3d, + initial_clim_max=initial_clim_max, + clim_range=(0.0, initial_clim_max), + slider_decimals=slider_decimals, + channel_axis=channel_axis, + n_channels=n_channels, + ) + return viewer_model, geometry + + +# --------------------------------------------------------------------------- +# Layer 2: Qt bootstrap helper (imported from _utils) +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Layer 3: Non-blocking show (interactive / Jupyter) +# --------------------------------------------------------------------------- + + +def viewer( + zarr_uri: str, + theme: str = "dark", +) -> OmeZarrViewer: + """Open a viewer 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 :func:`launch_viewer`. + + Parameters + ---------- + 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 + ------- + OmeZarrViewer + 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_viewer() for scripts, or run inside IPython/Jupyter." + ) + return _build_and_show_viewer(zarr_uri, theme=theme) + + +# --------------------------------------------------------------------------- +# Layer 4: Private async core +# --------------------------------------------------------------------------- + + +async def _run_viewer_async( + zarr_uri: str, + theme: str = "dark", + *, + channel_axis: int | None = None, + perf: StartupPerfTracer | None = None, +) -> None: + import asyncio as _asyncio + + from PySide6.QtWidgets import QApplication + + _asyncio.get_event_loop().set_exception_handler(_asyncio_exception_handler) + _perf_mark(perf, "viewer.async.start", theme=theme) + + v = _build_and_show_viewer( + zarr_uri, theme=theme, channel_axis=channel_axis, perf=perf + ) + _perf_mark(perf, "viewer.async.build_complete") + + app = QApplication.instance() + close_event = asyncio.Event() + app.aboutToQuit.connect(close_event.set) + app.aboutToQuit.connect(v.close_widgets) + await close_event.wait() + + +# --------------------------------------------------------------------------- +# Layer 5: Blocking launcher (scripts / CLI) +# --------------------------------------------------------------------------- + + +def launch_viewer( + zarr_uri: str, + theme: str = "dark", + *, + channel_axis: int | None = None, + perf: StartupPerfTracer | None = None, +) -> None: + """Open a viewer 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 :func:`viewer` instead. + + Parameters + ---------- + 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. + channel_axis : int or None, optional + Axis index to treat as the channel dimension. When ``None`` (default), + the channel axis is auto-detected from the OME-Zarr axis metadata. + perf : StartupPerfTracer | None, optional + Optional startup performance tracer. + """ + import sys + + import fsspec.asyn as _fsspec_asyn + import PySide6.QtAsyncio as QtAsyncio + from PySide6.QtWidgets import QApplication + + _fsspec_asyn.get_loop() + + _perf_mark(perf, "viewer.launch.start", theme=theme) + app = QApplication.instance() or QApplication([sys.argv[0]]) # noqa: F841 + _perf_mark(perf, "viewer.launch.qapp_ready") + QtAsyncio.run( + _run_viewer_async(zarr_uri, theme=theme, channel_axis=channel_axis, perf=perf), + handle_sigint=True, + ) + + +# --------------------------------------------------------------------------- +# Shared builder (used by both viewer() and _run_viewer_async()) +# --------------------------------------------------------------------------- + + +def _build_and_show_viewer( + zarr_uri: str, + theme: str = "dark", + *, + channel_axis: int | None = None, + perf: StartupPerfTracer | None = None, +) -> OmeZarrViewer: + """Build the full viewer from a zarr URI and show the window.""" + from PySide6.QtWidgets import QApplication + + from oz_viewer.theme import apply_theme + + _perf_mark(perf, "viewer.build.start", theme=theme) + apply_theme(QApplication.instance(), theme) + _perf_mark(perf, "viewer.build.theme_applied") + + from cellier.v2.controller import CellierController + from cellier.v2.gui._scene import QtCanvasWidget + from cellier.v2.render._config import ( + RenderManagerConfig, + SlicingConfig, + TemporalAccumulationConfig, + ) + + viewer_model, geometry = build_viewer_model( + zarr_uri, channel_axis=channel_axis, perf=perf + ) + _perf_mark(perf, "viewer.build.model_ready") + data_store = next(iter(viewer_model.data.stores.values())) + + controller = CellierController.from_model( + viewer_model, + render_config=RenderManagerConfig( + slicing=SlicingConfig(batch_size=32, render_every=4), + temporal=TemporalAccumulationConfig(enabled=False), + ), + widget_parent=None, + ) + _perf_mark(perf, "viewer.build.controller_ready") + + controller.camera_reslice_enabled = True + controller.camera_settle_threshold_s = 0.3 + + scene = controller.get_scene_by_name("main") + visual_model = next(iter(scene.visuals)) + + canvas_id = controller.get_canvas_ids(scene.id)[0] + canvas_view = controller.get_canvas_view(canvas_id) + canvas_widget = QtCanvasWidget.from_scene_and_canvas( + scene, canvas_view, axis_ranges=geometry.axis_ranges + ) + controller.connect_widget( + canvas_widget.dims_sliders, + subscription_specs=canvas_widget.dims_sliders.subscription_specs(), + ) + _perf_mark(perf, "viewer.build.canvas_widget_ready") + + v = OmeZarrViewer( + controller=controller, + scene=scene, + canvas_widget=canvas_widget, + visual_model=visual_model, + geometry=geometry, + data_store=data_store, + ) + + # Perf: track time to first paint. + if perf is not None and perf.enabled: + from PySide6.QtCore import QEvent, QObject, QTimer + + settled_timer = QTimer() + settled_timer.setSingleShot(True) + settled_timer.setInterval(300) + + def _on_settled() -> None: + _perf_mark(perf, "viewer.canvas.startup_settled", quiet_ms=300) + perf.report_rich_table() + + settled_timer.timeout.connect(_on_settled) + + class _PaintTracker(QObject): + def eventFilter(self, watched, event): + if event.type() == QEvent.Type.Paint: + _perf_mark(perf, "viewer.canvas.first_paint") + settled_timer.start() + canvas_widget.widget.removeEventFilter(self) + return False + + paint_tracker = _PaintTracker() + canvas_widget.widget.installEventFilter(paint_tracker) + v._startup_perf_objects = (paint_tracker, settled_timer) + + v.window.show() + _perf_mark(perf, "viewer.window.show_called") + + controller.fit_camera(scene.id) + controller.reslice_scene(scene.id) + + return v diff --git a/src/oz_viewer/viewer/_widgets.py b/src/oz_viewer/viewer/_widgets.py new file mode 100644 index 0000000..c4092ac --- /dev/null +++ b/src/oz_viewer/viewer/_widgets.py @@ -0,0 +1,362 @@ +"""Shared GUI widgets used by both the single-panel viewer and the orthoviewer.""" + +from __future__ import annotations + +from uuid import uuid4 + +_DEFAULT_COLORMAPS: list[str] = [ + "viridis", + "plasma", + "grays", + "white", + "green", + "blue", + "red", + "magenta", + "cyan", + "bop_blue", + "bop_orange", + "bop_purple", + "i_blue", + "i_bordeaux", + "i_cyan", + "i_forest", + "i_green", + "i_magenta", + "i_orange", + "i_purple", + "i_red", + "i_yellow", +] + + +# --------------------------------------------------------------------------- +# Multi-visual control widgets +# --------------------------------------------------------------------------- + + +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, + ) + ) + + def _on_visual_changed(self, event) -> None: + if event.source_id == self._id: + return + if event.field_name != "clim": + return + self._slider.blockSignals(True) + self._slider.setValue(event.new_value) + self._slider.blockSignals(False) + + def subscription_specs(self) -> list: + from cellier.v2.events import AppearanceChangedEvent, SubscriptionSpec + + if not self._visual_ids: + return [] + return [ + SubscriptionSpec( + event_type=AppearanceChangedEvent, + handler=self._on_visual_changed, + entity_id=self._visual_ids[0], + ) + ] + + @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.addColormaps(_DEFAULT_COLORMAPS) + 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, + ) + ) + + def _on_visual_changed(self, event) -> None: + if event.source_id == self._id: + return + if event.field_name != "color_map": + return + self._combo.blockSignals(True) + self._combo.setCurrentColormap(event.new_value) + self._combo.blockSignals(False) + + def subscription_specs(self) -> list: + from cellier.v2.events import AppearanceChangedEvent, SubscriptionSpec + + if not self._visual_ids: + return [] + return [ + SubscriptionSpec( + event_type=AppearanceChangedEvent, + handler=self._on_visual_changed, + entity_id=self._visual_ids[0], + ) + ] + + @property + def widget(self): + return self._combo + + def close(self) -> None: + self.closed.emit() + + +class _MultiVisualLodBiasSlider: + """LOD-bias slider that updates multiple visuals at once.""" + + from psygnal import Signal + + changed = Signal(object) + closed = Signal() + + def __init__( + self, + visual_ids: list, + *, + initial_lod_bias: float = 1.0, + lod_range: tuple[float, float] = (1e-6, 5.0), + decimals: int = 2, + parent=None, + ) -> None: + from cellier.v2.events import AppearanceUpdateEvent + from qtpy.QtCore import Qt + from superqt import QLabeledDoubleSlider + + self._id = uuid4() + self._visual_ids = visual_ids + self._AppearanceUpdateEvent = AppearanceUpdateEvent + + self._slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal, parent) + self._slider.setRange(*lod_range) + self._slider.setDecimals(decimals) + self._slider.setValue(initial_lod_bias) + + # Fire only on release to avoid a reslice on every drag tick. + self._slider.sliderReleased.connect(self._on_released) + + def _on_released(self) -> None: + value = self._slider.value() + for vid in self._visual_ids: + self.changed.emit( + self._AppearanceUpdateEvent( + source_id=self._id, + visual_id=vid, + field="lod_bias", + value=value, + ) + ) + + def _on_visual_changed(self, event) -> None: + if event.source_id == self._id: + return + if event.field_name != "lod_bias": + return + self._slider.blockSignals(True) + self._slider.setValue(event.new_value) + self._slider.blockSignals(False) + + def subscription_specs(self) -> list: + from cellier.v2.events import AppearanceChangedEvent, SubscriptionSpec + + if not self._visual_ids: + return [] + return [ + SubscriptionSpec( + event_type=AppearanceChangedEvent, + handler=self._on_visual_changed, + entity_id=self._visual_ids[0], + ) + ] + + @property + def widget(self): + return self._slider + + def close(self) -> None: + self.closed.emit() + + +# --------------------------------------------------------------------------- +# Per-channel control builders +# --------------------------------------------------------------------------- + + +def build_channel_group( + ch_idx: int, + ch_appearance, + clim_range: tuple[float, float], + slider_decimals: int, +): + """Group for visibility, colormap, clim, and opacity controls for 1 channel.""" + from PySide6 import QtWidgets + from PySide6.QtCore import Qt + from superqt import QLabeledDoubleRangeSlider, QLabeledDoubleSlider + from superqt.cmap import QColormapComboBox + + group = QtWidgets.QGroupBox(f"Channel {ch_idx}") + layout = QtWidgets.QVBoxLayout(group) + + vis_cb = QtWidgets.QCheckBox("Visible") + vis_cb.setChecked(ch_appearance.visible) + vis_cb.stateChanged.connect( + lambda state, _ch=ch_appearance: setattr(_ch, "visible", bool(state)) + ) + ch_appearance.events.visible.connect( + lambda v, _cb=vis_cb: ( + _cb.blockSignals(True), + _cb.setChecked(v), + _cb.blockSignals(False), + ) + ) + layout.addWidget(vis_cb) + + combo = QColormapComboBox() + combo.addColormaps(_DEFAULT_COLORMAPS) + combo.setCurrentColormap(ch_appearance.color_map) + combo.currentColormapChanged.connect( + lambda cmap, _ch=ch_appearance: setattr(_ch, "color_map", cmap) + ) + ch_appearance.events.color_map.connect(lambda v, _c=combo: _c.setCurrentColormap(v)) + layout.addWidget(combo) + + clim_slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal) + clim_slider.setDecimals(slider_decimals) + clim_slider.setRange(*clim_range) + clim_slider.setValue(ch_appearance.clim) + clim_slider.valueChanged.connect( + lambda v, _ch=ch_appearance: setattr(_ch, "clim", tuple(v)) + ) + ch_appearance.events.clim.connect( + lambda v, _s=clim_slider: ( + _s.blockSignals(True), + _s.setValue(v), + _s.blockSignals(False), + ) + ) + layout.addWidget(clim_slider) + + opacity_slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) + opacity_slider.setRange(0.0, 1.0) + opacity_slider.setSingleStep(0.05) + opacity_slider.setValue(ch_appearance.opacity) + opacity_slider.valueChanged.connect( + lambda v, _ch=ch_appearance: setattr(_ch, "opacity", v) + ) + ch_appearance.events.opacity.connect( + lambda v, _s=opacity_slider: ( + _s.blockSignals(True), + _s.setValue(v), + _s.blockSignals(False), + ) + ) + layout.addWidget(opacity_slider) + + return group + + +def build_channel_list_widget( + channel_appearances: dict, + clim_range: tuple[float, float], + slider_decimals: int, +): + """Return a widget containing per-channel control groups. + + Uses a QScrollArea when there are more than 3 channels so the panel does + not overflow; otherwise returns a plain container widget. + """ + from PySide6 import QtWidgets + from PySide6.QtCore import Qt + + use_scroll = len(channel_appearances) > 3 + + container = QtWidgets.QWidget() + container_layout = QtWidgets.QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + for i, ch in channel_appearances.items(): + container_layout.addWidget( + build_channel_group(i, ch, clim_range, slider_decimals) + ) + + if not use_scroll: + return container + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + scroll.setWidget(container) + return scroll