From 15b7e5555f6870ebb3cfd83a9a3d8410959dbd43 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Tue, 21 Jul 2026 09:47:23 +0200 Subject: [PATCH 1/8] remove SceneControls --- src/oz_viewer/viewer/_viewer.py | 22 +++++++--------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/oz_viewer/viewer/_viewer.py b/src/oz_viewer/viewer/_viewer.py index 5edb862..710bfbf 100644 --- a/src/oz_viewer/viewer/_viewer.py +++ b/src/oz_viewer/viewer/_viewer.py @@ -2,8 +2,9 @@ Rebuilt on top of :mod:`cellier.convenience`, so the same builder runs under both ``gui="qt"`` (desktop / CLI) and ``gui="anywidget"`` (Jupyter / marimo). -The 2D/3D toggle, appearance controls, and per-channel controls are provided by -cellier's cross-toolkit ``Layout`` docks; this module only supplies the +Appearance controls and per-channel controls are provided by cellier's +cross-toolkit ``Layout`` docks and the 2D/3D toggle by the dims control +embedded in the canvas view; this module only supplies the OME-Zarr-specific geometry (see :mod:`oz_viewer.viewer._geometry`) and the Qt-specific launch niceties oz-viewer cares about (theme, fsspec loop, asyncio exception handling, startup perf tracing). @@ -234,12 +235,7 @@ def build_viewer_layout( The layout spec plus the canvas view/widget (kept by the caller so it can install a paint tracker or avoid GC). """ - from cellier.convenience import ( - AppearanceControls, - ChannelControls, - Layout, - SceneControls, - ) + from cellier.convenience import AppearanceControls, ChannelControls, Layout from cellier.convenience.gui import build_canvas_widget canvas_view = build_canvas_widget( @@ -250,18 +246,14 @@ def build_viewer_layout( ) # Left dock: per-channel controls for multichannel data, otherwise the - # single-channel appearance panel. Bottom dock: 2D/3D toggle (needs >=3 - # spatial axes to be meaningful). + # single-channel appearance panel. The 2D/3D toggle needs no dock of its + # own -- cellier embeds it in the canvas view's dims control. if geometry.channel_axis is not None: left: object = ChannelControls() else: left = AppearanceControls() - docks: dict[str, object] = {"left_dock": left} - if geometry.spatial_ndim >= 3: - docks["bottom_dock"] = SceneControls() - - layout = Layout(center=canvas_view, **docks) + layout = Layout(center=canvas_view, left_dock=left) return layout, canvas_view From 7f867d73445d7288fa4c6165115ee9e3841e75a9 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Tue, 21 Jul 2026 09:50:47 +0200 Subject: [PATCH 2/8] make pyside and cellier.convenience required --- tests/test_display.py | 2 -- tests/test_orthoviewer.py | 3 --- 2 files changed, 5 deletions(-) diff --git a/tests/test_display.py b/tests/test_display.py index 2b922ba..1b17c49 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -11,8 +11,6 @@ import pytest -pytest.importorskip("cellier.convenience") - def test_sidecar_options_false_returns_none(): from oz_viewer.viewer._utils import _sidecar_options diff --git a/tests/test_orthoviewer.py b/tests/test_orthoviewer.py index 73a7c59..9a1529a 100644 --- a/tests/test_orthoviewer.py +++ b/tests/test_orthoviewer.py @@ -14,9 +14,6 @@ import pytest -pytest.importorskip("PySide6") -pytest.importorskip("cellier.convenience") - @pytest.fixture(scope="module") def qapp(): From 6846e4e2279474a0b9957051ead6bb51e8134c1c Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Mon, 14 Sep 2026 14:46:22 +0200 Subject: [PATCH 3/8] update for cellier coordinate system and transform refactor --- src/oz_viewer/viewer/_geometry.py | 110 ++++++-- src/oz_viewer/viewer/_ortho_controls.py | 4 +- src/oz_viewer/viewer/_ortho_overlays.py | 356 +++++++++--------------- src/oz_viewer/viewer/_orthoviewer.py | 23 +- src/oz_viewer/viewer/_viewer.py | 44 +-- tests/test_orthoviewer.py | 48 ++++ 6 files changed, 309 insertions(+), 276 deletions(-) diff --git a/src/oz_viewer/viewer/_geometry.py b/src/oz_viewer/viewer/_geometry.py index 6dfecd4..e2b1ee2 100644 --- a/src/oz_viewer/viewer/_geometry.py +++ b/src/oz_viewer/viewer/_geometry.py @@ -1,11 +1,11 @@ """OME-Zarr metadata extraction shared by the convenience-based viewers. This module holds the pure, Qt-free logic that turns an OME-Zarr store into the -handful of numbers the cellier ``convenience`` API needs: which axis is the -channel axis, the spatial extents, the contrast-limit range, sensible slider -decimals, and the voxel-to-world transform. It has no cellier ``convenience`` -dependency itself so it can be reused by both the single-panel viewer and the -orthoviewer. +handful of objects the cellier ``convenience`` API needs: the world coordinate +system, the voxel-to-world transform, which axis is the channel axis, the +spatial extents, the slider values, the contrast-limit range, and sensible +slider decimals. It builds no viewer itself so it can be reused by both the +single-panel viewer and the orthoviewer. """ from __future__ import annotations @@ -17,8 +17,9 @@ from oz_viewer.viewer._utils import _dtype_clim_max, _dtype_decimals, _perf_mark if TYPE_CHECKING: + from cellier.convenience import ContinuousAxisValues, DiscreteAxisValues from cellier.data.image import OMEZarrImageDataStore - from cellier.transform import AffineTransform + from cellier.transform import AffineTransform, WorldCoordinateSystem from oz_viewer._perf import StartupPerfTracer @@ -31,7 +32,14 @@ class _ViewerGeometry(NamedTuple): spatial_ndim: int channel_axis: int | None n_channels: int + #: The world every scene shares, with the store's axis names, types and + #: units. Pass this exact object to the viewer: a transform names its + #: endpoints by id, so an equal-looking world built elsewhere is rejected. + world: WorldCoordinateSystem + #: Level-0 voxel -> ``world``, scaled by the level-0 OME-Zarr scale. voxel_to_world: AffineTransform + #: World units per level-0 voxel, for every axis. + level_0_scale: np.ndarray #: World-space ``(shape - 1) * scale`` for every axis, used for centering. world_max_full: np.ndarray #: World-space ``(shape - 1) * scale`` for the spatial axes only. @@ -58,27 +66,40 @@ def spatial_axes(self) -> tuple[int, ...]: return tuple(self.spatial_indices) @property - def axis_ranges(self) -> dict[int, tuple[float, float]]: - """World-space ``(0, world_max)`` per axis for the dims-slider ranges. - - Computed directly from the metadata rather than via - ``cellier.convenience.axis_ranges_from_viewer`` so it works for - multichannel visuals too (whose transform is spatial-only and cannot be - mapped against the full-ndim store shape). + def axis_values(self) -> dict[int, ContinuousAxisValues | DiscreteAxisValues]: + """World-space slider values per axis, for the dims sliders. + + The channel axis is discrete -- one stop per channel, so its slider + never rests between two channels. Every other axis (spatial, time) is + continuous over ``[0, world_max]``, from the first voxel centre to the + last. Derived from the metadata rather than via + ``cellier.convenience.axis_values_from_viewer``, which widens each axis + by half a voxel at both ends. """ - return { - i: (0.0, round(float(self.world_max_full[i]))) - for i in range(len(self.axis_names)) - } + from cellier.convenience import ContinuousAxisValues, DiscreteAxisValues + + values: dict[int, ContinuousAxisValues | DiscreteAxisValues] = {} + for axis in range(len(self.axis_names)): + if axis == self.channel_axis: + step = float(self.level_0_scale[axis]) + values[axis] = DiscreteAxisValues( + values=tuple(i * step for i in range(self.n_channels)) + ) + else: + values[axis] = ContinuousAxisValues( + min=0.0, max=float(self.world_max_full[axis]) + ) + return values def center_slice_indices(self) -> dict[int, float]: """World-coordinate midpoints for each spatial axis. - Extra (non-spatial) axes such as channel are intentionally omitted so - they keep their default position of ``0``. + Not rounded: a slice position is a float world coordinate. Extra + (non-spatial) axes such as channel are intentionally omitted so they + keep their default position of ``0``. """ return { - axis: round(float(self.world_max_full[axis]) / 2.0) + axis: float(self.world_max_full[axis]) / 2.0 for axis in self.spatial_indices } @@ -110,7 +131,6 @@ def extract_viewer_geometry( """ import yaozarrs from cellier.data.image import OMEZarrImageDataStore - from cellier.transform import AffineTransform _perf_mark(perf, "geometry.start", zarr_uri=zarr_uri) data_store = OMEZarrImageDataStore.from_path(zarr_uri) @@ -158,9 +178,7 @@ def extract_viewer_geometry( ) _perf_mark(perf, "geometry.metadata_printed") - voxel_to_world = AffineTransform.from_scale_and_translation( - scale=tuple(level_0_scale_full) - ) + world, voxel_to_world = _world_and_transform(data_store, level_0_scale_full) initial_clim_max = _dtype_clim_max(data_store.dtype) slider_decimals = _dtype_decimals(data_store.dtype) @@ -174,7 +192,9 @@ def extract_viewer_geometry( spatial_ndim=spatial_ndim, channel_axis=channel_axis, n_channels=n_channels, + world=world, voxel_to_world=voxel_to_world, + level_0_scale=level_0_scale_full, world_max_full=world_max_full, world_max_spatial=world_max_spatial, initial_clim_max=initial_clim_max, @@ -186,6 +206,48 @@ def extract_viewer_geometry( return data_store, geometry +def _world_and_transform( + data_store: OMEZarrImageDataStore, + level_0_scale: np.ndarray, +) -> tuple[WorldCoordinateSystem, AffineTransform]: + """Build the world system and the level-0 voxel -> world transform. + + The world takes the store's own axes -- names, types and units -- one to + one, so every data axis maps onto the world axis of the same position and + type. The transform is built against the store's level-0 coordinate + system and this world, which is what cellier checks when it is added. + """ + from cellier.transform import AffineTransform, Axis, WorldCoordinateSystem + + world = WorldCoordinateSystem( + name="world", + axes=tuple( + Axis(name=name, axis_type=axis_type, unit=unit) + for name, axis_type, unit in zip( + data_store.axis_names, + data_store.axis_types, + data_store.axis_units, + strict=True, + ) + ), + ) + level_0 = data_store.data_coordinate_systems[0] + voxel_to_world = AffineTransform.from_axis_map( + level_0, + world, + axis_map={ + data_axis.id: world_axis.id + for data_axis, world_axis in zip(level_0.axes, world.axes, strict=True) + }, + scale={ + data_axis.id: float(scale) + for data_axis, scale in zip(level_0.axes, level_0_scale, strict=True) + }, + name="voxel_to_world", + ) + return world, voxel_to_world + + def _print_metadata_table( zarr_uri: str, data_store: OMEZarrImageDataStore, diff --git a/src/oz_viewer/viewer/_ortho_controls.py b/src/oz_viewer/viewer/_ortho_controls.py index 93d8709..6d26457 100644 --- a/src/oz_viewer/viewer/_ortho_controls.py +++ b/src/oz_viewer/viewer/_ortho_controls.py @@ -94,7 +94,7 @@ def build_sc_controls_panel( """ from cellier.gui.qt.visuals import ( QtClimRangeSlider, - QtColormapComboBox, + QtColormapCombo, QtVolumeRenderControls, ) from PySide6 import QtWidgets @@ -153,7 +153,7 @@ def build_sc_controls_panel( closables.append(clim_3d) _add_group(layout_3d, "Contrast limits", clim_3d.widget) - cmap_3d = QtColormapComboBox(vol_id, initial_colormap=vol_app.color_map) + cmap_3d = QtColormapCombo(vol_id, initial_colormap=vol_app.color_map) cmap_3d.add_colormaps(_DEFAULT_COLORMAPS) controller.connect_widget( cmap_3d, subscription_specs=cmap_3d.subscription_specs() diff --git a/src/oz_viewer/viewer/_ortho_overlays.py b/src/oz_viewer/viewer/_ortho_overlays.py index bdd4e34..410ac9a 100644 --- a/src/oz_viewer/viewer/_ortho_overlays.py +++ b/src/oz_viewer/viewer/_ortho_overlays.py @@ -330,38 +330,66 @@ def _make_axis_set_face_colors( ) -def _pad_positions( - positions_3d: np.ndarray, +def _spatial_mesh_store( + positions_zyx: np.ndarray, + indices: np.ndarray, + colors: np.ndarray, + name: str, + *, + world, spatial_axes: tuple[int, int, int], - n_dims: int, -) -> np.ndarray: - """Scatter 3-D local ZYX positions into an N-dim global positions array. +): + """A face-coloured mesh store over the three spatial world axes only. - The three local columns (0=Z, 1=Y, 2=X) are placed at the global axis - indices given by ``spatial_axes``. All other columns remain zero. + Its axes copy the world's spatial axes (name, type, unit), so + :func:`_mesh_to_world` can map them one to one. """ - if n_dims == 3 and spatial_axes == (0, 1, 2): - return positions_3d - out = np.zeros((len(positions_3d), n_dims), dtype=positions_3d.dtype) - out[:, spatial_axes[0]] = positions_3d[:, 0] - out[:, spatial_axes[1]] = positions_3d[:, 1] - out[:, spatial_axes[2]] = positions_3d[:, 2] - return out - - -def _spatial_translation( - z: float, - y: float, - x: float, + from cellier.data.mesh import MeshMemoryStore + + zyx = [world.axes[axis] for axis in spatial_axes] + return MeshMemoryStore( + positions=positions_zyx, + indices=indices, + colors=colors, + colors_layout="face", + name=name, + axis_names=[axis.name for axis in zyx], + axis_types=[axis.axis_type for axis in zyx], + axis_units=[axis.unit for axis in zyx], + ) + + +def _mesh_to_world( + store, + world, spatial_axes: tuple[int, int, int], - n_dims: int, -) -> tuple[float, ...]: - """Build an N-dim translation vector with spatial values at global axis indices.""" - t = np.zeros(n_dims, dtype=np.float64) - t[spatial_axes[0]] = z - t[spatial_axes[1]] = y - t[spatial_axes[2]] = x - return tuple(float(v) for v in t) + translation_zyx=(0.0, 0.0, 0.0), +): + """Place a 3-D ``(z, y, x)`` overlay mesh in the world. + + The mesh has only the three spatial axes; every other world axis (channel, + time, ...) is broadcast, so the overlay exists at every position along + them and needs no update when their sliders move. + """ + from cellier.transform import AffineTransform + + data = store.data_coordinate_system + return AffineTransform.from_axis_map( + data, + world, + axis_map={ + data.axes[i].id: world.axes[axis].id for i, axis in enumerate(spatial_axes) + }, + translation={ + data.axes[i].id: float(value) for i, value in enumerate(translation_zyx) + }, + broadcast_output_axes=[ + world.axes[axis].id + for axis in range(world.ndim) + if axis not in spatial_axes + ], + name="overlay_to_world", + ) def _make_axis_meshes( @@ -371,12 +399,10 @@ def _make_axis_meshes( world_min_extent: float, *, spatial_axes: tuple[int, int, int], - n_dims: int, ) -> tuple: - from cellier.data.mesh import MeshMemoryStore - from cellier.transform import AffineTransform from cellier.visuals import MeshFlatAppearance + world = vol_scene.dims.world_coordinate_system color_z = _PLANE_COLOR_XY color_y = _PLANE_COLOR_XZ color_x = _PLANE_COLOR_YZ @@ -391,25 +417,23 @@ def _make_axis_meshes( cube_side = _AXIS_3D_CUBE_SIDE_FRACTION * world_min_extent prism_cross_section = _AXIS_3D_PRISM_CROSS_SECTION_FRACTION * world_min_extent - initial_translation = _spatial_translation( - float(initial_centre_zyx[0]), - float(initial_centre_zyx[1]), - float(initial_centre_zyx[2]), - spatial_axes, - n_dims, - ) - initial_transform = AffineTransform.from_translation(initial_translation) - axis_stores = [] axis_visuals = [] for view_name, axis_a, axis_b, color_a, color_b in view_specifications: - positions_3d, indices = _make_axis_set_geometry( + positions, indices = _make_axis_set_geometry( axis_a, axis_b, axis_length, cube_side, prism_cross_section ) - positions = _pad_positions(positions_3d, spatial_axes, n_dims) face_colors = _make_axis_set_face_colors(color_a, color_b) - store = MeshMemoryStore( - positions=positions, indices=indices, colors=face_colors, name=view_name + store = _spatial_mesh_store( + positions, + indices, + face_colors, + view_name, + world=world, + spatial_axes=spatial_axes, + ) + initial_transform = _mesh_to_world( + store, world, spatial_axes, initial_centre_zyx ) appearance = MeshFlatAppearance( color_mode="face", @@ -439,32 +463,29 @@ def _make_plane_positions( y_world: float, x_world: float, world_max_zyx: np.ndarray, - *, - spatial_axes: tuple[int, int, int] = (0, 1, 2), - n_dims: int = 3, ) -> np.ndarray: + """``(12, 3)`` ``(z, y, x)`` vertices of the three slice planes.""" wz = float(world_max_zyx[0]) wy = float(world_max_zyx[1]) wx = float(world_max_zyx[2]) z, y, x = float(z_world), float(y_world), float(x_world) - sz0, sz1, sz2 = spatial_axes - positions = np.zeros((12, n_dims), dtype=np.float32) + positions = np.zeros((12, 3), dtype=np.float32) # XY plane (constant Z = z) - positions[0:4, sz0] = z - positions[0:4, sz1] = [0.0, wy, wy, 0.0] - positions[0:4, sz2] = [0.0, 0.0, wx, wx] + positions[0:4, 0] = z + positions[0:4, 1] = [0.0, wy, wy, 0.0] + positions[0:4, 2] = [0.0, 0.0, wx, wx] # XZ plane (constant Y = y) - positions[4:8, sz0] = [0.0, wz, wz, 0.0] - positions[4:8, sz1] = y - positions[4:8, sz2] = [0.0, 0.0, wx, wx] + positions[4:8, 0] = [0.0, wz, wz, 0.0] + positions[4:8, 1] = y + positions[4:8, 2] = [0.0, 0.0, wx, wx] # YZ plane (constant X = x) - positions[8:12, sz0] = [0.0, wz, wz, 0.0] - positions[8:12, sz1] = [0.0, 0.0, wy, wy] - positions[8:12, sz2] = x + positions[8:12, 0] = [0.0, wz, wz, 0.0] + positions[8:12, 1] = [0.0, 0.0, wy, wy] + positions[8:12, 2] = x return positions @@ -493,34 +514,35 @@ def _make_plane_mesh( world_max_zyx: np.ndarray, initial_opacity: float = 0.4, *, - spatial_axes: tuple[int, int, int] = (0, 1, 2), - n_dims: int = 3, + spatial_axes: tuple[int, int, int], ): - from cellier.data.mesh import MeshMemoryStore from cellier.visuals import MeshFlatAppearance - positions = _make_plane_positions( - z_world, - y_world, - x_world, - world_max_zyx, - spatial_axes=spatial_axes, - n_dims=n_dims, - ) + world = vol_scene.dims.world_coordinate_system + 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" + store = _spatial_mesh_store( + positions, + indices, + colors, + "slice_planes", + world=world, + spatial_axes=spatial_axes, ) 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" + data=store, + scene_id=vol_scene.id, + appearance=appearance, + name="slice_planes", + transform=_mesh_to_world(store, world, spatial_axes), ) return store, visual @@ -533,9 +555,7 @@ def __init__( plane_visual, world_max_zyx, *, - spatial_axes: tuple[int, int, int] = (0, 1, 2), - n_dims: int = 3, - channel_axis: int | None = None, + spatial_axes: tuple[int, int, int], ) -> None: self._id = uuid4() self._controller = controller @@ -543,67 +563,35 @@ def __init__( self._plane_visual = plane_visual self._world_max_zyx = world_max_zyx self._spatial_axes = spatial_axes - self._n_dims = n_dims - self._channel_axis = channel_axis - self._ch_world: float = 0.0 - # Read initial slice positions from the N-dim positions array. + # Read initial slice positions from the (z, y, x) positions array. positions = plane_store.positions - sz0, sz1, sz2 = spatial_axes - self._z_world = float(positions[0, sz0]) # XY plane vertex 0: Z - self._y_world = float(positions[4, sz1]) # XZ plane vertex 4: Y - self._x_world = float(positions[8, sz2]) # YZ plane vertex 8: X + self._z_world = float(positions[0, 0]) # XY plane vertex 0: Z + self._y_world = float(positions[4, 1]) # XZ plane vertex 4: Y + self._x_world = float(positions[8, 2]) # YZ plane vertex 8: X def _update(self) -> None: - positions = _make_plane_positions( - self._z_world, - self._y_world, - self._x_world, - self._world_max_zyx, - spatial_axes=self._spatial_axes, - n_dims=self._n_dims, + self._plane_store.positions = _make_plane_positions( + self._z_world, self._y_world, self._x_world, self._world_max_zyx ) - if self._channel_axis is not None: - positions[:, self._channel_axis] = self._ch_world - self._plane_store.positions = positions self._controller.reslice_visual(self._plane_visual.id) - def on_channel_changed(self, new_ch: int) -> None: - self._ch_world = float(new_ch) - # Only update the store positions; reslice_scene (triggered by - # update_slice_indices on the vol scene) handles the actual reslice with - # the correct channel dims, avoiding a conflicting reslice with stale dims. - positions = _make_plane_positions( - self._z_world, - self._y_world, - self._x_world, - self._world_max_zyx, - spatial_axes=self._spatial_axes, - n_dims=self._n_dims, - ) - if self._channel_axis is not None: - positions[:, self._channel_axis] = self._ch_world - self._plane_store.positions = positions - def on_xy_dims_changed(self, event) -> None: - slice_indices = event.dims_state.selection.slice_indices sz0 = self._spatial_axes[0] - if sz0 in slice_indices: - self._z_world = float(slice_indices[sz0]) + if sz0 in event.slice_indices: + self._z_world = float(event.slice_indices[sz0]) self._update() def on_xz_dims_changed(self, event) -> None: - slice_indices = event.dims_state.selection.slice_indices sz1 = self._spatial_axes[1] - if sz1 in slice_indices: - self._y_world = float(slice_indices[sz1]) + if sz1 in event.slice_indices: + self._y_world = float(event.slice_indices[sz1]) self._update() def on_yz_dims_changed(self, event) -> None: - slice_indices = event.dims_state.selection.slice_indices sz2 = self._spatial_axes[2] - if sz2 in slice_indices: - self._x_world = float(slice_indices[sz2]) + if sz2 in event.slice_indices: + self._x_world = float(event.slice_indices[sz2]) self._update() @@ -616,118 +604,80 @@ def __init__( yz_axis_visual, world_max_zyx: np.ndarray, *, - xy_axis_store=None, - xz_axis_store=None, - yz_axis_store=None, - spatial_axes: tuple[int, int, int] = (0, 1, 2), - n_dims: int = 3, - channel_axis: int | None = None, + axis_stores: tuple, + world, + spatial_axes: tuple[int, int, int], ): 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 - self._xy_axis_store = xy_axis_store - self._xz_axis_store = xz_axis_store - self._yz_axis_store = yz_axis_store + self._axis_stores = axis_stores + self._world = world self._spatial_axes = spatial_axes - self._n_dims = n_dims - self._channel_axis = channel_axis - self._ch_world: float = 0.0 mid = world_max_zyx / 2.0 self._z_world = float(mid[0]) self._y_world = float(mid[1]) self._x_world = float(mid[2]) - # N-dim centre vectors, one per 2D panel. - self._xy_centre = self._make_centre(self._z_world, self._y_world, self._x_world) + # (z, y, x) centre vectors, one per 2D panel. + self._xy_centre = np.array( + [self._z_world, self._y_world, self._x_world], dtype=np.float64 + ) self._xz_centre = self._xy_centre.copy() self._yz_centre = self._xy_centre.copy() - def _make_centre(self, z: float, y: float, x: float) -> np.ndarray: - c = np.zeros(self._n_dims, dtype=np.float64) - c[self._spatial_axes[0]] = z - c[self._spatial_axes[1]] = y - c[self._spatial_axes[2]] = x - if self._channel_axis is not None: - c[self._channel_axis] = self._ch_world - return c - - def on_channel_changed(self, new_ch: int) -> None: - self._ch_world = float(new_ch) - if self._channel_axis is not None: - self._xy_centre[self._channel_axis] = self._ch_world - self._xz_centre[self._channel_axis] = self._ch_world - self._yz_centre[self._channel_axis] = self._ch_world - # Update channel column in each axis store so the slab filter keeps - # the meshes visible. reslice_scene (from update_slice_indices on the - # vol scene) handles the actual reslice — no reslice_visual here. - for store in ( - self._xy_axis_store, - self._xz_axis_store, - self._yz_axis_store, - ): - if store is not None: - positions = store.positions.copy() - positions[:, self._channel_axis] = self._ch_world - store.positions = positions - self._update_3d() - def _update_3d(self) -> None: - from cellier.transform import AffineTransform - - for visual_id, centre_nd in zip( + for visual_id, store, centre_zyx in zip( (self._xy_axis_visual_id, self._xz_axis_visual_id, self._yz_axis_visual_id), + self._axis_stores, (self._xy_centre, self._xz_centre, self._yz_centre), - strict=False, + strict=True, ): self._controller.set_visual_transform( visual_id, - AffineTransform.from_translation(tuple(float(v) for v in centre_nd)), + _mesh_to_world(store, self._world, self._spatial_axes, centre_zyx), reslice=False, ) def on_xy_camera_changed(self, event) -> None: p = event.camera_state.position # p[0] → X world, p[1] → Y world (canvas horizontal/vertical convention) - self._xy_centre = self._make_centre(self._z_world, p[1], p[0]) + self._xy_centre = 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 # p[0] → X world, p[1] → Z world - self._xz_centre = self._make_centre(p[1], self._y_world, p[0]) + self._xz_centre = 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 # p[0] → Y world, p[1] → Z world - self._yz_centre = self._make_centre(p[1], p[0], self._x_world) + self._yz_centre = 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 sz0 = self._spatial_axes[0] - if sz0 in slice_indices: - self._z_world = float(slice_indices[sz0]) - self._xy_centre[sz0] = self._z_world + if sz0 in event.slice_indices: + self._z_world = float(event.slice_indices[sz0]) + self._xy_centre[0] = self._z_world self._update_3d() def on_xz_dims_changed(self, event) -> None: - slice_indices = event.dims_state.selection.slice_indices sz1 = self._spatial_axes[1] - if sz1 in slice_indices: - self._y_world = float(slice_indices[sz1]) - self._xz_centre[sz1] = self._y_world + if sz1 in event.slice_indices: + self._y_world = float(event.slice_indices[sz1]) + self._xz_centre[1] = self._y_world self._update_3d() def on_yz_dims_changed(self, event) -> None: - slice_indices = event.dims_state.selection.slice_indices sz2 = self._spatial_axes[2] - if sz2 in slice_indices: - self._x_world = float(slice_indices[sz2]) - self._yz_centre[sz2] = self._x_world + if sz2 in event.slice_indices: + self._x_world = float(event.slice_indices[sz2]) + self._yz_centre[2] = self._x_world self._update_3d() @@ -807,8 +757,6 @@ def attach_ortho_overlays( vol_scene = scenes["vol"] spatial_axes = geometry.spatial_axes - n_dims_world = len(geometry.axis_names) - channel_axis = geometry.channel_axis world_max_zyx = geometry.world_max_spatial center = geometry.center_slice_indices() @@ -827,7 +775,6 @@ def attach_ortho_overlays( initial_centre_zyx=initial_centre_zyx, world_min_extent=float(world_max_zyx.min()), spatial_axes=spatial_axes, - n_dims=n_dims_world, ) axis_visual_ids = [xy_axis_visual.id, xz_axis_visual.id, yz_axis_visual.id] @@ -841,7 +788,6 @@ def attach_ortho_overlays( world_max_zyx, initial_opacity=_INITIAL_PLANE_OPACITY, spatial_axes=spatial_axes, - n_dims=n_dims_world, ) plane_updater = _PlaneUpdater( @@ -850,8 +796,6 @@ def attach_ortho_overlays( plane_visual=plane_visual, world_max_zyx=world_max_zyx, spatial_axes=spatial_axes, - n_dims=n_dims_world, - channel_axis=channel_axis, ) controller.on_dims_changed( scenes["xy"].id, plane_updater.on_xy_dims_changed, owner_id=plane_updater._id @@ -894,13 +838,10 @@ def render_mode_callback(new_mode) -> None: xy_axis_visual=xy_axis_visual, xz_axis_visual=xz_axis_visual, yz_axis_visual=yz_axis_visual, - xy_axis_store=xy_axis_store, - xz_axis_store=xz_axis_store, - yz_axis_store=yz_axis_store, world_max_zyx=world_max_zyx, + axis_stores=(xy_axis_store, xz_axis_store, yz_axis_store), + world=vol_scene.dims.world_coordinate_system, spatial_axes=spatial_axes, - n_dims=n_dims_world, - channel_axis=channel_axis, ) orient_owner = orient_updater._id controller.on_camera_changed( @@ -922,34 +863,11 @@ def render_mode_callback(new_mode) -> None: scenes["yz"].id, orient_updater.on_yz_dims_changed, owner_id=orient_updater._id ) + # No channel following is needed: the overlay meshes are broadcast over + # every non-spatial world axis (see _mesh_to_world), so moving a channel or + # time slider leaves them in place. owner_ids = [plane_updater._id, orient_updater._id] - # --- channel following (replaces the old _ChannelAxisSyncer) --- - # In single-channel mode the channel axis is a normal (synced) extra axis; - # the overlays must track it. The convenience OrthoViewer's built-in - # _ExtraAxisSyncer keeps the channel in step across panels, so a thin bridge - # on the vol scene forwards the new channel to the updaters (conversion plan - # D4.5). In multichannel mode the channel axis is stacked (no slider, never - # changes) so no bridge is needed. - if channel_axis is not None and not vol_is_multichannel: - bridge_owner = uuid4() - - def _on_channel_bridge(event) -> None: - slice_indices = event.dims_state.selection.slice_indices - if channel_axis not in slice_indices: - return - new_ch = int(slice_indices[channel_axis]) - plane_updater.on_channel_changed(new_ch) - controller.reslice_visual(plane_visual.id) - orient_updater.on_channel_changed(new_ch) - for vid in axis_visual_ids: - controller.reslice_visual(vid) - - controller.on_dims_changed( - vol_scene.id, _on_channel_bridge, owner_id=bridge_owner - ) - owner_ids.append(bridge_owner) - # --- seed the orientation gizmo from post-fit camera state --- # The initial camera fit is deferred to the canvas first frame (fit="ready"), # so seed once every panel is ready rather than synchronously at build time. diff --git a/src/oz_viewer/viewer/_orthoviewer.py b/src/oz_viewer/viewer/_orthoviewer.py index c8084b5..6386939 100644 --- a/src/oz_viewer/viewer/_orthoviewer.py +++ b/src/oz_viewer/viewer/_orthoviewer.py @@ -123,7 +123,7 @@ def build_ortho_viewer( ) viewer = OrthoViewer( - axis_labels=geometry.axis_names, + geometry.world, spatial_axes=geometry.spatial_axes, render_config=_controller_render_config(), gui=gui, @@ -216,6 +216,7 @@ def _add_multichannel_visuals( as a composited stack (all channels at once) with no redundant dims slider; the cross-toolkit ``ChannelControls`` dock owns per-channel visibility. """ + from cellier.convenience import ChannelControlsConfig from cellier.visuals import ChannelAppearance controller = viewer.controller @@ -244,11 +245,11 @@ def _add_multichannel_visuals( transform=geometry.voxel_to_world, max_channels_2d=max_channels, max_channels_3d=max_channels, - controls={ - "fields": _CHANNEL_FIELDS, - "colormap_names": _DEFAULT_COLORMAPS, - "clim_range": geometry.clim_range, - }, + controls=ChannelControlsConfig( + fields=_CHANNEL_FIELDS, + colormap_names=_DEFAULT_COLORMAPS, + clim_range=geometry.clim_range, + ), ) # Stack the channel axis on every panel: drop it from slice_indices and mark @@ -271,10 +272,10 @@ def _add_multichannel_visuals( def _center_ortho_slices(viewer: OrthoViewer, geometry: _ViewerGeometry) -> None: """Center each panel's sliced spatial axis at the volume midpoint. - Applied directly from OME-Zarr metadata rather than via - ``OrthoViewer.center_slices`` -> ``axis_ranges_from_ortho``, which crashes - for multichannel visuals (spatial-only transform vs full-ndim store, see - conversion plan Phase 1 finding). Extra axes (channel) keep position 0. + Applied from the OME-Zarr metadata rather than via + ``OrthoViewer.center_slices`` so the midpoint agrees with + :attr:`_ViewerGeometry.axis_values`, which spans voxel centres rather than + voxel edges. Extra axes (channel) keep position 0. """ center = geometry.center_slice_indices() for scene in viewer.scenes.values(): @@ -321,7 +322,7 @@ def build_ortho_layout( viewer = build.viewer grid = build_ortho_grid_widget( viewer, - geometry.axis_ranges, + geometry.axis_values, depth_range_3d=geometry.depth_range, canvas_size=min_canvas_size, ) diff --git a/src/oz_viewer/viewer/_viewer.py b/src/oz_viewer/viewer/_viewer.py index 710bfbf..d72227e 100644 --- a/src/oz_viewer/viewer/_viewer.py +++ b/src/oz_viewer/viewer/_viewer.py @@ -104,7 +104,7 @@ def build_viewer( from cellier.convenience import Viewer viewer = Viewer( - axis_labels=geometry.axis_names, + geometry.world, dim="2d", render_config=_render_config(), gui=gui, @@ -135,25 +135,28 @@ def _add_single_channel_visual( geometry: _ViewerGeometry, ) -> None: """Add a single-channel multiscale image visual + appearance controls.""" + from cellier.convenience import MultiscaleImageControlsConfig + from cellier.visuals import MultiscaleImageAppearance + clim_max = geometry.initial_clim_max viewer.add_image_multiscale( data_store, - appearance={ - "color_map": "viridis", - "clim": (0.0, clim_max), - "lod_bias": _INITIAL_LOD_BIAS, - "iso_threshold": clim_max / 2.0, - "render_mode": "mip", - "attenuation": 1.0, - }, + appearance=MultiscaleImageAppearance( + color_map="viridis", + clim=(0.0, clim_max), + lod_bias=_INITIAL_LOD_BIAS, + iso_threshold=clim_max / 2.0, + render_mode="mip", + attenuation=1.0, + ), name="volume", render_config=_visual_render_config(), transform=geometry.voxel_to_world, - controls={ - "appearance": _APPEARANCE_FIELDS, - "colormap_names": _DEFAULT_COLORMAPS, - "clim_range": geometry.clim_range, - }, + controls=MultiscaleImageControlsConfig( + appearance=_APPEARANCE_FIELDS, + colormap_names=_DEFAULT_COLORMAPS, + clim_range=geometry.clim_range, + ), ) @@ -168,6 +171,7 @@ def _add_multichannel_visual( channels at once) and no redundant dims slider appears for it -- the ``ChannelControls`` dock owns per-channel visibility instead. """ + from cellier.convenience import ChannelControlsConfig from cellier.visuals import ChannelAppearance clim_max = geometry.initial_clim_max @@ -195,11 +199,11 @@ def _add_multichannel_visual( transform=geometry.voxel_to_world, max_channels_2d=max_channels, max_channels_3d=max_channels, - controls={ - "fields": _CHANNEL_FIELDS, - "colormap_names": _DEFAULT_COLORMAPS, - "clim_range": geometry.clim_range, - }, + controls=ChannelControlsConfig( + fields=_CHANNEL_FIELDS, + colormap_names=_DEFAULT_COLORMAPS, + clim_range=geometry.clim_range, + ), ) # Stack the channel axis: drop it from slice_indices and mark it stacked so @@ -240,7 +244,7 @@ def build_viewer_layout( canvas_view = build_canvas_widget( viewer, - geometry.axis_ranges, + geometry.axis_values, depth_range_3d=geometry.depth_range, canvas_size=min_canvas_size, ) diff --git a/tests/test_orthoviewer.py b/tests/test_orthoviewer.py index 9a1529a..c72a8e1 100644 --- a/tests/test_orthoviewer.py +++ b/tests/test_orthoviewer.py @@ -29,6 +29,21 @@ def _dock_titles(window) -> list[str]: return [d.windowTitle() for d in window.findChildren(QDockWidget)] +def _dock_control_names(window, title: str) -> set[str]: + """Every group-box title and label inside the dock called *title*. + + See the twin helper in ``test_viewer.py``: a dock title alone does not show + that cellier put any controls in it. + """ + from PySide6.QtWidgets import QCheckBox, QDockWidget, QGroupBox, QLabel + + dock = next(d for d in window.findChildren(QDockWidget) if d.windowTitle() == title) + names = {g.title() for g in dock.findChildren(QGroupBox)} + names |= {w.text() for w in dock.findChildren(QLabel)} + names |= {w.text() for w in dock.findChildren(QCheckBox)} + return {n for n in names if n} + + def test_single_channel_ortho_build(qapp, tmp_path): """Blobs (z,y,x) -> single-channel ortho: 4 panels, overlays, Rendering dock.""" from oz_viewer.data._blobs import make_example_zarr @@ -48,6 +63,16 @@ def test_single_channel_ortho_build(qapp, tmp_path): assert len(overlays.axis_visual_ids) == 3 assert overlays.transparency_manager.current_mode == "iso" + # A dims change reaches the overlays: the slice plane and the gizmo + # follow the XY panel's slider. Off the voxel grid on purpose -- slice + # positions are floats, and an integer is where rounding rules agree. + controller = build.viewer.controller + z_axis = handle.geometry.spatial_axes[0] + controller.update_slice_indices(build.viewer.scenes["xy"].id, {z_axis: 12.3}) + assert overlays.plane_store.positions[0, 0] == pytest.approx(12.3) + gizmo = controller.get_visual_model(overlays.axis_visual_ids[0]) + assert gizmo.transform.translation[z_axis] == pytest.approx(12.3) + # Qt-only appearance panel on the left dock; no channel dock. assert "Rendering" in _dock_titles(handle.window) finally: @@ -72,9 +97,32 @@ def test_multichannel_ortho_build(qapp, write_demo_ome): # Multichannel volume is locked to MIP; overlays present. assert build.overlays.transparency_manager.current_mode == "mip" + # The overlay meshes are 3-D (z, y, x) and broadcast over the channel + # axis, so they exist at every channel without per-channel updates. + controller = build.viewer.controller + world = build.viewer.scenes["vol"].dims.world_coordinate_system + overlay_ids = (build.overlays.plane_visual.id, *build.overlays.axis_visual_ids) + for visual_id in overlay_ids: + transform = controller.get_visual_model(visual_id).transform + assert transform.broadcast_axes == frozenset({world.axes[0].id}) + + # A channel slider steps through channels; spatial sliders are free. + from cellier.convenience import ContinuousAxisValues, DiscreteAxisValues + + axis_values = handle.geometry.axis_values + assert axis_values[0] == DiscreteAxisValues( + values=tuple(float(i) for i in range(handle.geometry.n_channels)) + ) + assert all(isinstance(axis_values[a], ContinuousAxisValues) for a in (1, 2, 3)) + # ChannelControls dock ("Left") plus the Qt-only volume group ("Volume"). titles = _dock_titles(handle.window) assert "Left" in titles assert "Volume" in titles + + # The channel dock drives all four panels' sibling visuals, so it must + # actually hold one group per channel -- not just exist. + names = _dock_control_names(handle.window, "Left") + assert {f"Channel {i}" for i in range(handle.geometry.n_channels)} <= names finally: handle.close() From caa55ab9d9fb37485f1372ecf23030fee7b013c3 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Wed, 23 Sep 2026 22:31:07 +0200 Subject: [PATCH 4/8] update to new cellier convenience API --- README.md | 2 +- src/oz_viewer/_cli.py | 42 ++- src/oz_viewer/viewer/_geometry.py | 139 ++++++++-- src/oz_viewer/viewer/_image.py | 135 ++++++++++ src/oz_viewer/viewer/_ortho_controls.py | 222 ++-------------- src/oz_viewer/viewer/_ortho_overlays.py | 223 ++++++++++------ src/oz_viewer/viewer/_orthoviewer.py | 318 +++++++--------------- src/oz_viewer/viewer/_viewer.py | 220 +++++----------- src/oz_viewer/viewer/_widgets.py | 336 +----------------------- tests/conftest.py | 44 ++++ tests/test_cli.py | 43 ++- tests/test_level_translations.py | 128 +++++++++ tests/test_orthoviewer.py | 79 ++++-- tests/test_viewer.py | 161 ++++++++++++ 14 files changed, 1050 insertions(+), 1042 deletions(-) create mode 100644 src/oz_viewer/viewer/_image.py create mode 100644 tests/test_level_translations.py create mode 100644 tests/test_viewer.py diff --git a/README.md b/README.md index 9bf39d2..6e9d0ae 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Example viewing https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpA_VIP_A https://github.com/user-attachments/assets/a6c0cab9-0cd9-4fe0-80c2-c77207b4fd77 -You can click the multichannel button in the upper left-hand corner to toggle between single/multichannel rendering. Example viewing the scikit-image cells3d (converted to ome-zarr) multichannel image +Images with a channel axis open as a composite of their channels (up to 4). Use the "Composite channels" checkbox in the image controls on the left to switch between the composite and single-channel rendering, where a slider steps through the channels. Example viewing the scikit-image cells3d (converted to ome-zarr) multichannel image https://github.com/user-attachments/assets/b6655863-b8fb-4eea-be84-eb031283494e diff --git a/src/oz_viewer/_cli.py b/src/oz_viewer/_cli.py index 39e51be..acee942 100644 --- a/src/oz_viewer/_cli.py +++ b/src/oz_viewer/_cli.py @@ -157,6 +157,19 @@ def ortho( show_default=False, ), ] = None, + infer_multiscale_translations: Annotated[ + bool, + typer.Option( + "--infer-multiscale-translations", + help=( + "Assume the coarser pyramid levels were downsampled centre-aligned" + " (e.g. block averages) and add the half-voxel offsets that implies" + " when the store declares no level translations. Wrong for" + " pyramids made by striding. Ignored, with a warning, when the" + " store declares translations." + ), + ), + ] = False, theme: Annotated[ str, typer.Option( @@ -237,7 +250,13 @@ def ortho( perf.mark("cli.ortho.uri_resolved", zarr_uri=zarr_uri) perf.mark("cli.ortho.launch") - launch_orthoviewer(zarr_uri, channel_axis=multichannel, theme=theme, perf=perf) + launch_orthoviewer( + zarr_uri, + channel_axis=multichannel, + infer_multiscale_translations=infer_multiscale_translations, + theme=theme, + perf=perf, + ) @app.command() @@ -279,6 +298,19 @@ def view( show_default=False, ), ] = None, + infer_multiscale_translations: Annotated[ + bool, + typer.Option( + "--infer-multiscale-translations", + help=( + "Assume the coarser pyramid levels were downsampled centre-aligned" + " (e.g. block averages) and add the half-voxel offsets that implies" + " when the store declares no level translations. Wrong for" + " pyramids made by striding. Ignored, with a warning, when the" + " store declares translations." + ), + ), + ] = False, theme: Annotated[ str, typer.Option( @@ -359,7 +391,13 @@ def view( 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) + launch_viewer( + zarr_uri, + channel_axis=multichannel, + infer_multiscale_translations=infer_multiscale_translations, + theme=theme, + perf=perf, + ) @app.command(name="theme") diff --git a/src/oz_viewer/viewer/_geometry.py b/src/oz_viewer/viewer/_geometry.py index e2b1ee2..40c49c8 100644 --- a/src/oz_viewer/viewer/_geometry.py +++ b/src/oz_viewer/viewer/_geometry.py @@ -10,7 +10,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, NamedTuple +import warnings +from typing import TYPE_CHECKING, Literal, NamedTuple import numpy as np @@ -32,6 +33,8 @@ class _ViewerGeometry(NamedTuple): spatial_ndim: int channel_axis: int | None n_channels: int + #: One name per channel from the store's ``omero`` metadata, or ``None``. + channel_labels: tuple[str, ...] | None #: The world every scene shares, with the store's axis names, types and #: units. Pass this exact object to the viewer: a transform names its #: endpoints by id, so an equal-looking world built elsewhere is rejected. @@ -70,7 +73,10 @@ def axis_values(self) -> dict[int, ContinuousAxisValues | DiscreteAxisValues]: """World-space slider values per axis, for the dims sliders. The channel axis is discrete -- one stop per channel, so its slider - never rests between two channels. Every other axis (spatial, time) is + never rests between two channels -- and named by the ``omero`` channel + labels when the store has them. It only shows a slider while the image + is in single mode; a composite draws every channel at once. Every + other axis (spatial, time) is continuous over ``[0, world_max]``, from the first voxel centre to the last. Derived from the metadata rather than via ``cellier.convenience.axis_values_from_viewer``, which widens each axis @@ -83,7 +89,8 @@ def axis_values(self) -> dict[int, ContinuousAxisValues | DiscreteAxisValues]: if axis == self.channel_axis: step = float(self.level_0_scale[axis]) values[axis] = DiscreteAxisValues( - values=tuple(i * step for i in range(self.n_channels)) + values=tuple(i * step for i in range(self.n_channels)), + labels=self.channel_labels, ) else: values[axis] = ContinuousAxisValues( @@ -110,6 +117,7 @@ def extract_viewer_geometry( channel_axis: int | None = None, perf: StartupPerfTracer | None = None, print_summary: bool = True, + infer_multiscale_translations: bool = False, ) -> tuple[OMEZarrImageDataStore, _ViewerGeometry]: """Open an OME-Zarr store and extract the geometry the viewers need. @@ -119,41 +127,64 @@ def extract_viewer_geometry( 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. + the channel axis is auto-detected from the store's axis types. perf : StartupPerfTracer or None, optional Optional startup performance tracer. print_summary : bool, optional Print a Rich table summarizing the store's metadata. Default ``True``. + infer_multiscale_translations : bool, optional + Assume the coarser levels were downsampled centre-aligned (e.g. block + averages) and give each the half-voxel offset that implies, when the + store declares no level translations. See + :func:`_with_inferred_level_translations`. Default ``False``: wrong + for pyramids made by striding. Returns ------- tuple[OMEZarrImageDataStore, _ViewerGeometry] """ - import yaozarrs from cellier.data.image import OMEZarrImageDataStore _perf_mark(perf, "geometry.start", zarr_uri=zarr_uri) data_store = OMEZarrImageDataStore.from_path(zarr_uri) _perf_mark(perf, "geometry.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] + translations: _TranslationSource = ( + "declared" if _has_level_translations(data_store) else "none" + ) + if infer_multiscale_translations: + if translations == "declared": + warnings.warn( + f"{zarr_uri} already declares multiscale level translations; " + "--infer-multiscale-translations is ignored and the declared " + "translations are used.", + UserWarning, + stacklevel=2, + ) + else: + data_store = _with_inferred_level_translations(data_store) + translations = "inferred" + _perf_mark(perf, "geometry.level_translations_inferred") - n_dims = len(data_store.level_shapes[0]) + # The level-0 coordinate system is the store's record of its axis names, + # types and units, parsed from the NGFF metadata at construction. + data_axes = data_store.data_coordinate_systems[0].axes + n_dims = len(data_axes) - # Detect channel axis from OME-Zarr axis metadata when not explicitly set. + # Detect the channel axis from the axis types when not explicitly set. if channel_axis is None: - for idx, ax in enumerate(ms.axes): - if getattr(ax, "type", None) == "channel": + for idx, ax in enumerate(data_axes): + if ax.axis_type == "channel": channel_axis = idx break spatial_indices: list[int] = [i for i in range(n_dims) if i != channel_axis] spatial_ndim = len(spatial_indices) + # The level-0 data -> world scale: the multiscale's global scale composed + # with the finest dataset's, as the store parsed it. level_0_scale_full = np.array( - ms.datasets[0].scale_transform.scale, dtype=np.float64 + data_store.physical_scale or [1.0] * n_dims, dtype=np.float64 ) level_0_scale_spatial = level_0_scale_full[spatial_indices] @@ -175,6 +206,7 @@ def extract_viewer_geometry( level_0_scale_spatial, world_extents_spatial, depth_range, + translations, ) _perf_mark(perf, "geometry.metadata_printed") @@ -185,13 +217,18 @@ def extract_viewer_geometry( n_channels = ( int(data_store.level_shapes[0][channel_axis]) if channel_axis is not None else 0 ) + labels = data_store.channel_labels + channel_labels = ( + tuple(labels) if labels is not None and len(labels) == n_channels else None + ) geometry = _ViewerGeometry( - axis_names=tuple(data_store.axis_names), + axis_names=tuple(ax.name for ax in data_axes), spatial_indices=spatial_indices, spatial_ndim=spatial_ndim, channel_axis=channel_axis, n_channels=n_channels, + channel_labels=channel_labels, world=world, voxel_to_world=voxel_to_world, level_0_scale=level_0_scale_full, @@ -206,6 +243,55 @@ def extract_viewer_geometry( return data_store, geometry +#: Where the store's level translations came from, for the metadata table. +_TranslationSource = Literal["declared", "inferred", "none"] + +#: Store fields cellier derives at construction, so a rebuilt store must not +#: carry them over: the id comes from the coordinate systems, and the level +#: transforms from the level scales and translations. +_DERIVED_STORE_FIELDS = frozenset({"id", "level_transforms", "store_type"}) + + +def _has_level_translations(data_store: OMEZarrImageDataStore) -> bool: + """Whether any level of *data_store* is offset from level 0.""" + return any( + any(float(value) != 0.0 for value in translation) + for translation in data_store.level_translations + ) + + +def _inferred_level_translations( + level_scales: list[tuple[float, ...]], +) -> list[tuple[float, ...]]: + """Half-voxel offsets of centre-aligned downsampling, per level and axis. + + A level-k voxel that averages ``f`` level-0 voxels along an axis starts at + level-0 voxel 0, so its centre sits at level-0 index ``(f - 1) / 2``. The + result is in level-0 voxels, the units of ``level_translations``. An axis + that is not downsampled (``f == 1``) gets 0, and so does level 0. + """ + return [tuple((float(f) - 1.0) / 2.0 for f in scale) for scale in level_scales] + + +def _with_inferred_level_translations( + data_store: OMEZarrImageDataStore, +) -> OMEZarrImageDataStore: + """Rebuild *data_store* with the centre-aligned level translations. + + Goes through the public constructor so cellier rebuilds the level + transforms from the new translations; every other field is copied, so a + field cellier adds later is carried over too. Level 0 is untouched, so + the store sits in the world exactly where it did. + """ + fields = { + name: getattr(data_store, name) + for name in type(data_store).model_fields + if name not in _DERIVED_STORE_FIELDS and name != "level_translations" + } + fields["level_translations"] = _inferred_level_translations(data_store.level_scales) + return type(data_store)(**fields) + + def _world_and_transform( data_store: OMEZarrImageDataStore, level_0_scale: np.ndarray, @@ -219,19 +305,14 @@ def _world_and_transform( """ from cellier.transform import AffineTransform, Axis, WorldCoordinateSystem + level_0 = data_store.data_coordinate_systems[0] world = WorldCoordinateSystem( name="world", axes=tuple( - Axis(name=name, axis_type=axis_type, unit=unit) - for name, axis_type, unit in zip( - data_store.axis_names, - data_store.axis_types, - data_store.axis_units, - strict=True, - ) + Axis(name=ax.name, axis_type=ax.axis_type, unit=ax.unit) + for ax in level_0.axes ), ) - level_0 = data_store.data_coordinate_systems[0] voxel_to_world = AffineTransform.from_axis_map( level_0, world, @@ -256,6 +337,7 @@ def _print_metadata_table( level_0_scale_spatial: np.ndarray, world_extents_spatial: np.ndarray, depth_range: tuple[float, float], + translations: _TranslationSource = "none", ) -> None: """Print the startup metadata summary as a Rich table.""" from rich.console import Console @@ -271,8 +353,9 @@ def _print_metadata_table( 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)) + data_axes = data_store.data_coordinate_systems[0].axes + table.add_row("axes", " ".join(ax.name for ax in data_axes)) + table.add_row("units", " ".join(str(ax.unit) for ax in data_axes)) 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))) @@ -285,4 +368,12 @@ def _print_metadata_table( " ".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}") + table.add_row( + "level translations", + { + "declared": "declared in the file", + "inferred": "inferred: (factor - 1) / 2 level-0 voxels (centre-aligned)", + "none": "none", + }[translations], + ) Console().print(table) diff --git a/src/oz_viewer/viewer/_image.py b/src/oz_viewer/viewer/_image.py new file mode 100644 index 0000000..f0e6477 --- /dev/null +++ b/src/oz_viewer/viewer/_image.py @@ -0,0 +1,135 @@ +"""The multiscale image both viewers add, built from the OME-Zarr geometry. + +Each viewer holds one image visual (fanned out to every panel on the +orthoviewer). When the store has a channel axis the visual carries it and +starts in composite mode, drawing its channels at once; the image control's +mode switch flips it to single mode at runtime, where the channel axis gets a +slider instead. Both modes are configured up front so either can be shown. + +Also holds the render-pipeline configs the two viewers share. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from oz_viewer.viewer._widgets import _DEFAULT_COLORMAPS + +if TYPE_CHECKING: + from cellier.render import RenderManagerConfig + from cellier.visuals import MultiscaleImageRenderConfig + + from oz_viewer.viewer._geometry import _ViewerGeometry + +# Appearance fields shown in the image control. Fields that only one mode +# has (e.g. a channel's ``visible``) appear on that mode's page. +_IMAGE_FIELDS = [ + "visible", + "opacity", + "color_map", + "clim", + "render_mode", + "iso_threshold", + "attenuation", + "lod_bias", +] + +# Composite channel colormaps, in channel order. Composites blend additively +# by default, so each colormap starts from black. +_CHANNEL_COLORMAPS = ["green", "magenta", "cyan", "red"] + + +def controller_render_config() -> RenderManagerConfig: + """The controller render-pipeline config used by both viewers.""" + from cellier.render import RenderManagerConfig, TemporalAccumulationConfig + + return RenderManagerConfig(temporal=TemporalAccumulationConfig(enabled=False)) + + +def visual_render_config() -> MultiscaleImageRenderConfig: + """LOD / GPU-budget config for the multiscale image visual. + + The 3D budget is only allocated by panels that render in 3D, so the + orthoviewer's 2D panels can share this config. + """ + from cellier.visuals import MultiscaleImageRenderConfig + + return MultiscaleImageRenderConfig( + block_size=32, + gpu_budget_bytes=2048 * 1024**2, + gpu_budget_bytes_2d=64 * 1024**2, + ) + + +def _max_channels() -> int: + """Cellier's default cap on the channels one image visual may hold.""" + from cellier.visuals import MultiscaleImageVisual + + return int(MultiscaleImageVisual.model_fields["max_channels"].default) + + +def image_visual_kwargs( + geometry: _ViewerGeometry, + *, + render_mode: str, + lod_bias: float, +) -> dict[str, Any]: + """Keyword arguments for ``add_image_multiscale`` on either viewer. + + Parameters + ---------- + geometry : _ViewerGeometry + Metadata extracted by :func:`extract_viewer_geometry`. + render_mode : str + The single-mode 3D render mode, e.g. ``"mip"`` or ``"iso"``. + lod_bias : float + Initial level-of-detail bias. + + Returns + ------- + dict[str, Any] + Everything but the data store and the visual name. With a channel + axis the visual starts in composite mode holding the first + ``max_channels`` channels (cellier's default); single mode can still + step through every channel. + """ + from cellier.convenience import MultiscaleImageControlsConfig + from cellier.visuals import ( + MultiscaleImageAppearance, + MultiscaleImageChannelAppearance, + MultiscaleImageSingleAppearance, + ) + + clim_max = geometry.initial_clim_max + labels = geometry.channel_labels + kwargs: dict[str, Any] = { + "appearance": MultiscaleImageAppearance(lod_bias=lod_bias, attenuation=1.0), + "single": MultiscaleImageSingleAppearance( + color_map="viridis", + clim=(0.0, clim_max), + iso_threshold=clim_max / 2.0, + render_mode=render_mode, + ), + "render_config": visual_render_config(), + "transform": geometry.voxel_to_world, + "controls": MultiscaleImageControlsConfig( + appearance=_IMAGE_FIELDS, + colormap_names=_DEFAULT_COLORMAPS, + clim_range=geometry.clim_range, + channel_labels=dict(enumerate(labels)) if labels is not None else None, + ), + } + if geometry.channel_axis is not None: + n_held = min(geometry.n_channels, _max_channels()) + kwargs["channel_axis"] = geometry.channel_axis + kwargs["composite"] = True + kwargs["channels"] = { + i: MultiscaleImageChannelAppearance( + color_map=_CHANNEL_COLORMAPS[i % len(_CHANNEL_COLORMAPS)], + clim=(0.0, clim_max), + iso_threshold=clim_max / 2.0, + render_mode="mip", + ) + for i in range(n_held) + } + return kwargs diff --git a/src/oz_viewer/viewer/_ortho_controls.py b/src/oz_viewer/viewer/_ortho_controls.py index 6d26457..87ce922 100644 --- a/src/oz_viewer/viewer/_ortho_controls.py +++ b/src/oz_viewer/viewer/_ortho_controls.py @@ -1,46 +1,33 @@ -"""Qt-only control panels for the orthoviewer. - -The convenience ``Layout``/renderer control docks cover the multichannel -``ChannelControls`` (cross-toolkit) but have no appearance dock for an -``OrthoViewer`` (their appearance builder needs a single-scene ``viewer.scene``). -oz-viewer therefore builds its single-channel appearance panel and the -render/opacity groups here, in Qt only, and bolts them onto the rendered -``QMainWindow``. In the anywidget path these panels are simply omitted -(accepted feature loss -- see conversion plan risks); the canvases, slicing, -overlays, and multichannel controls still work. +"""Qt-only overlay control panel for the orthoviewer. + +The image itself is controlled by cellier's cross-toolkit +``AppearanceControls`` dock, which drives all four panels' visuals together. +The orthoviewer's 3D overlays (the slice planes and the orientation gizmo) are +plain meshes oz-viewer adds to the ``vol`` scene only, and the convenience +docks have no public way to configure controls for them, so their panel is +built here, in Qt only, and bolted onto the rendered ``QMainWindow``. In the +anywidget path this panel is omitted; the overlays still render. """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING -from oz_viewer.viewer._ortho_overlays import _TRANSPARENCY_MODES, _make_plane_colors -from oz_viewer.viewer._widgets import ( - _DEFAULT_COLORMAPS, - _MultiVisualClimSlider, - _MultiVisualColormapCombo, - _MultiVisualLodBiasSlider, -) +from oz_viewer.viewer._ortho_overlays import _INITIAL_PLANE_OPACITY, _make_plane_colors if TYPE_CHECKING: - from oz_viewer.viewer._geometry import _ViewerGeometry from oz_viewer.viewer._ortho_overlays import OrthoOverlays @dataclass class _OrthoControlsHandle: - """The Qt controls panel widget plus the sub-widgets to close on teardown.""" + """The Qt controls panel widget.""" widget: object - _closables: list = field(default_factory=list) def close(self) -> None: - for w in self._closables: - try: - w.close() - except (RuntimeError, ValueError): - pass + """Nothing to unsubscribe: the panel only writes to the controller.""" def _plane_opacity_group(controller, overlays: OrthoOverlays, initial_opacity: float): @@ -73,196 +60,37 @@ def _on_changed(value: float) -> None: return box -def build_sc_controls_panel( +def build_overlay_controls_panel( controller, - visuals: dict, - geometry: _ViewerGeometry, - overlays: OrthoOverlays | None, + overlays: OrthoOverlays, ) -> _OrthoControlsHandle: - """Build the single-channel appearance + overlay control panel (Qt only). + """Build the orientation-axes toggle + slice-opacity panel (Qt only). Parameters ---------- controller : cellier.controller.CellierController The orthoviewer's controller. - visuals : dict - Per-panel single-channel image visuals keyed ``xy``/``xz``/``yz``/``vol``. - geometry : _ViewerGeometry - OME-Zarr geometry (clim range, slider decimals). - overlays : OrthoOverlays or None - The attached 3D overlays; ``None`` disables the overlay control groups. - """ - from cellier.gui.qt.visuals import ( - QtClimRangeSlider, - QtColormapCombo, - QtVolumeRenderControls, - ) - from PySide6 import QtWidgets - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QCheckBox - - clim_range = geometry.clim_range - decimals = geometry.slider_decimals - closables: list = [] - - panel = QtWidgets.QWidget() - panel.setFixedWidth(300) - root = QtWidgets.QVBoxLayout(panel) - root.setAlignment(Qt.AlignmentFlag.AlignTop) - - # ── 2D rendering (drives all three 2D panels at once) ────────────────── - group_2d = QtWidgets.QGroupBox("2D rendering") - layout_2d = QtWidgets.QVBoxLayout(group_2d) - - ids_2d = [visuals[k].id for k in ("xy", "xz", "yz") if visuals.get(k) is not None] - xy_app = visuals["xy"].appearance - - clim_2d = _MultiVisualClimSlider( - ids_2d, clim_range=clim_range, initial_clim=xy_app.clim, decimals=decimals - ) - controller.connect_widget(clim_2d, subscription_specs=clim_2d.subscription_specs()) - closables.append(clim_2d) - _add_group(layout_2d, "Contrast limits", clim_2d.widget) - - cmap_2d = _MultiVisualColormapCombo(ids_2d, initial_colormap=xy_app.color_map) - controller.connect_widget(cmap_2d, subscription_specs=cmap_2d.subscription_specs()) - closables.append(cmap_2d) - _add_group(layout_2d, "Colormap", cmap_2d.widget) - - lod_2d = _MultiVisualLodBiasSlider(ids_2d, initial_lod_bias=xy_app.lod_bias) - controller.connect_widget(lod_2d, subscription_specs=lod_2d.subscription_specs()) - closables.append(lod_2d) - _add_group(layout_2d, "Fine-coarse tile bias", lod_2d.widget) - - root.addWidget(group_2d) - - # ── 3D rendering (vol panel) ─────────────────────────────────────────── - vol_visual = visuals.get("vol") - if vol_visual is not None: - vol_id = vol_visual.id - vol_app = vol_visual.appearance - group_3d = QtWidgets.QGroupBox("3D rendering") - layout_3d = QtWidgets.QVBoxLayout(group_3d) - - clim_3d = QtClimRangeSlider( - vol_id, clim_range=clim_range, initial_clim=vol_app.clim, decimals=decimals - ) - controller.connect_widget( - clim_3d, subscription_specs=clim_3d.subscription_specs() - ) - closables.append(clim_3d) - _add_group(layout_3d, "Contrast limits", clim_3d.widget) - - cmap_3d = QtColormapCombo(vol_id, initial_colormap=vol_app.color_map) - cmap_3d.add_colormaps(_DEFAULT_COLORMAPS) - controller.connect_widget( - cmap_3d, subscription_specs=cmap_3d.subscription_specs() - ) - closables.append(cmap_3d) - _add_group(layout_3d, "Colormap", cmap_3d.widget) - - render_3d = QtVolumeRenderControls( - vol_id, - dtype_max=clim_range[1], - initial_render_mode=vol_app.render_mode, - initial_threshold=vol_app.iso_threshold, - decimals=decimals, - ) - controller.connect_widget( - render_3d, subscription_specs=render_3d.subscription_specs() - ) - closables.append(render_3d) - _add_group(layout_3d, "Render mode", render_3d.widget) - # The transparency manager observes the appearance model's render_mode - # directly (see attach_ortho_overlays), so no widget signal is wired. - - if overlays is not None: - orient_cb = QCheckBox("Show 3D orientation axes") - orient_cb.setChecked(True) - - def _on_orient_toggled(checked: bool) -> None: - for vid in overlays.axis_visual_ids: - controller.set_visual_visible(vid, checked) - - orient_cb.toggled.connect(_on_orient_toggled) - layout_3d.addWidget(orient_cb) - - root.addWidget(group_3d) - - # ── Slice overlay opacity ────────────────────────────────────────────── - if overlays is not None: - from oz_viewer.viewer._ortho_overlays import _INITIAL_PLANE_OPACITY - - root.addWidget( - _plane_opacity_group(controller, overlays, _INITIAL_PLANE_OPACITY) - ) - - root.addStretch() - return _OrthoControlsHandle(widget=panel, _closables=closables) - - -def build_mc_controls_panel( - controller, - geometry: _ViewerGeometry, - overlays: OrthoOverlays | None, -) -> _OrthoControlsHandle: - """Build the multichannel 3D render + opacity panel (Qt only). - - The per-channel controls come from the cross-toolkit ``ChannelControls`` - dock; this panel adds the volume transparency/opacity and slice-opacity - groups that have no convenience equivalent yet. + overlays : OrthoOverlays + The attached 3D overlays. """ from PySide6 import QtWidgets from PySide6.QtCore import Qt - from superqt import QLabeledDoubleSlider panel = QtWidgets.QWidget() panel.setFixedWidth(300) root = QtWidgets.QVBoxLayout(panel) root.setAlignment(Qt.AlignmentFlag.AlignTop) - if overlays is None: - root.addStretch() - return _OrthoControlsHandle(widget=panel) - - tm = overlays.transparency_manager - - group = QtWidgets.QGroupBox("3D rendering") - layout = QtWidgets.QVBoxLayout(group) - - mode_box = QtWidgets.QGroupBox("Transparency mode") - mode_layout = QtWidgets.QVBoxLayout(mode_box) - mode_combo = QtWidgets.QComboBox() - for m in _TRANSPARENCY_MODES: - mode_combo.addItem(m) - mode_combo.setCurrentText(tm.current_profile.transparency_mode) - mode_combo.currentTextChanged.connect(tm.update_transparency_mode) - mode_layout.addWidget(mode_combo) - layout.addWidget(mode_box) + orient_cb = QtWidgets.QCheckBox("Show 3D orientation axes") + orient_cb.setChecked(True) - opacity_box = QtWidgets.QGroupBox("Volume opacity") - opacity_layout = QtWidgets.QVBoxLayout(opacity_box) - opacity_slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal) - opacity_slider.setRange(0.0, 1.0) - opacity_slider.setSingleStep(0.05) - opacity_slider.setDecimals(2) - opacity_slider.setValue(tm.current_profile.opacity) - opacity_slider.valueChanged.connect(tm.update_opacity) - opacity_layout.addWidget(opacity_slider) - layout.addWidget(opacity_box) + def _on_orient_toggled(checked: bool) -> None: + for vid in overlays.axis_visual_ids: + controller.set_visual_visible(vid, checked) - root.addWidget(group) - - from oz_viewer.viewer._ortho_overlays import _INITIAL_PLANE_OPACITY + orient_cb.toggled.connect(_on_orient_toggled) + root.addWidget(orient_cb) root.addWidget(_plane_opacity_group(controller, overlays, _INITIAL_PLANE_OPACITY)) root.addStretch() return _OrthoControlsHandle(widget=panel) - - -def _add_group(parent_layout, title: str, widget) -> None: - from PySide6 import QtWidgets - - box = QtWidgets.QGroupBox(title) - QtWidgets.QVBoxLayout(box).addWidget(widget) - parent_layout.addWidget(box) diff --git a/src/oz_viewer/viewer/_ortho_overlays.py b/src/oz_viewer/viewer/_ortho_overlays.py index 410ac9a..da786eb 100644 --- a/src/oz_viewer/viewer/_ortho_overlays.py +++ b/src/oz_viewer/viewer/_ortho_overlays.py @@ -34,8 +34,6 @@ _AXIS_3D_CUBE_COLOUR: tuple[float, float, float, float] = (0.75, 0.75, 0.75, 1.0) _N_FACES_PER_BOX: int = 12 -_TRANSPARENCY_MODES: list[str] = ["weighted_blend", "weighted_solid", "blend", "add"] - _INITIAL_PLANE_OPACITY: float = 1.0 @@ -98,22 +96,49 @@ class _VisualRenderProfile: # --------------------------------------------------------------------------- +def _is_mip(render_mode: object) -> bool: + """Whether *render_mode* is a maximum-intensity variant.""" + return str(render_mode) in ("mip", "attenuated_mip") + + class _VolTransparencyManager: - """Manages transparency profiles for each render mode via the model layer.""" + """Keeps the volume and the overlay meshes blending per render mode. + + The effective render mode is the ``vol`` image's: its single appearance's + ``render_mode`` in single mode; in composite mode ``"iso"`` if any held + channel renders an isosurface, otherwise ``"mip"``. It is re-read from + the model whenever a render mode or the image's single/composite mode + changes, whatever the origin (Qt, anywidget, programmatic). + + In single mode the volume's opacity and blending follow a per-render-mode + profile, and edits made through the image control are remembered in the + current profile. A composite hands blending back to cellier (its default + follows the mode) and keeps the channels' own opacities; only the meshes + follow the render mode there. + + Subscribes through ``controller.connect_widget`` (the widget contract: + ``_id``, ``changed``, ``closed``), so the bus rewires it if the image's + ``single`` or ``channels`` models are replaced; :meth:`close` unsubscribes. + """ + + from psygnal import Signal + + #: Required by ``connect_widget``; the manager writes through the + #: controller directly, so this never emits. + changed = Signal(object) + closed = Signal() def __init__( self, controller, vol_visual_id, *, - vol_is_multichannel: bool = False, plane_visual_id=None, axis_visual_ids: list | None = None, - initial_mode: str = "iso", ) -> None: + self._id = uuid4() self._controller = controller self._vol_visual_id = vol_visual_id - self._vol_is_multichannel = vol_is_multichannel self._plane_visual_id = plane_visual_id self._axis_visual_ids: list = axis_visual_ids or [] self._vol_profiles: dict[str, _VolTransparencyProfile] = { @@ -134,9 +159,8 @@ def __init__( "iso": _ISO_AXES_PROFILE, "mip": _MIP_AXES_PROFILE, } - self._current_mode = ( - initial_mode if initial_mode in self._vol_profiles else "iso" - ) + self._current_mode = self._read_mode() + controller.connect_widget(self, subscription_specs=self._subscription_specs()) @property def current_mode(self) -> str: @@ -146,6 +170,73 @@ def current_mode(self) -> str: def current_profile(self) -> _VolTransparencyProfile: return self._vol_profiles[self._current_mode] + def _vol_visual(self): + return self._controller.get_visual_model(self._vol_visual_id) + + def _read_mode(self) -> str: + visual = self._vol_visual() + if visual.composite: + modes = [ch.render_mode for ch in visual.channels.values()] + return "mip" if all(_is_mip(m) for m in modes) else "iso" + return "mip" if _is_mip(visual.single.render_mode) else "iso" + + def _subscription_specs(self) -> list: + from cellier.events import ( + AppearanceChangedEvent, + ChannelAppearanceChangedEvent, + ImageCompositeChangedEvent, + SingleAppearanceChangedEvent, + SubscriptionSpec, + ) + + vid = self._vol_visual_id + return [ + SubscriptionSpec(SingleAppearanceChangedEvent, self._on_single, vid), + SubscriptionSpec(ChannelAppearanceChangedEvent, self._on_channel, vid), + SubscriptionSpec(ImageCompositeChangedEvent, self._on_composite, vid), + SubscriptionSpec(AppearanceChangedEvent, self._on_appearance, vid), + ] + + # -- bus handlers -------------------------------------------------------- + + def _on_single(self, event) -> None: + # field_name is None when the whole single model was replaced. + if event.field_name in (None, "render_mode"): + self._refresh_mode() + elif ( + event.field_name == "opacity" + and event.source_id != self._id + and not self._vol_visual().composite + ): + self.current_profile.opacity = float(event.new_value) + + def _on_channel(self, event) -> None: + if event.field_name in (None, "render_mode"): + self._refresh_mode() + + def _on_composite(self, event) -> None: + # Always re-apply: entering single mode restores the volume profile, + # entering composite mode hands blending back to cellier. + self._current_mode = self._read_mode() + self.apply() + + def _on_appearance(self, event) -> None: + if ( + event.field_name == "transparency_mode" + and event.source_id != self._id + and event.new_value is not None + and not self._vol_visual().composite + ): + self.current_profile.transparency_mode = str(event.new_value) + + # -- application --------------------------------------------------------- + + def _refresh_mode(self) -> None: + new_mode = self._read_mode() + if new_mode != self._current_mode: + self._current_mode = new_mode + self.apply() + def _apply_profile_to_mesh(self, visual_id, profile: _VisualRenderProfile) -> None: if visual_id is None: return @@ -159,28 +250,30 @@ def _apply_profile_to_mesh(self, visual_id, profile: _VisualRenderProfile) -> No c.update_appearance_field(visual_id, "opacity", profile.opacity) def _apply_vol_profile(self) -> None: - """Apply the current volume profile via the model layer. + """Apply the current volume profile, or hand blending back in composite. - For multichannel visuals each ChannelAppearance is mutated directly - since they share no single .appearance object; the psygnal bridge then - routes the changes to the render layer. For single-channel visuals - update_appearance_field is used so source-id threading is preserved. + Writes are stamped with the manager's id so the bus handlers can tell + them apart from user edits. """ + c = self._controller vid = self._vol_visual_id - if vid is None: + if self._vol_visual().composite: + # cellier's defaults: blending follows the mode (None), and the + # composite manages depth between its channel volumes itself. + c.update_appearance_field( + vid, "transparency_mode", None, source_id=self._id + ) + c.update_appearance_field(vid, "depth_write", True, source_id=self._id) return vol = self.current_profile depth_write = vol.transparency_mode == "weighted_solid" - if self._vol_is_multichannel: - visual = self._controller.get_visual_model(vid) - for ch in visual.channels.values(): - ch.transparency_mode = vol.transparency_mode - ch.opacity = vol.opacity - else: - c = self._controller - c.update_appearance_field(vid, "transparency_mode", vol.transparency_mode) - c.update_appearance_field(vid, "opacity", vol.opacity) - c.update_appearance_field(vid, "depth_write", depth_write) + c.update_appearance_field( + vid, "transparency_mode", vol.transparency_mode, source_id=self._id + ) + c.update_appearance_field(vid, "depth_write", depth_write, source_id=self._id) + c.update_single_appearance_field( + vid, "opacity", vol.opacity, source_id=self._id + ) def apply(self) -> None: self._apply_vol_profile() @@ -190,22 +283,9 @@ def apply(self) -> None: for vid in self._axis_visual_ids: self._apply_profile_to_mesh(vid, axes_profile) - def on_render_mode_changed(self, new_mode: str) -> None: - if new_mode in self._vol_profiles: - self._current_mode = new_mode - elif new_mode.startswith("mip"): - self._current_mode = "mip" - else: - self._current_mode = "iso" - self.apply() - - def update_transparency_mode(self, transparency_mode: str) -> None: - self.current_profile.transparency_mode = transparency_mode - self.apply() - - def update_opacity(self, opacity: float) -> None: - self.current_profile.opacity = opacity - self.apply() + def close(self) -> None: + """Unsubscribe from the controller bus.""" + self.closed.emit() # --------------------------------------------------------------------------- @@ -341,21 +421,30 @@ def _spatial_mesh_store( ): """A face-coloured mesh store over the three spatial world axes only. - Its axes copy the world's spatial axes (name, type, unit), so - :func:`_mesh_to_world` can map them one to one. + Its axes copy the world's spatial axes (name, type, unit) with fresh ids, + so :func:`_mesh_to_world` can map them one to one. The coordinate system + is built here rather than derived when the mesh is added, because the + transform that places the mesh is built against it first. """ from cellier.data.mesh import MeshMemoryStore + from cellier.transform import Axis, DataCoordinateSystem zyx = [world.axes[axis] for axis in spatial_axes] + system = DataCoordinateSystem( + name=f"{name}_data", + axes=tuple( + Axis(name=axis.name, axis_type=axis.axis_type, unit=axis.unit) + for axis in zyx + ), + datastore_id=uuid4(), + ) return MeshMemoryStore( positions=positions_zyx, indices=indices, colors=colors, colors_layout="face", name=name, - axis_names=[axis.name for axis in zyx], - axis_types=[axis.axis_type for axis in zyx], - axis_units=[axis.unit for axis in zyx], + data_coordinate_systems=[system], ) @@ -703,21 +792,10 @@ class OrthoOverlays: _orientation_updater: _OrientationUpdater _owner_ids: list _controller: object - _render_mode_callback: object | None = None - _vol_appearance_events: object | None = None def close(self) -> None: - """Unsubscribe every overlay owner and detach the render-mode observer.""" - if ( - self._render_mode_callback is not None - and self._vol_appearance_events is not None - ): - try: - self._vol_appearance_events.render_mode.disconnect( - self._render_mode_callback - ) - except (ValueError, RuntimeError): - pass + """Unsubscribe every overlay owner, the transparency manager included.""" + self.transparency_manager.close() for owner_id in self._owner_ids: self._controller.unsubscribe_owner(owner_id) @@ -727,7 +805,6 @@ def attach_ortho_overlays( geometry: _ViewerGeometry, *, vol_visual_id, - vol_is_multichannel: bool, ) -> OrthoOverlays: """Attach the slice-plane, orientation, and transparency overlays. @@ -743,10 +820,8 @@ def attach_ortho_overlays( geometry : _ViewerGeometry OME-Zarr geometry (spatial axes, world extents, channel axis). vol_visual_id : - The image visual id in the ``vol`` scene the transparency manager owns. - vol_is_multichannel : bool - Whether the ``vol`` visual is a multichannel visual (locks the - transparency manager to MIP and mutates each ``ChannelAppearance``). + The image visual id in the ``vol`` scene the transparency manager + follows; it tracks the image's single/composite mode live. Returns ------- @@ -808,28 +883,14 @@ def attach_ortho_overlays( ) # --- volume transparency manager --- + # Follows the vol image's render mode and single/composite mode from any + # origin (Qt, anywidget, programmatic) through the controller bus. transparency_manager = _VolTransparencyManager( controller, vol_visual_id, - vol_is_multichannel=vol_is_multichannel, plane_visual_id=plane_visual.id, axis_visual_ids=axis_visual_ids, - initial_mode="mip" if vol_is_multichannel else "iso", ) - - render_mode_callback = None - vol_appearance_events = None - if not vol_is_multichannel: - # Observe the evented appearance model directly so the transparency - # profile tracks render_mode changes from any origin (Qt, anywidget, - # programmatic) with no widget-signal plumbing (conversion plan D4.4). - vol_visual = controller.get_visual_model(vol_visual_id) - vol_appearance_events = vol_visual.appearance.events - - def render_mode_callback(new_mode) -> None: - transparency_manager.on_render_mode_changed(str(new_mode)) - - vol_appearance_events.render_mode.connect(render_mode_callback) transparency_manager.apply() # --- orientation updater (camera + dims driven) --- @@ -903,6 +964,4 @@ def _seed_cameras() -> None: _orientation_updater=orient_updater, _owner_ids=owner_ids, _controller=controller, - _render_mode_callback=render_mode_callback, - _vol_appearance_events=vol_appearance_events, ) diff --git a/src/oz_viewer/viewer/_orthoviewer.py b/src/oz_viewer/viewer/_orthoviewer.py index 6386939..2581ab2 100644 --- a/src/oz_viewer/viewer/_orthoviewer.py +++ b/src/oz_viewer/viewer/_orthoviewer.py @@ -2,15 +2,16 @@ Rebuilt on :class:`cellier.convenience.OrthoViewer`, so the same builder runs under both ``gui="qt"`` (desktop / CLI) and ``gui="anywidget"`` (Jupyter / -marimo). The four pre-wired panels, cross-panel extra-axis sync, and the -per-channel ``ChannelControls`` dock come from the convenience API; the -OME-Zarr-specific geometry (see :mod:`oz_viewer.viewer._geometry`) and the 3D -overlays (see :mod:`oz_viewer.viewer._ortho_overlays`) are re-attached on top. - -The single-channel appearance panel and the volume render/opacity groups have -no convenience equivalent for an ``OrthoViewer``, so they are built as Qt-only -docks (see :mod:`oz_viewer.viewer._ortho_controls`) that are omitted in the -anywidget path. +marimo). The four pre-wired panels, cross-panel axis sync, the image fanned +out to every panel, and the image control (``AppearanceControls``, driving all +four panels together, with the single/composite switch) come from the +convenience API; the OME-Zarr-specific geometry (see +:mod:`oz_viewer.viewer._geometry`) and the 3D overlays (see +:mod:`oz_viewer.viewer._ortho_overlays`) are re-attached on top. + +The overlay controls (orientation gizmo toggle, slice-plane opacity) have no +convenience equivalent, so they are a Qt-only dock (see +:mod:`oz_viewer.viewer._ortho_controls`) that is omitted in the anywidget path. """ from __future__ import annotations @@ -20,6 +21,7 @@ from typing import TYPE_CHECKING, Literal from oz_viewer.viewer._geometry import _ViewerGeometry, extract_viewer_geometry +from oz_viewer.viewer._image import controller_render_config, image_visual_kwargs from oz_viewer.viewer._ortho_overlays import ( _PLANE_COLOR_XY, _PLANE_COLOR_XZ, @@ -33,7 +35,6 @@ _perf_mark, _sidecar_options, ) -from oz_viewer.viewer._widgets import _DEFAULT_COLORMAPS if TYPE_CHECKING: from cellier.convenience import OrthoViewer @@ -41,44 +42,15 @@ from oz_viewer._perf import StartupPerfTracer -# Per-channel fields exposed in the multichannel dock, in order. -_CHANNEL_FIELDS = ["visible", "color_map", "clim", "opacity"] - - -def _controller_render_config() -> object: - """The controller render-pipeline config shared by every panel.""" - from cellier.render import ( - RenderManagerConfig, - SlicingConfig, - TemporalAccumulationConfig, - ) - - return RenderManagerConfig( - slicing=SlicingConfig(batch_size=32, render_every=4), - temporal=TemporalAccumulationConfig(enabled=False), - ) - - -def _panel_render_config(gpu_budget_mb: int) -> object: - """LOD / GPU-budget config for a multiscale image visual.""" - from cellier.visuals import MultiscaleImageRenderConfig - - return MultiscaleImageRenderConfig( - block_size=32, - gpu_budget_bytes=gpu_budget_mb * 1024**2, - gpu_budget_bytes_2d=64 * 1024**2, - ) - @dataclass class _OrthoBuild: """The convenience OrthoViewer plus the handles overlays/controls need.""" viewer: OrthoViewer + #: The image's panel siblings, keyed ``xy``/``xz``/``yz``/``vol``. visuals: dict - is_multichannel: bool vol_visual_id: object - channel_appearances: dict | None = None overlays: OrthoOverlays | None = None @@ -95,9 +67,9 @@ def build_ortho_viewer( ) -> _OrthoBuild: """Build a :class:`cellier.convenience.OrthoViewer` for an OME-Zarr store. - Chooses a single-channel or multichannel visual at build time based on - whether *geometry* found a channel axis (D1 in the conversion plan): there - is no runtime single<->multichannel toggle. + Fans one multiscale image out to every panel. When *geometry* found a + channel axis the image starts in composite mode; the image control + switches every panel to single mode (and back) at runtime. Parameters ---------- @@ -125,148 +97,46 @@ def build_ortho_viewer( viewer = OrthoViewer( geometry.world, spatial_axes=geometry.spatial_axes, - render_config=_controller_render_config(), + render_config=controller_render_config(), gui=gui, ) viewer.controller.camera_reslice_enabled = True viewer.controller.camera_settle_threshold_s = 0.3 - if geometry.channel_axis is not None: - build = _add_multichannel_visuals(viewer, data_store, geometry) - else: - build = _add_single_channel_visuals(viewer, data_store, geometry) - + build = _add_image_visuals(viewer, data_store, geometry) _center_ortho_slices(viewer, geometry) return build -def _add_single_channel_visuals( +def _add_image_visuals( viewer: OrthoViewer, data_store: OMEZarrImageDataStore, geometry: _ViewerGeometry, ) -> _OrthoBuild: - """Add a single-channel multiscale image to every panel. + """Fan one multiscale image out to every panel. - The three 2D panels share a MIP/viridis appearance; the ``vol`` panel gets - its own ISO/white appearance forced to the coarsest level (matching the - hand-built orthoviewer), so visuals are added per scene rather than via the - convenience fan-out. + The image control drives the four panels as one group, so they share an + appearance. Only the ``vol`` panel's level-of-detail policy differs: it + is pinned to the coarsest level and not frustum-culled. Those shared + appearance fields are not in the control, so the group never overwrites + them. """ - from cellier.visuals import MultiscaleImageAppearance - controller = viewer.controller - scenes = viewer.scenes - clim_max = geometry.initial_clim_max - coarsest_level = data_store.n_levels - 1 - - visuals: dict = {} - for key in ("xy", "xz", "yz"): - visuals[key] = controller.add_image_multiscale( - data_store, - scenes[key].id, - appearance=MultiscaleImageAppearance( - color_map="viridis", - clim=(0.0, clim_max), - lod_bias=1.0, - iso_threshold=0.2, - render_mode="mip", - frustum_cull=True, - ), - name=f"{key}_volume", - render_config=_panel_render_config(512), - transform=geometry.voxel_to_world, - ) - - vol_visual = controller.add_image_multiscale( + visuals = viewer.add_image_multiscale( data_store, - scenes["vol"].id, - appearance=MultiscaleImageAppearance( - color_map="white", - clim=(0.0, clim_max), - lod_bias=1.0, - force_level=coarsest_level, - frustum_cull=False, - iso_threshold=clim_max / 2.0, - render_mode="iso", - ), - name="vol_volume", - render_config=_panel_render_config(2048), - transform=geometry.voxel_to_world, + name="image", + **image_visual_kwargs(geometry, render_mode="iso", lod_bias=1.0), ) - vol_visual.aabb.enabled = True - vol_visual.aabb.color = "#ff00ff" - visuals["vol"] = vol_visual - return _OrthoBuild( - viewer=viewer, - visuals=visuals, - is_multichannel=False, - vol_visual_id=vol_visual.id, - ) - - -def _add_multichannel_visuals( - viewer: OrthoViewer, - data_store: OMEZarrImageDataStore, - geometry: _ViewerGeometry, -) -> _OrthoBuild: - """Add a multichannel multiscale image to every panel + channel controls. - - The channel axis is moved to ``stacked_axes`` on each panel so it renders - as a composited stack (all channels at once) with no redundant dims slider; - the cross-toolkit ``ChannelControls`` dock owns per-channel visibility. - """ - from cellier.convenience import ChannelControlsConfig - from cellier.visuals import ChannelAppearance - - controller = viewer.controller - channel_axis = geometry.channel_axis - assert channel_axis is not None - clim_max = geometry.initial_clim_max - n = geometry.n_channels - - channels = { - i: ChannelAppearance( - color_map=_DEFAULT_COLORMAPS[i % len(_DEFAULT_COLORMAPS)], - clim=(0.0, clim_max), - visible=True, - ) - for i in range(n) - } - # Raise the per-visual channel-node budget to cover every channel so - # construction and the ChannelControls dock never exceed the cap. - max_channels = max(8, n) - visuals = viewer.add_multichannel_image_multiscale( - data_store, - channel_axis=channel_axis, - channels=channels, - name="multichannel", - render_config=_panel_render_config(512), - transform=geometry.voxel_to_world, - max_channels_2d=max_channels, - max_channels_3d=max_channels, - controls=ChannelControlsConfig( - fields=_CHANNEL_FIELDS, - colormap_names=_DEFAULT_COLORMAPS, - clim_range=geometry.clim_range, - ), + vol_visual = visuals["vol"] + controller.update_appearance_field( + vol_visual.id, "force_level", data_store.n_levels - 1 ) + controller.update_appearance_field(vol_visual.id, "frustum_cull", False) + vol_visual.aabb.enabled = True + vol_visual.aabb.color = "#ff00ff" - # Stack the channel axis on every panel: drop it from slice_indices and mark - # it stacked so all channels render and no channel slider is shown. - for scene in viewer.scenes.values(): - current = dict(scene.dims.selection.slice_indices) - current.pop(channel_axis, None) - controller.update_slice_indices(scene.id, current) - controller.set_stacked_axes(scene.id, (channel_axis,)) - - return _OrthoBuild( - viewer=viewer, - visuals=visuals, - is_multichannel=True, - vol_visual_id=visuals["vol"].id, - channel_appearances=channels, - ) + return _OrthoBuild(viewer=viewer, visuals=visuals, vol_visual_id=vol_visual.id) def _center_ortho_slices(viewer: OrthoViewer, geometry: _ViewerGeometry) -> None: @@ -279,11 +149,10 @@ def _center_ortho_slices(viewer: OrthoViewer, geometry: _ViewerGeometry) -> None """ center = geometry.center_slice_indices() for scene in viewer.scenes.values(): - current = dict(scene.dims.selection.slice_indices) - updated = {a: center[a] for a in center if a in current} - if updated and any(current[a] != v for a, v in updated.items()): - current.update(updated) - viewer.controller.update_slice_indices(scene.id, current) + # Panels after the first are usually already mirrored by the axis sync. + current = scene.dims.selection.slice_indices + if any(current.get(a) != v for a, v in center.items()): + viewer.controller.update_slice_indices(scene.id, center) # --------------------------------------------------------------------------- @@ -316,7 +185,7 @@ def build_ortho_layout( The layout spec plus the grid widget/view (kept by the caller so it is not garbage collected and so a paint tracker can be installed). """ - from cellier.convenience import ChannelControls, Layout + from cellier.convenience import AppearanceControls, Layout from cellier.convenience.gui import build_ortho_grid_widget viewer = build.viewer @@ -329,11 +198,8 @@ def build_ortho_layout( # Screen-space 2D orientation axes on each slice panel (both toolkits). _add_2d_axis_overlays(viewer) - docks: dict = {} - if build.is_multichannel: - docks["left_dock"] = ChannelControls() - - layout = Layout(center=grid, **docks) + # The image control drives all four panels' sibling visuals together. + layout = Layout(center=grid, left_dock=AppearanceControls()) return layout, grid @@ -353,7 +219,7 @@ def _add_2d_axis_overlays(viewer: OrthoViewer) -> None: canvas_ids = controller.get_canvas_ids(scenes[key].id) if not canvas_ids: continue - controller.add_canvas_overlay_model( + controller.add_canvas_overlay( canvas_ids[0], CenteredAxes2D( name=name, @@ -380,6 +246,7 @@ def launch_orthoviewer( theme: str = "dark", *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, perf: StartupPerfTracer | None = None, gui: Literal["qt", "anywidget"] = "qt", ) -> None: @@ -399,6 +266,11 @@ def launch_orthoviewer( channel_axis : int or None, optional Axis index to treat as the channel dimension. Auto-detected from the OME-Zarr metadata when ``None``. + infer_multiscale_translations : bool, optional + Give the coarser levels the half-voxel offsets of centre-aligned + downsampling (e.g. block averages) when the store declares no level + translations; warns and keeps the declared ones otherwise. Default + ``False``: wrong for pyramids made by striding. perf : StartupPerfTracer or None, optional Optional startup performance tracer. gui : "qt" or "anywidget" @@ -429,7 +301,12 @@ def launch_orthoviewer( _perf_mark(perf, "viewer.launch.qapp_ready") QtAsyncio.run( - _run_orthoviewer_async(zarr_uri, channel_axis=channel_axis, perf=perf), + _run_orthoviewer_async( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + perf=perf, + ), handle_sigint=True, ) @@ -438,6 +315,7 @@ async def _run_orthoviewer_async( zarr_uri: str, *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, perf: StartupPerfTracer | None = None, ) -> None: """Build, show, and keep the orthoviewer alive until the window closes.""" @@ -446,7 +324,12 @@ async def _run_orthoviewer_async( asyncio.get_event_loop().set_exception_handler(_asyncio_exception_handler) _perf_mark(perf, "viewer.async.start") - handle = _build_and_show_ortho_qt(zarr_uri, channel_axis=channel_axis, perf=perf) + handle = _build_and_show_ortho_qt( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + perf=perf, + ) _perf_mark(perf, "viewer.async.build_complete") app = QApplication.instance() @@ -483,6 +366,7 @@ def _build_and_show_ortho_qt( zarr_uri: str, *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, perf: StartupPerfTracer | None = None, ) -> _OrthoHandle: """Build the Qt orthoviewer window, show it, and arm first-frame startup.""" @@ -494,16 +378,16 @@ def _build_and_show_ortho_qt( _perf_mark(perf, "viewer.build.start") data_store, geometry = extract_viewer_geometry( - zarr_uri, channel_axis=channel_axis, perf=perf + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + perf=perf, ) build = build_ortho_viewer(data_store, geometry, gui="qt") _perf_mark(perf, "viewer.build.model_ready") build.overlays = attach_ortho_overlays( - build.viewer, - geometry, - vol_visual_id=build.vol_visual_id, - vol_is_multichannel=build.is_multichannel, + build.viewer, geometry, vol_visual_id=build.vol_visual_id ) _perf_mark(perf, "viewer.build.overlays_ready") @@ -512,7 +396,7 @@ def _build_and_show_ortho_qt( window.setWindowTitle("OME-Zarr Orthoviewer") _perf_mark(perf, "viewer.build.window_ready") - controls = _attach_qt_controls(build, geometry, window) + controls = _attach_qt_controls(build, window) _perf_mark(perf, "viewer.build.controls_ready") handle = _OrthoHandle( @@ -531,45 +415,28 @@ def _build_and_show_ortho_qt( return handle -def _attach_qt_controls( - build: _OrthoBuild, - geometry: _ViewerGeometry, - window, -) -> object: - """Bolt oz's Qt-only control panel onto the rendered ``QMainWindow``. +def _attach_qt_controls(build: _OrthoBuild, window) -> object | None: + """Bolt oz's Qt-only overlay panel onto the rendered ``QMainWindow``. - Single-channel: a full appearance panel on the left dock. Multichannel: - the volume render/opacity groups on the right dock (per-channel controls - are already in the layout's left ``ChannelControls`` dock). + The image control is already in the layout's left ``AppearanceControls`` + dock; this adds the overlay toggles on the right. """ from PySide6 import QtWidgets from PySide6.QtCore import Qt - from oz_viewer.viewer._ortho_controls import ( - build_mc_controls_panel, - build_sc_controls_panel, - ) + from oz_viewer.viewer._ortho_controls import build_overlay_controls_panel - if build.is_multichannel: - controls = build_mc_controls_panel( - build.viewer.controller, geometry, build.overlays - ) - area = Qt.DockWidgetArea.RightDockWidgetArea - title = "Volume" - else: - controls = build_sc_controls_panel( - build.viewer.controller, build.visuals, geometry, build.overlays - ) - area = Qt.DockWidgetArea.LeftDockWidgetArea - title = "Rendering" + if build.overlays is None: + return None + controls = build_overlay_controls_panel(build.viewer.controller, build.overlays) - dock = QtWidgets.QDockWidget(title, window) + dock = QtWidgets.QDockWidget("Overlays", window) dock.setWidget(controls.widget) dock.setFeatures( QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetMovable | QtWidgets.QDockWidget.DockWidgetFeature.DockWidgetFloatable ) - window.addDockWidget(area, dock) + window.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, dock) return controls @@ -634,6 +501,7 @@ def orthoviewer( theme: str = "dark", *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, ) -> _OrthoHandle: """Open a Qt orthoviewer window without blocking (IPython / interactive). @@ -658,13 +526,18 @@ def orthoviewer( "notebooks, or run inside IPython/Jupyter." ) apply_theme(QApplication.instance(), theme) - return _build_and_show_ortho_qt(zarr_uri, channel_axis=channel_axis) + return _build_and_show_ortho_qt( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + ) def display_orthoviewer( zarr_uri: str, *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, sidecar: bool = False, min_canvas_size: tuple[int, int] | None = None, ): @@ -672,9 +545,8 @@ def display_orthoviewer( The notebook counterpart of :func:`launch_orthoviewer`. Returns the cellier ``DisplayHandle`` (Jupyter) or the host-native renderable (marimo). The - Qt-only appearance panels are omitted; the four synced panels, the 3D - overlays, and (for multichannel data) the ``ChannelControls`` dock render - normally. + Qt-only overlay panel is omitted; the four synced panels, the 3D overlays, + and the image control dock render normally. Parameters ---------- @@ -683,6 +555,11 @@ def display_orthoviewer( channel_axis : int or None, optional Axis index to treat as the channel dimension. Auto-detected when ``None``. + infer_multiscale_translations : bool, optional + Give the coarser levels the half-voxel offsets of centre-aligned + downsampling (e.g. block averages) when the store declares no level + translations; warns and keeps the declared ones otherwise. Default + ``False``: wrong for pyramids made by striding. sidecar : bool Present the orthoviewer in a ``jupyterlab-sidecar`` tab instead of below the cell. Requires the optional ``sidecar`` package (raises @@ -697,13 +574,14 @@ def display_orthoviewer( """ from cellier.convenience import display - data_store, geometry = extract_viewer_geometry(zarr_uri, channel_axis=channel_axis) + data_store, geometry = extract_viewer_geometry( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + ) build = build_ortho_viewer(data_store, geometry, gui="anywidget") build.overlays = attach_ortho_overlays( - build.viewer, - geometry, - vol_visual_id=build.vol_visual_id, - vol_is_multichannel=build.is_multichannel, + build.viewer, geometry, vol_visual_id=build.vol_visual_id ) layout, _grid = build_ortho_layout(build, geometry, min_canvas_size=min_canvas_size) return display( diff --git a/src/oz_viewer/viewer/_viewer.py b/src/oz_viewer/viewer/_viewer.py index d72227e..0b6cd08 100644 --- a/src/oz_viewer/viewer/_viewer.py +++ b/src/oz_viewer/viewer/_viewer.py @@ -2,12 +2,13 @@ Rebuilt on top of :mod:`cellier.convenience`, so the same builder runs under both ``gui="qt"`` (desktop / CLI) and ``gui="anywidget"`` (Jupyter / marimo). -Appearance controls and per-channel controls are provided by cellier's -cross-toolkit ``Layout`` docks and the 2D/3D toggle by the dims control -embedded in the canvas view; this module only supplies the -OME-Zarr-specific geometry (see :mod:`oz_viewer.viewer._geometry`) and the -Qt-specific launch niceties oz-viewer cares about (theme, fsspec loop, asyncio -exception handling, startup perf tracing). +The image control (both single and composite modes, and the switch between +them) comes from cellier's cross-toolkit ``AppearanceControls`` dock and the +2D/3D toggle from the dims control embedded in the canvas view; this module +only supplies the OME-Zarr-specific geometry (see +:mod:`oz_viewer.viewer._geometry`) and the Qt-specific launch niceties +oz-viewer cares about (theme, fsspec loop, asyncio exception handling, startup +perf tracing). """ from __future__ import annotations @@ -16,13 +17,13 @@ from typing import TYPE_CHECKING, Literal from oz_viewer.viewer._geometry import _ViewerGeometry, extract_viewer_geometry +from oz_viewer.viewer._image import controller_render_config, image_visual_kwargs from oz_viewer.viewer._utils import ( _asyncio_exception_handler, _ensure_qt_app, _perf_mark, _sidecar_options, ) -from oz_viewer.viewer._widgets import _DEFAULT_COLORMAPS if TYPE_CHECKING: from cellier.convenience import Viewer @@ -31,46 +32,9 @@ from oz_viewer._perf import StartupPerfTracer -# Appearance fields exposed in the single-channel appearance dock, in order. -_APPEARANCE_FIELDS = [ - "color_map", - "clim", - "render_mode", - "iso_threshold", - "attenuation", - "lod_bias", -] -# Per-channel fields exposed in the multichannel dock, in order. -_CHANNEL_FIELDS = ["visible", "color_map", "clim", "opacity"] - _INITIAL_LOD_BIAS = 1.5 -def _render_config() -> object: - """The controller render-pipeline config used by both viewers.""" - from cellier.render import ( - RenderManagerConfig, - SlicingConfig, - TemporalAccumulationConfig, - ) - - return RenderManagerConfig( - slicing=SlicingConfig(batch_size=32, render_every=4), - temporal=TemporalAccumulationConfig(enabled=False), - ) - - -def _visual_render_config() -> object: - """LOD / GPU-budget config for the single-panel multiscale visual.""" - from cellier.visuals import MultiscaleImageRenderConfig - - return MultiscaleImageRenderConfig( - block_size=32, - gpu_budget_bytes=2048 * 1024**2, - gpu_budget_bytes_2d=64 * 1024**2, - ) - - # --------------------------------------------------------------------------- # Layer 1: build the convenience Viewer (no launch, no Qt event loop) # --------------------------------------------------------------------------- @@ -84,9 +48,9 @@ def build_viewer( ) -> Viewer: """Build a :class:`cellier.convenience.Viewer` for an OME-Zarr store. - Chooses a single-channel or multichannel visual at build time based on - whether *geometry* found a channel axis (D1 in the conversion plan): there - is no runtime single<->multichannel toggle. + Adds one multiscale image visual. When *geometry* found a channel axis + the visual starts in composite mode; the image control switches it to + single mode (and back) at runtime. Parameters ---------- @@ -106,112 +70,25 @@ def build_viewer( viewer = Viewer( geometry.world, dim="2d", - render_config=_render_config(), + render_config=controller_render_config(), gui=gui, ) viewer.controller.camera_reslice_enabled = True viewer.controller.camera_settle_threshold_s = 0.3 - if geometry.channel_axis is not None: - _add_multichannel_visual(viewer, data_store, geometry) - else: - _add_single_channel_visual(viewer, data_store, geometry) - - # Center the sliced spatial axis (e.g. Z) at the volume midpoint; extra - # axes such as channel keep their default position of 0. - center = geometry.center_slice_indices() - current = dict(viewer.scene.dims.selection.slice_indices) - updated = {a: center[a] for a in center if a in current} - if updated: - current.update(updated) - viewer.controller.update_slice_indices(viewer.scene.id, current) - - return viewer - - -def _add_single_channel_visual( - viewer: Viewer, - data_store: OMEZarrImageDataStore, - geometry: _ViewerGeometry, -) -> None: - """Add a single-channel multiscale image visual + appearance controls.""" - from cellier.convenience import MultiscaleImageControlsConfig - from cellier.visuals import MultiscaleImageAppearance - - clim_max = geometry.initial_clim_max viewer.add_image_multiscale( data_store, - appearance=MultiscaleImageAppearance( - color_map="viridis", - clim=(0.0, clim_max), - lod_bias=_INITIAL_LOD_BIAS, - iso_threshold=clim_max / 2.0, - render_mode="mip", - attenuation=1.0, - ), name="volume", - render_config=_visual_render_config(), - transform=geometry.voxel_to_world, - controls=MultiscaleImageControlsConfig( - appearance=_APPEARANCE_FIELDS, - colormap_names=_DEFAULT_COLORMAPS, - clim_range=geometry.clim_range, - ), + **image_visual_kwargs(geometry, render_mode="mip", lod_bias=_INITIAL_LOD_BIAS), ) - -def _add_multichannel_visual( - viewer: Viewer, - data_store: OMEZarrImageDataStore, - geometry: _ViewerGeometry, -) -> None: - """Add a multichannel multiscale visual + per-channel controls. - - The channel axis is moved to ``stacked_axes`` so it renders as a stack (all - channels at once) and no redundant dims slider appears for it -- the - ``ChannelControls`` dock owns per-channel visibility instead. - """ - from cellier.convenience import ChannelControlsConfig - from cellier.visuals import ChannelAppearance - - clim_max = geometry.initial_clim_max - channel_axis = geometry.channel_axis - assert channel_axis is not None - n = geometry.n_channels - # Start every channel visible; the ChannelControls dock toggles from there. - channels = { - i: ChannelAppearance( - color_map=_DEFAULT_COLORMAPS[i % len(_DEFAULT_COLORMAPS)], - clim=(0.0, clim_max), - visible=True, - ) - for i in range(n) - } - # Raise the channel-node budget to cover every channel so construction and - # the ChannelControls dock never exceed the cap for real (few-channel) data. - max_channels = max(8, n) - viewer.add_multichannel_image_multiscale( - data_store, - channel_axis=channel_axis, - channels=channels, - name="multichannel_volume", - render_config=_visual_render_config(), - transform=geometry.voxel_to_world, - max_channels_2d=max_channels, - max_channels_3d=max_channels, - controls=ChannelControlsConfig( - fields=_CHANNEL_FIELDS, - colormap_names=_DEFAULT_COLORMAPS, - clim_range=geometry.clim_range, - ), + # Center the spatial axes at the volume midpoint; extra axes such as + # channel keep their default position of 0. update_slice_indices merges. + viewer.controller.update_slice_indices( + viewer.scene.id, geometry.center_slice_indices() ) - # Stack the channel axis: drop it from slice_indices and mark it stacked so - # the multichannel visual renders every channel and no slider is shown. - current = dict(viewer.scene.dims.selection.slice_indices) - current.pop(channel_axis, None) - viewer.controller.update_slice_indices(viewer.scene.id, current) - viewer.controller.set_stacked_axes(viewer.scene.id, (channel_axis,)) + return viewer def build_viewer_layout( @@ -239,7 +116,7 @@ def build_viewer_layout( The layout spec plus the canvas view/widget (kept by the caller so it can install a paint tracker or avoid GC). """ - from cellier.convenience import AppearanceControls, ChannelControls, Layout + from cellier.convenience import AppearanceControls, Layout from cellier.convenience.gui import build_canvas_widget canvas_view = build_canvas_widget( @@ -249,15 +126,10 @@ def build_viewer_layout( canvas_size=min_canvas_size, ) - # Left dock: per-channel controls for multichannel data, otherwise the - # single-channel appearance panel. The 2D/3D toggle needs no dock of its - # own -- cellier embeds it in the canvas view's dims control. - if geometry.channel_axis is not None: - left: object = ChannelControls() - else: - left = AppearanceControls() - - layout = Layout(center=canvas_view, left_dock=left) + # Left dock: the image control, which holds both modes and the switch + # between them. The 2D/3D toggle needs no dock of its own -- cellier + # embeds it in the canvas view's dims control. + layout = Layout(center=canvas_view, left_dock=AppearanceControls()) return layout, canvas_view @@ -271,6 +143,7 @@ def launch_viewer( theme: str = "dark", *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, perf: StartupPerfTracer | None = None, gui: Literal["qt", "anywidget"] = "qt", ) -> None: @@ -290,6 +163,11 @@ def launch_viewer( channel_axis : int or None, optional Axis index to treat as the channel dimension. Auto-detected from the OME-Zarr metadata when ``None``. + infer_multiscale_translations : bool, optional + Give the coarser levels the half-voxel offsets of centre-aligned + downsampling (e.g. block averages) when the store declares no level + translations; warns and keeps the declared ones otherwise. Default + ``False``: wrong for pyramids made by striding. perf : StartupPerfTracer or None, optional Optional startup performance tracer. gui : "qt" or "anywidget" @@ -321,7 +199,12 @@ def launch_viewer( _perf_mark(perf, "viewer.launch.qapp_ready") QtAsyncio.run( - _run_viewer_async(zarr_uri, channel_axis=channel_axis, perf=perf), + _run_viewer_async( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + perf=perf, + ), handle_sigint=True, ) @@ -330,6 +213,7 @@ async def _run_viewer_async( zarr_uri: str, *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, perf: StartupPerfTracer | None = None, ) -> None: """Build, show, and keep the viewer alive until the window closes.""" @@ -338,7 +222,12 @@ async def _run_viewer_async( asyncio.get_event_loop().set_exception_handler(_asyncio_exception_handler) _perf_mark(perf, "viewer.async.start") - holder = _build_and_show_viewer_qt(zarr_uri, channel_axis=channel_axis, perf=perf) + holder = _build_and_show_viewer_qt( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + perf=perf, + ) _perf_mark(perf, "viewer.async.build_complete") app = QApplication.instance() @@ -365,6 +254,7 @@ def _build_and_show_viewer_qt( zarr_uri: str, *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, perf: StartupPerfTracer | None = None, ) -> _ViewerHandle: """Build the Qt viewer window, show it, and arm first-frame startup.""" @@ -377,7 +267,10 @@ def _build_and_show_viewer_qt( _perf_mark(perf, "viewer.build.start") data_store, geometry = extract_viewer_geometry( - zarr_uri, channel_axis=channel_axis, perf=perf + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + perf=perf, ) viewer = build_viewer(data_store, geometry, gui="qt") _perf_mark(perf, "viewer.build.model_ready") @@ -440,6 +333,7 @@ def viewer( theme: str = "dark", *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, ) -> _ViewerHandle: """Open a Qt viewer window without blocking (IPython / interactive). @@ -464,13 +358,18 @@ def viewer( "or run inside IPython/Jupyter." ) apply_theme(QApplication.instance(), theme) - return _build_and_show_viewer_qt(zarr_uri, channel_axis=channel_axis) + return _build_and_show_viewer_qt( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + ) def display_viewer( zarr_uri: str, *, channel_axis: int | None = None, + infer_multiscale_translations: bool = False, sidecar: bool = False, min_canvas_size: tuple[int, int] | None = None, ): @@ -486,6 +385,11 @@ def display_viewer( channel_axis : int or None, optional Axis index to treat as the channel dimension. Auto-detected when ``None``. + infer_multiscale_translations : bool, optional + Give the coarser levels the half-voxel offsets of centre-aligned + downsampling (e.g. block averages) when the store declares no level + translations; warns and keeps the declared ones otherwise. Default + ``False``: wrong for pyramids made by striding. sidecar : bool Present the viewer in a ``jupyterlab-sidecar`` tab instead of below the cell. Requires the optional ``sidecar`` package (raises @@ -501,7 +405,11 @@ def display_viewer( """ from cellier.convenience import display - data_store, geometry = extract_viewer_geometry(zarr_uri, channel_axis=channel_axis) + data_store, geometry = extract_viewer_geometry( + zarr_uri, + channel_axis=channel_axis, + infer_multiscale_translations=infer_multiscale_translations, + ) v = build_viewer(data_store, geometry, gui="anywidget") layout, _canvas_view = build_viewer_layout( v, geometry, min_canvas_size=min_canvas_size diff --git a/src/oz_viewer/viewer/_widgets.py b/src/oz_viewer/viewer/_widgets.py index 5fe37f8..e2af82b 100644 --- a/src/oz_viewer/viewer/_widgets.py +++ b/src/oz_viewer/viewer/_widgets.py @@ -1,9 +1,7 @@ -"""Shared GUI widgets used by both the single-panel viewer and the orthoviewer.""" +"""Shared GUI constants used by both the single-panel viewer and the orthoviewer.""" from __future__ import annotations -from uuid import uuid4 - _DEFAULT_COLORMAPS: list[str] = [ "viridis", "plasma", @@ -28,335 +26,3 @@ "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.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.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.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.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.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.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 diff --git a/tests/conftest.py b/tests/conftest.py index 133ee45..f628a53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,8 +3,10 @@ from __future__ import annotations import functools +import sys import threading import time +import weakref from http.server import HTTPServer, SimpleHTTPRequestHandler from typing import TYPE_CHECKING, Literal @@ -15,6 +17,48 @@ from pathlib import Path +@pytest.fixture(autouse=True) +def _close_cellier_controllers(monkeypatch): + """Close every ``CellierController`` a test creates. + + A controller owns render canvases and GPU resources that the GUI backend + holds, not Python refcounting. A test that only closes its window leaves + them to the garbage collector, which may run their teardown at any later + moment on any thread -- including inside zarr's I/O thread while a later + test writes an example store, which then deadlocks. Mirrors cellier's own + test teardown. + """ + from cellier.controller import CellierController + + created: list[weakref.ref] = [] + original_init = CellierController.__init__ + + def _tracking_init(self, *args, **kwargs): + original_init(self, *args, **kwargs) + created.append(weakref.ref(self)) + + monkeypatch.setattr(CellierController, "__init__", _tracking_init) + + yield + + for ref in created: + controller = ref() + if controller is None: + continue + try: + controller.close() + except Exception: + # Teardown must not turn a passing test into an error. + pass + + # Qt deletes a closed widget only when the event loop next runs. + widgets_module = sys.modules.get("PySide6.QtWidgets") + if widgets_module is not None: + app = widgets_module.QApplication.instance() + if app is not None: + app.processEvents() + + @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. diff --git a/tests/test_cli.py b/tests/test_cli.py index 7fe1703..7ba72a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ import types from typing import TYPE_CHECKING +import pytest from typer.testing import CliRunner if TYPE_CHECKING: @@ -133,7 +134,7 @@ def test_ortho_perf_startup_flag_enables_tracer(tmp_path, monkeypatch): captured: dict[str, object] = {} - def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None): + def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None, **_): captured["zarr_uri"] = zarr_uri captured["theme"] = theme captured["perf"] = perf @@ -159,7 +160,7 @@ def test_ortho_perf_table_flag_sets_tracer_show_table(tmp_path, monkeypatch): captured: dict[str, object] = {} - def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None): + def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None, **_): captured["perf"] = perf monkeypatch.setitem( @@ -183,7 +184,7 @@ def test_ortho_perf_table_title_sets_tracer_title(tmp_path, monkeypatch): captured: dict[str, object] = {} - def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None): + def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None, **_): captured["perf"] = perf monkeypatch.setitem( @@ -214,7 +215,7 @@ def test_ortho_perf_env_enables_tracer(tmp_path, monkeypatch): captured: dict[str, object] = {} - def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None): + def _fake_launch(zarr_uri, theme="dark", perf=None, channel_axis=None, **_): captured["perf"] = perf monkeypatch.setitem( @@ -282,3 +283,37 @@ def test_startup_perf_tracer_rich_table_uses_default_title(capsys): stderr = capsys.readouterr().err assert "Tracer Default Title" in stderr + + +# --------------------------------------------------------------------------- +# --infer-multiscale-translations +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("command", "launcher"), + [("view", "launch_viewer"), ("ortho", "launch_orthoviewer")], +) +@pytest.mark.parametrize("flag", [True, False]) +def test_infer_multiscale_translations_flag_reaches_launcher( + tmp_path, monkeypatch, command, launcher, flag +): + zarr_path = tmp_path / "demo.zarr" + zarr_path.mkdir() + captured: dict[str, object] = {} + + def _fake_launch(zarr_uri, **kwargs): + captured.update(kwargs) + + monkeypatch.setitem( + sys.modules, + "oz_viewer.viewer", + types.SimpleNamespace(**{launcher: _fake_launch}), + ) + + args = [command, str(zarr_path)] + if flag: + args.append("--infer-multiscale-translations") + result = runner.invoke(app, args) + assert result.exit_code == 0, result.output + assert captured["infer_multiscale_translations"] is flag diff --git a/tests/test_level_translations.py b/tests/test_level_translations.py new file mode 100644 index 0000000..0c23d7f --- /dev/null +++ b/tests/test_level_translations.py @@ -0,0 +1,128 @@ +"""Tests for ``infer_multiscale_translations`` (the CLI's +``--infer-multiscale-translations``). + +A pyramid downsampled centre-aligned (block averages) puts the centre of a +coarse voxel at level-0 index ``(f - 1) / 2``; a store that omits the +translation saying so draws each coarser level shifted toward the origin. The +flag fills the offsets in when the store declares none, and warns and keeps +the store's own when it does. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +import numpy as np +import pytest + +if TYPE_CHECKING: + from pathlib import Path + +_SCALE_Z = 2.0 +_SCALE_YX = 0.5 +_N_LEVELS = 3 + + +def _write_pyramid(path: Path, *, translations: bool) -> str: + """Write a small (z, y, x) OME-Zarr whose y/x halve per level. + + With *translations*, each level declares the centre-aligned offset; without, + it declares only a scale, like the ExpA dataset. + """ + import zarr + + root = zarr.open_group(str(path), mode="w") + data = np.arange(4 * 16 * 16, dtype=np.uint16).reshape(4, 16, 16) + datasets = [] + for level in range(_N_LEVELS): + factor = 2**level + level_data = data[:, ::factor, ::factor] + arr = root.create_array( + f"s{level}", shape=level_data.shape, chunks=(4, 8, 8), dtype=np.uint16 + ) + arr[:] = level_data + yx = _SCALE_YX * factor + transforms: list[dict] = [{"type": "scale", "scale": [_SCALE_Z, yx, yx]}] + if translations: + offset = (factor - 1) / 2 * _SCALE_YX + transforms.append( + {"type": "translation", "translation": [0.0, offset, offset]} + ) + datasets.append({"path": f"s{level}", "coordinateTransformations": transforms}) + 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, + "name": "pyramid", + } + ], + } + return f"file://{path.resolve()}" + + +# (f - 1) / 2 level-0 voxels on y and x; z is not downsampled. +_CENTRE_ALIGNED = [(0.0, 0.0, 0.0), (0.0, 0.5, 0.5), (0.0, 1.5, 1.5)] + + +def _extract(uri: str, **kwargs): + from oz_viewer.viewer._geometry import extract_viewer_geometry + + return extract_viewer_geometry(uri, print_summary=False, **kwargs) + + +def test_without_the_flag_the_store_is_untouched(tmp_path): + uri = _write_pyramid(tmp_path / "bare.zarr", translations=False) + store, _ = _extract(uri) + assert [tuple(t) for t in store.level_translations] == [(0.0, 0.0, 0.0)] * 3 + + +def test_flag_infers_centre_aligned_offsets(tmp_path): + uri = _write_pyramid(tmp_path / "bare.zarr", translations=False) + plain_store, plain = _extract(uri) + + with warnings.catch_warnings(): + warnings.simplefilter("error") + store, geometry = _extract(uri, infer_multiscale_translations=True) + + assert [tuple(t) for t in store.level_translations] == _CENTRE_ALIGNED + # cellier rebuilt the level transforms from them (level-0 voxel units). + assert store.level_transforms[2].translation[1] == pytest.approx(1.5) + assert store.level_transforms[2].translation[0] == pytest.approx(0.0) + + # Everything else carries over, and level 0 -- hence the image's place in + # the world -- does not move. + assert store.zarr_path == plain_store.zarr_path + assert store.level_scales == plain_store.level_scales + assert store.level_shapes == plain_store.level_shapes + np.testing.assert_array_equal(geometry.world_max_full, plain.world_max_full) + np.testing.assert_array_equal(geometry.level_0_scale, plain.level_0_scale) + + +def test_flag_matches_what_a_complete_file_declares(tmp_path): + """The inferred offsets equal what a file with the offsets states.""" + bare = _write_pyramid(tmp_path / "bare.zarr", translations=False) + full = _write_pyramid(tmp_path / "full.zarr", translations=True) + inferred, _ = _extract(bare, infer_multiscale_translations=True) + declared, _ = _extract(full) + assert [tuple(t) for t in inferred.level_translations] == [ + tuple(t) for t in declared.level_translations + ] + + +def test_flag_warns_and_keeps_declared_translations(tmp_path): + uri = _write_pyramid(tmp_path / "full.zarr", translations=True) + declared, _ = _extract(uri) + + with pytest.warns(UserWarning, match="already declares multiscale level"): + store, _ = _extract(uri, infer_multiscale_translations=True) + + assert [tuple(t) for t in store.level_translations] == [ + tuple(t) for t in declared.level_translations + ] diff --git a/tests/test_orthoviewer.py b/tests/test_orthoviewer.py index c72a8e1..4f72a94 100644 --- a/tests/test_orthoviewer.py +++ b/tests/test_orthoviewer.py @@ -1,8 +1,9 @@ """Headless build tests for the convenience-based orthoviewer. These exercise the full Qt build path (``_build_and_show_ortho_qt``) offscreen: -the four synced panels, the build-time single/multichannel choice, the -re-attached 3D overlays, and the Qt control docks. A live GPU render is not +the four synced panels, the image's starting mode and the runtime switch +between modes, the re-attached 3D overlays, and the control docks. A live GPU +render is not exercised (there is no display in CI), only construction. """ @@ -45,7 +46,7 @@ def _dock_control_names(window, title: str) -> set[str]: def test_single_channel_ortho_build(qapp, tmp_path): - """Blobs (z,y,x) -> single-channel ortho: 4 panels, overlays, Rendering dock.""" + """Blobs (z,y,x) -> single-mode ortho: 4 panels, overlays, both docks.""" from oz_viewer.data._blobs import make_example_zarr from oz_viewer.viewer._orthoviewer import _build_and_show_ortho_qt @@ -53,9 +54,14 @@ def test_single_channel_ortho_build(qapp, tmp_path): handle = _build_and_show_ortho_qt(f"file://{zarr_path}") try: build = handle.build - assert build.is_multichannel is False assert set(build.visuals) == {"xy", "xz", "yz", "vol"} assert set(build.viewer.scenes) == {"xy", "xz", "yz", "vol"} + assert not any(v.composite for v in build.visuals.values()) + + # One linked appearance; only the vol panel's LOD policy differs. + assert build.visuals["vol"].appearance.force_level is not None + assert build.visuals["vol"].appearance.frustum_cull is False + assert build.visuals["xy"].appearance.force_level is None # Overlays attached: plane + three orientation-axis meshes, ISO profile. overlays = build.overlays @@ -73,34 +79,40 @@ def test_single_channel_ortho_build(qapp, tmp_path): gizmo = controller.get_visual_model(overlays.axis_visual_ids[0]) assert gizmo.transform.translation[z_axis] == pytest.approx(12.3) - # Qt-only appearance panel on the left dock; no channel dock. - assert "Rendering" in _dock_titles(handle.window) + # The image control (both toolkits) plus the Qt-only overlay panel. + titles = _dock_titles(handle.window) + assert "Left" in titles + assert "Overlays" in titles + names = _dock_control_names(handle.window, "Left") + assert {"Contrast", "Colormap", "Render mode", "LOD bias"} <= names finally: handle.close() def test_multichannel_ortho_build(qapp, write_demo_ome): - """Demo image (c,z,y,x) -> multichannel ortho: channel stacked, both docks.""" + """Demo image (c,z,y,x) -> composite ortho, switchable to single at runtime.""" from oz_viewer.viewer._orthoviewer import _build_and_show_ortho_qt zarr_path = write_demo_ome("image") handle = _build_and_show_ortho_qt(f"file://{zarr_path}") try: build = handle.build - assert build.is_multichannel is True + viewer = build.viewer + controller = viewer.controller assert set(build.visuals) == {"xy", "xz", "yz", "vol"} + assert all(v.composite for v in build.visuals.values()) - # Channel axis (0) is stacked, not sliced, so no channel slider remains. - for scene in build.viewer.scenes.values(): - assert 0 not in scene.dims.selection.slice_indices + # A composited channel axis (0) has no slider on any panel. + for scene in viewer.scenes.values(): + assert 0 not in scene.slider_axes - # Multichannel volume is locked to MIP; overlays present. - assert build.overlays.transparency_manager.current_mode == "mip" + # Composite channels render MIP, so the overlays use the MIP profile. + manager = build.overlays.transparency_manager + assert manager.current_mode == "mip" # The overlay meshes are 3-D (z, y, x) and broadcast over the channel # axis, so they exist at every channel without per-channel updates. - controller = build.viewer.controller - world = build.viewer.scenes["vol"].dims.world_coordinate_system + world = viewer.scenes["vol"].dims.world_coordinate_system overlay_ids = (build.overlays.plane_visual.id, *build.overlays.axis_visual_ids) for visual_id in overlay_ids: transform = controller.get_visual_model(visual_id).transform @@ -111,18 +123,43 @@ def test_multichannel_ortho_build(qapp, write_demo_ome): axis_values = handle.geometry.axis_values assert axis_values[0] == DiscreteAxisValues( - values=tuple(float(i) for i in range(handle.geometry.n_channels)) + values=tuple(float(i) for i in range(handle.geometry.n_channels)), + labels=handle.geometry.channel_labels, ) assert all(isinstance(axis_values[a], ContinuousAxisValues) for a in (1, 2, 3)) - # ChannelControls dock ("Left") plus the Qt-only volume group ("Volume"). + # The image control on the left drives all four panels; the overlay + # panel on the right. titles = _dock_titles(handle.window) assert "Left" in titles - assert "Volume" in titles - - # The channel dock drives all four panels' sibling visuals, so it must - # actually hold one group per channel -- not just exist. + assert "Overlays" in titles names = _dock_control_names(handle.window, "Left") assert {f"Channel {i}" for i in range(handle.geometry.n_channels)} <= names + assert "Composite channels" in names + + # Runtime switch to single mode: every panel follows, the channel axis + # gets a slider, and the manager picks up the single ISO render mode + # and applies its volume profile. + vol_id = build.vol_visual_id + viewer.set_image_composite(build.visuals, False) + assert not any(v.composite for v in build.visuals.values()) + for scene in viewer.scenes.values(): + assert 0 in scene.slider_axes + assert manager.current_mode == "iso" + vol = controller.get_visual_model(vol_id) + assert vol.single.opacity == pytest.approx(manager.current_profile.opacity) + assert vol.appearance.transparency_mode == "weighted_blend" + + # An opacity edit through the image group is remembered per mode. + viewer.update_image_single_field(build.visuals, "opacity", 0.6) + assert manager.current_profile.opacity == pytest.approx(0.6) + + # Back to composite: blending returns to cellier's default, and the + # remembered ISO profile is re-applied on the next switch to single. + viewer.set_image_composite(build.visuals, True) + assert manager.current_mode == "mip" + assert vol.appearance.transparency_mode is None + viewer.set_image_composite(build.visuals, False) + assert vol.single.opacity == pytest.approx(0.6) finally: handle.close() diff --git a/tests/test_viewer.py b/tests/test_viewer.py new file mode 100644 index 0000000..7966036 --- /dev/null +++ b/tests/test_viewer.py @@ -0,0 +1,161 @@ +"""Headless build tests for the convenience-based single-panel viewer. + +These exercise the full Qt build path (``_build_and_show_viewer_qt``) offscreen: +the image's starting mode (composite when the store has a channel axis), the +runtime switch between modes, and the resulting control dock. A +live GPU render is not exercised (there is no display in CI), only construction. +""" + +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest + + +@pytest.fixture(scope="module") +def qapp(): + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + return app + + +def _dock_titles(window) -> list[str]: + from PySide6.QtWidgets import QDockWidget + + return [d.windowTitle() for d in window.findChildren(QDockWidget)] + + +def _dock_control_names(window, title: str) -> set[str]: + """Every group-box title and label inside the dock called *title*. + + A dock title alone only proves cellier added a dock; these are the names of + the controls actually inside it. ``build_viewer`` used to hand cellier a + plain dict for ``controls=``, which produced an empty spec list and no dock + at all -- and would have produced an empty dock had cellier docked it -- so + the docks are checked by content, not just by existence. + """ + from PySide6.QtWidgets import QCheckBox, QDockWidget, QGroupBox, QLabel + + dock = next(d for d in window.findChildren(QDockWidget) if d.windowTitle() == title) + names = {g.title() for g in dock.findChildren(QGroupBox)} + names |= {w.text() for w in dock.findChildren(QLabel)} + names |= {w.text() for w in dock.findChildren(QCheckBox)} + return {n for n in names if n} + + +def test_single_channel_viewer_build(qapp, tmp_path): + """Blobs (z,y,x) -> single-channel viewer with the appearance dock.""" + from oz_viewer.data._blobs import make_example_zarr + from oz_viewer.viewer._viewer import _build_and_show_viewer_qt + + zarr_path = make_example_zarr(output_path=tmp_path / "blobs.ome.zarr") + handle = _build_and_show_viewer_qt(f"file://{zarr_path}") + try: + assert handle.geometry.channel_axis is None + # The 2D/3D toggle lives in the canvas view's dims control, not a dock, + # so the appearance panel is the only dock. + assert _dock_titles(handle.window) == ["Left"] + + # The image fields resolved to controls, plus the bounding box cellier + # adds to any configured appearance panel. + names = _dock_control_names(handle.window, "Left") + assert { + "Contrast", + "Colormap", + "Render mode", + "LOD bias", + "Bounding box", + } <= names + finally: + handle.window.close() + + +def test_multichannel_viewer_build(qapp, write_demo_ome): + """Demo image (c,z,y,x) -> composite image, switchable to single at runtime.""" + from oz_viewer.viewer._viewer import _build_and_show_viewer_qt + + zarr_path = write_demo_ome("image") + handle = _build_and_show_viewer_qt(f"file://{zarr_path}") + try: + assert handle.geometry.channel_axis == 0 + scene = handle.viewer.scene + (visual,) = scene.visuals + assert visual.composite is True + assert set(visual.channels) == set(range(handle.geometry.n_channels)) + + # A composited channel axis has no slider; every axis keeps a position. + assert 0 not in scene.slider_axes + assert 0 in scene.dims.selection.slice_indices + + # One image control holding the composite page and the mode switch. + assert _dock_titles(handle.window) == ["Left"] + names = _dock_control_names(handle.window, "Left") + n_channels = handle.geometry.n_channels + assert {f"Channel {i}" for i in range(n_channels)} <= names + assert "Composite channels" in names + + # The single/composite choice is no longer build-time only: single + # mode gives the channel axis a slider back. + handle.viewer.controller.set_image_composite(visual.id, False) + assert visual.composite is False + assert 0 in scene.slider_axes + finally: + handle.window.close() + + +def test_composite_holds_at_most_cellier_default_channels(tmp_path, write_demo_ome): + """A store with more channels than cellier's cap composites the first ones.""" + from cellier.visuals import MultiscaleImageVisual + + from oz_viewer.viewer._geometry import extract_viewer_geometry + from oz_viewer.viewer._image import image_visual_kwargs + + _, geometry = extract_viewer_geometry( + f"file://{write_demo_ome('image')}", print_summary=False + ) + cap = MultiscaleImageVisual.model_fields["max_channels"].default + kwargs = image_visual_kwargs( + geometry._replace(n_channels=cap + 2, channel_labels=None), + render_mode="mip", + lod_bias=1.0, + ) + assert kwargs["composite"] is True + assert sorted(kwargs["channels"]) == list(range(cap)) + assert "max_channels" not in kwargs + + +@pytest.mark.parametrize("fixture_name", ["blobs", "demo"]) +def test_controls_configs_are_config_objects( + qapp, tmp_path, write_demo_ome, fixture_name +): + """``controls=`` must be a config dataclass, never a plain dict. + + cellier stores whatever ``add_*`` was handed and dispatches on its type, so + a dict is accepted, recorded, and then silently ignored at render time -- + no dock, no error, no warning. This is the cheap, Qt-window-free guard for + that: it fails on the config object, not on a missing widget. + """ + from cellier.convenience import BaseControlsConfig + + from oz_viewer.viewer._geometry import extract_viewer_geometry + from oz_viewer.viewer._viewer import build_viewer + + if fixture_name == "blobs": + from oz_viewer.data._blobs import make_example_zarr + + zarr_path = make_example_zarr(output_path=tmp_path / "blobs.ome.zarr") + else: + zarr_path = write_demo_ome("image") + + data_store, geometry = extract_viewer_geometry(f"file://{zarr_path}") + viewer = build_viewer(data_store, geometry, gui="qt") + + configs = list(viewer._controls_configs.values()) + assert configs, "no controls were configured for the visual" + assert all(isinstance(c, BaseControlsConfig) for c in configs) + # One image control config covers single and composite modes alike. + assert [type(c).__name__ for c in configs] == ["MultiscaleImageControlsConfig"] From 67c47d5455f9e149c158af6c100faf2c29198b53 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Thu, 24 Sep 2026 16:52:24 +0200 Subject: [PATCH 5/8] bump cellier --- pyproject.toml | 14 +-- src/oz_viewer/viewer/_geometry.py | 31 ++++++- src/oz_viewer/viewer/_image.py | 1 + src/oz_viewer/viewer/_ortho_controls.py | 11 +-- src/oz_viewer/viewer/_ortho_overlays.py | 51 ++++++++--- src/oz_viewer/viewer/_orthoviewer.py | 32 ++++--- tests/conftest.py | 83 ++++++++++++++++-- tests/test_decimals.py | 76 ++++++++++++++++ tests/test_orthoviewer.py | 110 +++++++++++++++++++++++- 9 files changed, 354 insertions(+), 55 deletions(-) create mode 100644 tests/test_decimals.py diff --git a/pyproject.toml b/pyproject.toml index aabab7a..610a0c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ name = "oz-viewer" dynamic = ["version"] description = "A viewer for ome-zarr images." readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" license = { text = "BSD-3-Clause" } authors = [{ name = "Kevin Yamauchi", email = "kevin.yamauchi@gmail.com" }] # https://pypi.org/classifiers/ @@ -27,7 +27,6 @@ classifiers = [ "Development Status :: 3 - Alpha", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Typing :: Typed", @@ -38,7 +37,7 @@ dependencies = [ "typer >= 0.12", "rich >= 13", "numpy >= 1.24", - "cellier[pyside,anywidget]>=0.0.26", + "cellier[pyside,anywidget]>=0.0.27", "aiohttp >= 3.9", "zarr >= 3.0", "s3fs", @@ -49,15 +48,6 @@ dependencies = [ # https://peps.python.org/pep-0621/#dependencies-optional-dependencies # add dependencies for "extra" features here. Not dev dependencies. [project.optional-dependencies] -# Dependencies for the notebook examples under examples/. Install with -# `pip install -e '.[examples]'`. cellier[anywidget] adds the anywidget -# front-end (notebook rendering); scikit-image + pooch provide the cells3d -# sample dataset used by examples/viewer.ipynb. -# -# NOTE: the anywidget canvas backend currently requires rendercanvas from git -# main, which cannot be expressed as a version pin here -- install it -# separately: -# uv pip install "rendercanvas @ git+https://github.com/pygfx/rendercanvas.git" examples = [ "jupyterlab>=4.5.6", "scikit-image>=0.24", diff --git a/src/oz_viewer/viewer/_geometry.py b/src/oz_viewer/viewer/_geometry.py index 40c49c8..745e7ee 100644 --- a/src/oz_viewer/viewer/_geometry.py +++ b/src/oz_viewer/viewer/_geometry.py @@ -49,6 +49,8 @@ class _ViewerGeometry(NamedTuple): world_max_spatial: np.ndarray initial_clim_max: float clim_range: tuple[float, float] + #: Decimal places for values in data units -- the contrast limits and the + #: iso threshold: 0 for integer data, 2 for float. slider_decimals: int #: ``(near, far)`` clip distances derived from the world extents. depth_range: tuple[float, float] @@ -78,7 +80,8 @@ def axis_values(self) -> dict[int, ContinuousAxisValues | DiscreteAxisValues]: is in single mode; a composite draws every channel at once. Every other axis (spatial, time) is continuous over ``[0, world_max]``, from the first voxel centre to the - last. Derived from the metadata rather than via + last, and shows just enough decimals to resolve half a voxel (see + :func:`_axis_decimals`). Derived from the metadata rather than via ``cellier.convenience.axis_values_from_viewer``, which widens each axis by half a voxel at both ends. """ @@ -94,7 +97,9 @@ def axis_values(self) -> dict[int, ContinuousAxisValues | DiscreteAxisValues]: ) else: values[axis] = ContinuousAxisValues( - min=0.0, max=float(self.world_max_full[axis]) + min=0.0, + max=float(self.world_max_full[axis]), + decimals=_axis_decimals(float(self.level_0_scale[axis])), ) return values @@ -111,6 +116,28 @@ def center_slice_indices(self) -> dict[int, float]: } +#: The most decimals a dims slider readout is given, whatever the voxel size. +_MAX_AXIS_DECIMALS = 6 + + +def _axis_decimals(scale: float) -> int: + """The fewest decimals that resolve half a voxel of *scale* world units. + + The smallest ``d >= 0`` with ``10**-d <= scale / 2``: a 5 um voxel reads + in whole micrometres, a 0.26 um voxel to one decimal. A scale that is not + a positive finite number gets 2, the dims slider's usual precision. + """ + if not np.isfinite(scale) or scale <= 0.0: + return 2 + half = scale / 2.0 + decimals = 0 + # A small tolerance so an exact power of ten (a 0.2 voxel) is not pushed + # one decimal further by floating-point error. + while 10.0**-decimals > half * (1.0 + 1e-9) and decimals < _MAX_AXIS_DECIMALS: + decimals += 1 + return decimals + + def extract_viewer_geometry( zarr_uri: str, *, diff --git a/src/oz_viewer/viewer/_image.py b/src/oz_viewer/viewer/_image.py index f0e6477..e80ebd2 100644 --- a/src/oz_viewer/viewer/_image.py +++ b/src/oz_viewer/viewer/_image.py @@ -116,6 +116,7 @@ def image_visual_kwargs( appearance=_IMAGE_FIELDS, colormap_names=_DEFAULT_COLORMAPS, clim_range=geometry.clim_range, + decimals=geometry.slider_decimals, channel_labels=dict(enumerate(labels)) if labels is not None else None, ), } diff --git a/src/oz_viewer/viewer/_ortho_controls.py b/src/oz_viewer/viewer/_ortho_controls.py index 87ce922..aadead7 100644 --- a/src/oz_viewer/viewer/_ortho_controls.py +++ b/src/oz_viewer/viewer/_ortho_controls.py @@ -1,7 +1,8 @@ """Qt-only overlay control panel for the orthoviewer. The image itself is controlled by cellier's cross-toolkit -``AppearanceControls`` dock, which drives all four panels' visuals together. +``AppearanceControls`` dock (one control for the 2D views, one for the 3D +view). The orthoviewer's 3D overlays (the slice planes and the orientation gizmo) are plain meshes oz-viewer adds to the ``vol`` scene only, and the convenience docks have no public way to configure controls for them, so their panel is @@ -46,14 +47,14 @@ def _plane_opacity_group(controller, overlays: OrthoOverlays, initial_opacity: f plane_visual = overlays.plane_visual plane_store = overlays.plane_store - axis_visual_ids = overlays.axis_visual_ids + manager = overlays.transparency_manager def _on_changed(value: float) -> None: - controller.update_appearance_field(plane_visual.id, "opacity", value) + # The manager owns the meshes' opacity so it survives render-mode + # switches; the plane's face colours carry it too. + manager.overlay_opacity = value plane_store.colors = _make_plane_colors(value) controller.reslice_visual(plane_visual.id) - for vid in axis_visual_ids: - controller.update_appearance_field(vid, "opacity", value) slider.valueChanged.connect(_on_changed) layout.addWidget(slider) diff --git a/src/oz_viewer/viewer/_ortho_overlays.py b/src/oz_viewer/viewer/_ortho_overlays.py index da786eb..87e2118 100644 --- a/src/oz_viewer/viewer/_ortho_overlays.py +++ b/src/oz_viewer/viewer/_ortho_overlays.py @@ -34,7 +34,9 @@ _AXIS_3D_CUBE_COLOUR: tuple[float, float, float, float] = (0.75, 0.75, 0.75, 1.0) _N_FACES_PER_BOX: int = 12 -_INITIAL_PLANE_OPACITY: float = 1.0 +# The "Slice overlay opacity" the orthoviewer starts with: the slice plane and +# the orientation-axis meshes, which the overlay panel's slider drives together. +_INITIAL_PLANE_OPACITY: float = 0.8 # --------------------------------------------------------------------------- @@ -54,11 +56,17 @@ class _VolTransparencyProfile: @dataclass class _VisualRenderProfile: + """How an overlay mesh blends in one render mode. + + The mesh's opacity is the overlay opacity (the "Slice overlay opacity" + slider); a profile only caps it at ``max_opacity``. + """ + render_order: int depth_test: bool depth_write: bool transparency_mode: str - opacity: float + max_opacity: float = 1.0 _ISO_PLANE_PROFILE = _VisualRenderProfile( @@ -66,28 +74,26 @@ class _VisualRenderProfile: depth_test=True, depth_write=True, transparency_mode="blend", - opacity=1.0, ) _MIP_PLANE_PROFILE = _VisualRenderProfile( render_order=1, depth_test=False, depth_write=True, transparency_mode="weighted_blend", - opacity=0.99, + # Kept just below opaque, as this profile always set it. + max_opacity=0.99, ) _ISO_AXES_PROFILE = _VisualRenderProfile( render_order=1, depth_test=True, depth_write=True, transparency_mode="blend", - opacity=1.0, ) _MIP_AXES_PROFILE = _VisualRenderProfile( render_order=2, depth_test=False, depth_write=False, transparency_mode="blend", - opacity=1.0, ) @@ -111,11 +117,15 @@ class _VolTransparencyManager: changes, whatever the origin (Qt, anywidget, programmatic). In single mode the volume's opacity and blending follow a per-render-mode - profile, and edits made through the image control are remembered in the - current profile. A composite hands blending back to cellier (its default + profile, and edits made through the 3D view's image control are + remembered in the current profile (the 2D views' control does not write + the ``vol`` image). A composite hands blending back to cellier (its default follows the mode) and keeps the channels' own opacities; only the meshes follow the render mode there. + The meshes' opacity is :attr:`overlay_opacity`, kept across render-mode + switches; each mode's profile only caps it. + Subscribes through ``controller.connect_widget`` (the widget contract: ``_id``, ``changed``, ``closed``), so the bus rewires it if the image's ``single`` or ``channels`` models are replaced; :meth:`close` unsubscribes. @@ -135,8 +145,10 @@ def __init__( *, plane_visual_id=None, axis_visual_ids: list | None = None, + overlay_opacity: float = _INITIAL_PLANE_OPACITY, ) -> None: self._id = uuid4() + self._overlay_opacity = float(overlay_opacity) self._controller = controller self._vol_visual_id = vol_visual_id self._plane_visual_id = plane_visual_id @@ -166,6 +178,20 @@ def __init__( def current_mode(self) -> str: return self._current_mode + @property + def overlay_opacity(self) -> float: + """The slice plane's and orientation meshes' opacity. + + Setting it re-applies the meshes' profiles, so it holds across + render-mode switches. + """ + return self._overlay_opacity + + @overlay_opacity.setter + def overlay_opacity(self, value: float) -> None: + self._overlay_opacity = float(value) + self._apply_mesh_profiles() + @property def current_profile(self) -> _VolTransparencyProfile: return self._vol_profiles[self._current_mode] @@ -247,7 +273,9 @@ def _apply_profile_to_mesh(self, visual_id, profile: _VisualRenderProfile) -> No c.update_appearance_field( visual_id, "transparency_mode", profile.transparency_mode ) - c.update_appearance_field(visual_id, "opacity", profile.opacity) + c.update_appearance_field( + visual_id, "opacity", min(self._overlay_opacity, profile.max_opacity) + ) def _apply_vol_profile(self) -> None: """Apply the current volume profile, or hand blending back in composite. @@ -277,6 +305,9 @@ def _apply_vol_profile(self) -> None: def apply(self) -> None: self._apply_vol_profile() + self._apply_mesh_profiles() + + def _apply_mesh_profiles(self) -> None: plane_profile = self._plane_profiles[self._current_mode] self._apply_profile_to_mesh(self._plane_visual_id, plane_profile) axes_profile = self._axes_profiles[self._current_mode] @@ -527,7 +558,7 @@ def _make_axis_meshes( appearance = MeshFlatAppearance( color_mode="face", side="both", - opacity=1.0, + opacity=_INITIAL_PLANE_OPACITY, render_order=1, depth_test=True, depth_write=True, diff --git a/src/oz_viewer/viewer/_orthoviewer.py b/src/oz_viewer/viewer/_orthoviewer.py index 2581ab2..6421c0f 100644 --- a/src/oz_viewer/viewer/_orthoviewer.py +++ b/src/oz_viewer/viewer/_orthoviewer.py @@ -3,11 +3,12 @@ Rebuilt on :class:`cellier.convenience.OrthoViewer`, so the same builder runs under both ``gui="qt"`` (desktop / CLI) and ``gui="anywidget"`` (Jupyter / marimo). The four pre-wired panels, cross-panel axis sync, the image fanned -out to every panel, and the image control (``AppearanceControls``, driving all -four panels together, with the single/composite switch) come from the -convenience API; the OME-Zarr-specific geometry (see -:mod:`oz_viewer.viewer._geometry`) and the 3D overlays (see -:mod:`oz_viewer.viewer._ortho_overlays`) are re-attached on top. +out to every panel, and the image controls (``AppearanceControls``: a +selector between one control for the three 2D views and one for the 3D view, +each with its own single/composite switch) come from the convenience API; +the OME-Zarr-specific geometry (see :mod:`oz_viewer.viewer._geometry`) and +the 3D overlays (see :mod:`oz_viewer.viewer._ortho_overlays`) are +re-attached on top. The overlay controls (orientation gizmo toggle, slice-plane opacity) have no convenience equivalent, so they are a Qt-only dock (see @@ -68,8 +69,9 @@ def build_ortho_viewer( """Build a :class:`cellier.convenience.OrthoViewer` for an OME-Zarr store. Fans one multiscale image out to every panel. When *geometry* found a - channel axis the image starts in composite mode; the image control - switches every panel to single mode (and back) at runtime. + channel axis the image starts in composite mode; the image controls + switch the 2D views and the 3D view to single mode (and back) at runtime, + each on its own. Parameters ---------- @@ -115,11 +117,12 @@ def _add_image_visuals( ) -> _OrthoBuild: """Fan one multiscale image out to every panel. - The image control drives the four panels as one group, so they share an - appearance. Only the ``vol`` panel's level-of-detail policy differs: it - is pinned to the coarsest level and not frustum-culled. Those shared - appearance fields are not in the control, so the group never overwrites - them. + They start with one appearance. cellier then gives the three 2D panels + one image control and the ``vol`` panel another, so the 2D views and the + 3D view can differ from there on. The ``vol`` panel's level-of-detail + policy differs from the start: it is pinned to the coarsest level and not + frustum-culled. Those appearance fields are not in the control, so it + never overwrites them. """ controller = viewer.controller visuals = viewer.add_image_multiscale( @@ -198,7 +201,8 @@ def build_ortho_layout( # Screen-space 2D orientation axes on each slice panel (both toolkits). _add_2d_axis_overlays(viewer) - # The image control drives all four panels' sibling visuals together. + # The image controls: a selector between the 2D views (xy, xz, yz driven + # together) and the 3D view. layout = Layout(center=grid, left_dock=AppearanceControls()) return layout, grid @@ -418,7 +422,7 @@ def _build_and_show_ortho_qt( def _attach_qt_controls(build: _OrthoBuild, window) -> object | None: """Bolt oz's Qt-only overlay panel onto the rendered ``QMainWindow``. - The image control is already in the layout's left ``AppearanceControls`` + The image controls are already in the layout's left ``AppearanceControls`` dock; this adds the overlay toggles on the right. """ from PySide6 import QtWidgets diff --git a/tests/conftest.py b/tests/conftest.py index f628a53..62d7ec7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools +import gc import sys import threading import time @@ -24,9 +25,11 @@ def _close_cellier_controllers(monkeypatch): A controller owns render canvases and GPU resources that the GUI backend holds, not Python refcounting. A test that only closes its window leaves them to the garbage collector, which may run their teardown at any later - moment on any thread -- including inside zarr's I/O thread while a later - test writes an example store, which then deadlocks. Mirrors cellier's own - test teardown. + moment -- including while a later test writes an example store with + zarr's I/O threads (and, through ome-zarr-py, a dask thread pool) busy, + which has deadlocked and segfaulted. Mirrors cellier's own test + teardown, and then collects here so that nothing the test left behind is + finalized at such a moment. """ from cellier.controller import CellierController @@ -53,28 +56,90 @@ def _tracking_init(self, *args, **kwargs): # Qt deletes a closed widget only when the event loop next runs. widgets_module = sys.modules.get("PySide6.QtWidgets") + app = None if widgets_module is not None: app = widgets_module.QApplication.instance() if app is not None: app.processEvents() + # Finalize the test's leftovers here, at a quiet point, rather than + # whenever the collector next runs -- which can be while the next test + # writes its example store with zarr's I/O threads busy. + gc.collect() + if app is not None: + app.processEvents() + + +def _write_demo_image(path: Path) -> None: + """Write a small demo OME-Zarr 0.5 image with plain zarr. + + ``c, z, y, x`` = ``(1, 1, 64, 64)`` uint16 random data, two levels with + y and x halved, chunks ``(1, 1, 32, 32)`` -- the layout yaozarrs' demo + writer produces. Written directly rather than through that writer, which + builds the pyramid with ome-zarr-py and so runs a dask thread pool inside + the test process (see ``_close_cellier_controllers``). + """ + import numpy as np + import zarr + + rng = np.random.default_rng(42) + level_0 = rng.integers(0, 1000, size=(1, 1, 64, 64), dtype=np.uint16) + root = zarr.open_group(str(path), mode="w", zarr_format=3) + datasets = [] + for level in range(2): + factor = 2**level + data = level_0[..., ::factor, ::factor] + array = root.create_array( + f"s{level}", + shape=data.shape, + chunks=(1, 1, 32, 32), + dtype=data.dtype, + dimension_names=("c", "z", "y", "x"), + ) + array[:] = data + datasets.append( + { + "path": f"s{level}", + "coordinateTransformations": [ + {"type": "scale", "scale": [1.0, 1.0, factor, factor]} + ], + } + ) + root.attrs["ome"] = { + "version": "0.5", + "multiscales": [ + { + "name": "demo", + "axes": [ + {"name": "c", "type": "channel"}, + {"name": "z", "type": "space"}, + {"name": "y", "type": "space"}, + {"name": "x", "type": "space"}, + ], + "datasets": datasets, + } + ], + } + @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. + The image is written with plain zarr (:func:`_write_demo_image`). The + plate still comes from yaozarrs' demo writer (ome-zarr-py, and so dask); + the test is skipped if that is 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) + _write_demo_image(path) elif store_type == "plate": + try: + from yaozarrs._demo_data import write_ome_plate + except ImportError: + pytest.skip("ome-zarr is required for the demo plate fixture") path = tmp_path / "demo_plate.zarr" write_ome_plate(path) else: diff --git a/tests/test_decimals.py b/tests/test_decimals.py new file mode 100644 index 0000000..e083a80 --- /dev/null +++ b/tests/test_decimals.py @@ -0,0 +1,76 @@ +"""Tests for the decimals oz-viewer hands cellier's widgets. + +Cellier shows a fixed 2 decimals unless told otherwise; oz-viewer passes 0 +for integer images' contrast and threshold, and per dims axis just enough to +resolve half a voxel. +""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.parametrize( + ("scale", "expected"), + [ + (5.0, 0), # ExpA z + (6.55, 0), # ExpA y/x + (2.0, 0), + (1.0, 1), # half a voxel is 0.5 + (0.29, 1), # cells3d z + (0.26, 1), # cells3d y/x + (0.2, 1), # an exact power of ten is not pushed further + (0.1, 2), + (0.0, 2), # not a usable scale: the dims slider's usual 2 + (float("nan"), 2), + ], +) +def test_axis_decimals_resolve_half_a_voxel(scale, expected): + from oz_viewer.viewer._geometry import _axis_decimals + + assert _axis_decimals(scale) == expected + + +def test_an_integer_image_gets_integer_contrast_and_axis_readouts(tmp_path): + """The uint8 blobs example: 0 decimals for the controls and every axis.""" + from cellier.convenience import ContinuousAxisValues + + from oz_viewer.data._blobs import make_example_zarr + from oz_viewer.viewer._geometry import extract_viewer_geometry + from oz_viewer.viewer._image import image_visual_kwargs + + zarr_path = make_example_zarr(output_path=tmp_path / "blobs.ome.zarr") + _, geometry = extract_viewer_geometry(f"file://{zarr_path}", print_summary=False) + + controls = image_visual_kwargs(geometry, render_mode="mip", lod_bias=1.0)[ + "controls" + ] + assert controls.decimals == 0 + + axis_values = geometry.axis_values + assert all(isinstance(v, ContinuousAxisValues) for v in axis_values.values()) + # z is 5 um and y/x 6.55 um per voxel: whole micrometres suffice. + assert [axis_values[a].decimals for a in sorted(axis_values)] == [0, 0, 0] + + +def test_the_channel_axis_keeps_its_discrete_readout(write_demo_ome): + """Only continuous axes get decimals; the channel axis shows its values.""" + from cellier.convenience import ContinuousAxisValues, DiscreteAxisValues + + from oz_viewer.viewer._geometry import extract_viewer_geometry + + _, geometry = extract_viewer_geometry( + f"file://{write_demo_ome('image')}", print_summary=False + ) + axis_values = geometry.axis_values + assert isinstance(axis_values[geometry.channel_axis], DiscreteAxisValues) + for axis in geometry.spatial_indices: + spec = axis_values[axis] + assert isinstance(spec, ContinuousAxisValues) + assert spec.decimals == _expected(geometry.level_0_scale[axis]) + + +def _expected(scale: float) -> int: + from oz_viewer.viewer._geometry import _axis_decimals + + return _axis_decimals(float(scale)) diff --git a/tests/test_orthoviewer.py b/tests/test_orthoviewer.py index 4f72a94..b126d69 100644 --- a/tests/test_orthoviewer.py +++ b/tests/test_orthoviewer.py @@ -45,6 +45,17 @@ def _dock_control_names(window, title: str) -> set[str]: return {n for n in names if n} +def _dock_selector_items(window, title: str) -> list[list[str]]: + """The items of every combo box inside the dock called *title*.""" + from PySide6.QtWidgets import QComboBox, QDockWidget + + dock = next(d for d in window.findChildren(QDockWidget) if d.windowTitle() == title) + return [ + [combo.itemText(i) for i in range(combo.count())] + for combo in dock.findChildren(QComboBox) + ] + + def test_single_channel_ortho_build(qapp, tmp_path): """Blobs (z,y,x) -> single-mode ortho: 4 panels, overlays, both docks.""" from oz_viewer.data._blobs import make_example_zarr @@ -58,7 +69,7 @@ def test_single_channel_ortho_build(qapp, tmp_path): assert set(build.viewer.scenes) == {"xy", "xz", "yz", "vol"} assert not any(v.composite for v in build.visuals.values()) - # One linked appearance; only the vol panel's LOD policy differs. + # One starting appearance; only the vol panel's LOD policy differs. assert build.visuals["vol"].appearance.force_level is not None assert build.visuals["vol"].appearance.frustum_cull is False assert build.visuals["xy"].appearance.force_level is None @@ -85,6 +96,18 @@ def test_single_channel_ortho_build(qapp, tmp_path): assert "Overlays" in titles names = _dock_control_names(handle.window, "Left") assert {"Contrast", "Colormap", "Render mode", "LOD bias"} <= names + assert "Data fetch status" in names + + # One control for the three 2D views and one for the 3D view, chosen + # with the dock's selector. + from cellier.convenience.layout._shared import appearance_targets + + assert ["image (2D views)", "image (3D view)"] in _dock_selector_items( + handle.window, "Left" + ) + views_2d, view_3d = appearance_targets(build.viewer) + assert views_2d.visual_ids == [build.visuals[k].id for k in ("xy", "xz", "yz")] + assert view_3d.visual_ids == [build.vol_visual_id] finally: handle.close() @@ -128,8 +151,8 @@ def test_multichannel_ortho_build(qapp, write_demo_ome): ) assert all(isinstance(axis_values[a], ContinuousAxisValues) for a in (1, 2, 3)) - # The image control on the left drives all four panels; the overlay - # panel on the right. + # The image controls on the left (the 2D views' control is shown + # first); the overlay panel on the right. titles = _dock_titles(handle.window) assert "Left" in titles assert "Overlays" in titles @@ -163,3 +186,84 @@ def test_multichannel_ortho_build(qapp, write_demo_ome): assert vol.single.opacity == pytest.approx(0.6) finally: handle.close() + + +def test_the_2d_and_3d_views_are_edited_separately(qapp, tmp_path): + """A contrast edit in the dock's 2D views control leaves the volume alone.""" + from PySide6.QtWidgets import QDockWidget + from superqt import QLabeledDoubleRangeSlider + + from oz_viewer.data._blobs import make_example_zarr + from oz_viewer.viewer._orthoviewer import _build_and_show_ortho_qt + + zarr_path = make_example_zarr(output_path=tmp_path / "blobs.ome.zarr") + handle = _build_and_show_ortho_qt(f"file://{zarr_path}") + try: + build = handle.build + vol_clim = tuple(build.visuals["vol"].single.clim) + dock = next( + d + for d in handle.window.findChildren(QDockWidget) + if d.windowTitle() == "Left" + ) + # The selector starts on the 2D views; the 3D view's control is built + # but not shown. + (contrast,) = [ + s + for s in dock.findChildren(QLabeledDoubleRangeSlider) + if s.isVisibleTo(dock) + ] + + contrast.setValue((10.0, 20.0)) + + for key in ("xy", "xz", "yz"): + assert tuple(build.visuals[key].single.clim) == pytest.approx((10.0, 20.0)) + assert tuple(build.visuals["vol"].single.clim) == vol_clim + finally: + handle.close() + + +def test_the_slice_overlays_start_at_0_8_and_keep_the_slider_value(qapp, tmp_path): + """The overlay opacity holds across render-mode switches. + + The transparency manager used to write a fixed opacity per render mode + onto the slice plane and orientation meshes, overriding the slider. + """ + from PySide6.QtWidgets import QDockWidget + from superqt import QLabeledDoubleSlider + + from oz_viewer.data._blobs import make_example_zarr + from oz_viewer.viewer._orthoviewer import _build_and_show_ortho_qt + + zarr_path = make_example_zarr(output_path=tmp_path / "blobs.ome.zarr") + handle = _build_and_show_ortho_qt(f"file://{zarr_path}") + try: + build = handle.build + controller = build.viewer.controller + overlays = build.overlays + mesh_ids = [overlays.plane_visual.id, *overlays.axis_visual_ids] + + def opacities(): + return [controller.get_visual_model(v).appearance.opacity for v in mesh_ids] + + assert opacities() == pytest.approx([0.8] * 4) + assert overlays.plane_store.colors[:, 3] == pytest.approx([0.8] * 6) + dock = next( + d + for d in handle.window.findChildren(QDockWidget) + if d.windowTitle() == "Overlays" + ) + (slider,) = dock.findChildren(QLabeledDoubleSlider) + assert slider.value() == pytest.approx(0.8) + + # Switching render mode keeps it (MIP caps the plane at 0.99). + vol = build.vol_visual_id + controller.update_single_appearance_field(vol, "render_mode", "mip") + assert overlays.transparency_manager.current_mode == "mip" + assert opacities() == pytest.approx([0.8] * 4) + + slider.setValue(0.5) + controller.update_single_appearance_field(vol, "render_mode", "iso") + assert opacities() == pytest.approx([0.5] * 4) + finally: + handle.close() From bd75b1ce5e2429587aaf486101b7c8d0489acf34 Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Thu, 24 Sep 2026 16:55:35 +0200 Subject: [PATCH 6/8] update readme --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 6e9d0ae..3c91479 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ You can load a v0.4 or v0.5 OME-Zarr file into a single-canvas 2d/3d viewer usin oz-viewer view path/to/image.ome.zarr ``` +Note that some OME-Zarr files do not provide the necessary translation in the multiscale transforms to keep the downscaled voxels centered. `oz-viewer` provides a flag to infer the translations based on the shapes and downscale factors of the multiscale levels. Use the `--infer-multiscale-translations` flag to enable this behavior. + +```sh +oz-viewer view path/to/image.ome.zarr --infer-multiscale-translations` +``` + Example viewing a https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpA_VIP_ASLM_on.zarr (1937, 2048, 2048), anisotropic voxels (file was on local SSD). https://github.com/user-attachments/assets/59c31e89-db42-4cec-ae4f-373d997c227f @@ -45,6 +51,13 @@ You can load a v0.4 or v0.5 OME-Zarr file into an orthoviewer using the `oz-view oz-viewer ortho path/to/image.ome.zarr ``` +Note that some OME-Zarr files do not provide the necessary translation in the multiscale transforms to keep the downscaled voxels centered. `oz-viewer` provides a flag to infer the translations based on the shapes and downscale factors of the multiscale levels. Use the `--infer-multiscale-translations` flag to enable this behavior. + +```sh +oz-viewer ortho path/to/image.ome.zarr --infer-multiscale-translations` +``` + + Example viewing https://livingobjects.ebi.ac.uk/idr/zarr/v0.5/idr0066/ExpA_VIP_ASLM_on.zarr (1937, 2048, 2048), anisotropic voxels (file was on local SSD). https://github.com/user-attachments/assets/a6c0cab9-0cd9-4fe0-80c2-c77207b4fd77 From 77aee4e0b612191f12631d0705afe1eb36d9c79c Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Thu, 24 Sep 2026 17:01:13 +0200 Subject: [PATCH 7/8] update python version in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b648316..e3b99ea 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",] + python-version: ["3.12", "3.13",] platform: [ubuntu-latest, macos-latest, windows-latest] steps: From 296711cbd797f607ff2a96484982e22cce79e36d Mon Sep 17 00:00:00 2001 From: Kevin Yamauchi Date: Thu, 24 Sep 2026 17:08:34 +0200 Subject: [PATCH 8/8] bump package CI workflow --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b99ea..e142037 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: hynek/build-and-inspect-python-package@v2 + - uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 upload-to-pypi: name: Upload package to PyPI