Skip to content

Commit 1496f71

Browse files
add 2d image rendering (#57)
* initial 2d rendering * 2d/3d integration
1 parent c9855f3 commit 1496f71

34 files changed

Lines changed: 3324 additions & 267 deletions

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ ignore = [
117117

118118
[tool.ruff.lint.per-file-ignores]
119119
"tests/*.py" = ["D", "S"]
120+
"scripts/**/*.py" = ["D", "E402", "E501", "E702", "F841", "F821", "TCH", "RUF001", "RUF002", "RUF003", "RUF006", "B007", "C416"]
120121
# Pydantic v2 models require these imports at runtime for field type resolution
121122
"src/cellier/v2/scene/*.py" = ["TCH001", "TCH003"]
122123
"src/cellier/v2/viewer_model.py" = ["TCH001", "TCH003"]
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"""Async 2D multiscale image viewer using CellierController.
2+
3+
Uses the full cellier v2 pipeline: CellierController -> RenderManager ->
4+
SliceCoordinator -> SceneManager -> GFXMultiscaleImageVisual.build_slice_request_2d()
5+
-> AsyncSlicer -> data_store.get_data() -> on_data_ready_2d().
6+
7+
No BlockState2D is needed.
8+
9+
Usage
10+
-----
11+
Generate the multiscale zarr store (once):
12+
13+
uv run example.py --make-files
14+
15+
Then launch the 2D viewer:
16+
17+
uv run example_2d.py [--zarr-path PATH] [--z-slice N]
18+
"""
19+
20+
import argparse
21+
import asyncio
22+
import pathlib
23+
import sys
24+
import time
25+
from typing import ClassVar
26+
27+
import PySide6.QtAsyncio as QtAsyncio
28+
from PySide6.QtWidgets import (
29+
QApplication,
30+
QButtonGroup,
31+
QCheckBox,
32+
QDoubleSpinBox,
33+
QHBoxLayout,
34+
QLabel,
35+
QMainWindow,
36+
QPushButton,
37+
QRadioButton,
38+
QSpinBox,
39+
QVBoxLayout,
40+
QWidget,
41+
)
42+
43+
from cellier.v2.controller import CellierController
44+
from cellier.v2.data.image import MultiscaleZarrDataStore
45+
from cellier.v2.scene.dims import CoordinateSystem
46+
from cellier.v2.visuals import ImageAppearance
47+
48+
GPU_BUDGET = 64 * 1024**2 # 64 MB for 2D
49+
LOD_BIAS = 1.0
50+
COMMIT_BATCH_SIZE = 16
51+
52+
ZARR_PATH = pathlib.Path(__file__).parent / "multiscale_blobs.zarr"
53+
ZARR_SCALE_NAMES = ["s0", "s1", "s2"]
54+
55+
56+
class MainWindow(QMainWindow):
57+
"""PySide6 main window using CellierController for 2D image viewing."""
58+
59+
_COLORMAPS: ClassVar[list[str]] = ["viridis", "gray"]
60+
61+
def __init__(
62+
self,
63+
data_store: MultiscaleZarrDataStore,
64+
z_slice: int | None = None,
65+
) -> None:
66+
super().__init__()
67+
self._update_count = 0
68+
self._force_level: int | None = None
69+
self._colormap_index: int = 0
70+
71+
# Build the cellier scene with 2D dimensionality.
72+
self._controller = CellierController(widget_parent=self, slicer_batch_size=32)
73+
cs = CoordinateSystem(name="world", axis_labels=("z", "y", "x"))
74+
self._scene = self._controller.add_scene(
75+
dim="2d", coordinate_system=cs, name="main"
76+
)
77+
78+
# Determine the initial z-slice.
79+
z_depth = data_store.level_shapes[0][0]
80+
if z_slice is None:
81+
z_slice = z_depth // 2
82+
83+
# Set initial slice index on the scene's dims.
84+
self._scene.dims.slice_indices = (z_slice,)
85+
86+
# Add the image through the standard controller API.
87+
appearance = ImageAppearance(
88+
color_map="viridis",
89+
clim=(0.0, 1.0),
90+
lod_bias=LOD_BIAS,
91+
force_level=None,
92+
frustum_cull=True,
93+
)
94+
self._visual = self._controller.add_image(
95+
data=data_store,
96+
scene_id=self._scene.id,
97+
appearance=appearance,
98+
name="image",
99+
block_size=32,
100+
gpu_budget_bytes=GPU_BUDGET,
101+
)
102+
103+
# Add a canvas (auto-detects 2D -> orthographic + pan/zoom).
104+
canvas_widget = self._controller.add_canvas(self._scene.id)
105+
106+
self._z_max = z_depth - 1
107+
self._setup_ui(canvas_widget)
108+
109+
def _setup_ui(self, canvas_widget: QWidget) -> None:
110+
central = QWidget()
111+
self.setCentralWidget(central)
112+
root = QHBoxLayout(central)
113+
root.setContentsMargins(0, 0, 0, 0)
114+
115+
panel = QWidget()
116+
panel.setFixedWidth(220)
117+
panel_layout = QVBoxLayout(panel)
118+
panel_layout.setContentsMargins(8, 8, 8, 8)
119+
120+
# Update button.
121+
self._update_btn = QPushButton("Update")
122+
self._update_btn.clicked.connect(
123+
lambda: asyncio.ensure_future(self._on_update_clicked())
124+
)
125+
panel_layout.addWidget(self._update_btn)
126+
127+
# Colormap toggle.
128+
self._colormap_btn = QPushButton(
129+
f"Colormap: {self._COLORMAPS[self._colormap_index]}"
130+
)
131+
self._colormap_btn.clicked.connect(self._on_toggle_colormap)
132+
panel_layout.addWidget(self._colormap_btn)
133+
134+
# Viewport cull checkbox.
135+
self._viewport_cull_cb = QCheckBox("Viewport cull")
136+
self._viewport_cull_cb.setChecked(True)
137+
panel_layout.addWidget(self._viewport_cull_cb)
138+
139+
# Force level radio buttons.
140+
panel_layout.addWidget(QLabel("Force level:"))
141+
self._level_group = QButtonGroup(self)
142+
for label, value in [("Auto", None), ("1", 1), ("2", 2), ("3", 3)]:
143+
rb = QRadioButton(label)
144+
if value is None:
145+
rb.setChecked(True)
146+
self._level_group.addButton(rb)
147+
rb.setProperty("force_level", value)
148+
panel_layout.addWidget(rb)
149+
self._level_group.buttonClicked.connect(self._on_level_radio_clicked)
150+
151+
# LOD bias spinbox.
152+
panel_layout.addWidget(QLabel("LOD bias:"))
153+
self._lod_bias_sb = QDoubleSpinBox()
154+
self._lod_bias_sb.setRange(0.1, 10.0)
155+
self._lod_bias_sb.setSingleStep(0.1)
156+
self._lod_bias_sb.setDecimals(2)
157+
self._lod_bias_sb.setValue(LOD_BIAS)
158+
panel_layout.addWidget(self._lod_bias_sb)
159+
160+
# Z slice spinbox.
161+
panel_layout.addWidget(QLabel("Z slice:"))
162+
self._z_slice_sb = QSpinBox()
163+
self._z_slice_sb.setRange(0, self._z_max)
164+
self._z_slice_sb.setValue(self._scene.dims.slice_indices[0])
165+
self._z_slice_sb.valueChanged.connect(self._on_z_slice_changed)
166+
panel_layout.addWidget(self._z_slice_sb)
167+
168+
panel_layout.addStretch()
169+
170+
# Status label.
171+
self._status_label = QLabel("Ready")
172+
self._status_label.setWordWrap(True)
173+
panel_layout.addWidget(self._status_label)
174+
175+
root.addWidget(panel)
176+
root.addWidget(canvas_widget, stretch=1)
177+
178+
# ── UI callbacks ───────────────────────────────────────────────────
179+
180+
def _on_toggle_colormap(self) -> None:
181+
self._colormap_index = (self._colormap_index + 1) % len(self._COLORMAPS)
182+
new_cmap = self._COLORMAPS[self._colormap_index]
183+
self._visual.appearance.color_map = new_cmap
184+
self._colormap_btn.setText(f"Colormap: {new_cmap}")
185+
print(f"[colormap] switched to '{new_cmap}'")
186+
187+
def _on_level_radio_clicked(self, button: QRadioButton) -> None:
188+
self._force_level = button.property("force_level")
189+
190+
def _on_z_slice_changed(self, value: int) -> None:
191+
self._scene.dims.slice_indices = (value,)
192+
# All cached tiles are from the old z-plane — clear and re-fetch.
193+
gfx_visual = self._get_gfx_visual()
194+
gfx_visual._block_cache_2d.tile_manager.clear()
195+
gfx_visual._lut_manager_2d.rebuild(gfx_visual._block_cache_2d.tile_manager)
196+
self._reslice_task = asyncio.ensure_future(self._on_update_clicked())
197+
198+
def _get_gfx_visual(self):
199+
"""Access the render-layer visual (for stats / cache access)."""
200+
return self._controller._render_manager._scenes[self._scene.id]._visuals[
201+
self._visual.id
202+
]
203+
204+
# ── Update button handler ──────────────────────────────────────────
205+
206+
async def _on_update_clicked(self) -> None:
207+
"""Main update flow: write UI values into model, then reslice."""
208+
self._update_count += 1
209+
t_total = time.perf_counter()
210+
211+
# Write current UI values into the visual appearance model.
212+
self._visual.appearance.lod_bias = self._lod_bias_sb.value()
213+
self._visual.appearance.force_level = self._force_level
214+
self._visual.appearance.frustum_cull = self._viewport_cull_cb.isChecked()
215+
216+
# Trigger the full cellier reslicing pipeline.
217+
self._controller.reslice_scene(self._scene.id)
218+
219+
t_ms = (time.perf_counter() - t_total) * 1000
220+
221+
# Read debug stats from the render layer.
222+
gfx_visual = self._get_gfx_visual()
223+
stats = gfx_visual._last_plan_stats
224+
225+
total = stats.get("fills", 0)
226+
if total == 0:
227+
self._status_label.setText(
228+
f"Ready (all {stats.get('total_required', 0)} tiles cached)"
229+
)
230+
else:
231+
self._status_label.setText(f"Loading: 0 / {total} tiles")
232+
233+
print(
234+
f"[update #{self._update_count}] planning={t_ms:.1f}ms "
235+
f"fills={stats.get('fills', 0)} hits={stats.get('hits', 0)} "
236+
f"culled={stats.get('n_culled', 0)} "
237+
f"dropped={stats.get('n_dropped', 0)}"
238+
)
239+
240+
241+
async def async_main(data_store: MultiscaleZarrDataStore, z_slice: int | None) -> None:
242+
"""Create the main window and run the Qt event loop."""
243+
app = QApplication.instance()
244+
window = MainWindow(data_store, z_slice=z_slice)
245+
window.resize(1200, 800)
246+
window.setWindowTitle("Async 2D multiscale image viewer — CellierController")
247+
window.show()
248+
249+
close_event = asyncio.Event()
250+
app.aboutToQuit.connect(close_event.set)
251+
await close_event.wait()
252+
253+
254+
def main() -> None:
255+
"""Parse CLI args, build the data store, and launch the viewer."""
256+
parser = argparse.ArgumentParser(
257+
description="Async 2D multiscale image viewer (CellierController)"
258+
)
259+
parser.add_argument(
260+
"--zarr-path",
261+
type=pathlib.Path,
262+
default=ZARR_PATH,
263+
help="Path to the multiscale zarr store.",
264+
)
265+
parser.add_argument(
266+
"--z-slice",
267+
type=int,
268+
default=None,
269+
help="Z slice index (default: mid-slice).",
270+
)
271+
args = parser.parse_args()
272+
273+
if not args.zarr_path.exists():
274+
print(f"Error: zarr store not found at '{args.zarr_path}'")
275+
print("Run example.py with --make-files first:")
276+
print(" uv run example.py --make-files")
277+
sys.exit(1)
278+
279+
print("Opening tensorstore stores via MultiscaleZarrDataStore ...")
280+
data_store = MultiscaleZarrDataStore(
281+
zarr_path=str(args.zarr_path),
282+
scale_names=ZARR_SCALE_NAMES,
283+
)
284+
print(f" {data_store.n_levels} levels opened.")
285+
for i, shape in enumerate(data_store.level_shapes):
286+
print(f" s{i}: shape={shape}")
287+
print()
288+
print(
289+
"Press 'Update' to run the pipeline. "
290+
"Pan/zoom with the mouse between presses.\n"
291+
)
292+
293+
_app = QApplication([sys.argv[0]])
294+
QtAsyncio.run(async_main(data_store, args.z_slice), handle_sigint=True)
295+
296+
297+
if __name__ == "__main__":
298+
main()

0 commit comments

Comments
 (0)