Skip to content

Commit 0e8a359

Browse files
extend dims to nD (#61)
1 parent 1f10ba3 commit 0e8a359

18 files changed

Lines changed: 654 additions & 314 deletions

File tree

‎scripts/v2/integration_2d_3d/example_2d.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def __init__(
8181
z_slice = z_depth // 2
8282

8383
# Set initial slice index on the scene's dims.
84-
self._scene.dims.slice_indices = (z_slice,)
84+
self._scene.dims.selection.slice_indices = {0: z_slice}
8585

8686
# Add the image through the standard controller API.
8787
appearance = ImageAppearance(
@@ -161,7 +161,7 @@ def _setup_ui(self, canvas_widget: QWidget) -> None:
161161
panel_layout.addWidget(QLabel("Z slice:"))
162162
self._z_slice_sb = QSpinBox()
163163
self._z_slice_sb.setRange(0, self._z_max)
164-
self._z_slice_sb.setValue(self._scene.dims.slice_indices[0])
164+
self._z_slice_sb.setValue(self._scene.dims.selection.slice_indices[0])
165165
self._z_slice_sb.valueChanged.connect(self._on_z_slice_changed)
166166
panel_layout.addWidget(self._z_slice_sb)
167167

@@ -188,7 +188,7 @@ def _on_level_radio_clicked(self, button: QRadioButton) -> None:
188188
self._force_level = button.property("force_level")
189189

190190
def _on_z_slice_changed(self, value: int) -> None:
191-
self._scene.dims.slice_indices = (value,)
191+
self._scene.dims.selection.slice_indices = {0: value}
192192
# All cached tiles are from the old z-plane — clear and re-fetch.
193193
gfx_visual = self._get_gfx_visual()
194194
gfx_visual._block_cache_2d.tile_manager.clear()

‎scripts/v2/integration_2d_3d/example_combined.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ def __init__(self, data_store: MultiscaleZarrDataStore) -> None:
253253
self._scene_2d = self._controller.add_scene(
254254
dim="2d", coordinate_system=cs, name="scene_2d"
255255
)
256-
self._scene_2d.dims.slice_indices = (z_depth // 2,)
256+
self._scene_2d.dims.selection.slice_indices = {0: z_depth // 2}
257257

258258
self._visual_2d = self._controller.add_image(
259259
data=data_store,
@@ -364,7 +364,7 @@ def _setup_ui(self) -> None:
364364

365365
self._z_slice_sb = QSpinBox()
366366
self._z_slice_sb.setRange(0, self._z_max)
367-
self._z_slice_sb.setValue(self._scene_2d.dims.slice_indices[0])
367+
self._z_slice_sb.setValue(self._scene_2d.dims.selection.slice_indices[0])
368368
self._z_slice_sb.valueChanged.connect(self._on_z_slice_changed)
369369
pl.addWidget(self._z_slice_sb)
370370
self._widget_2d.append(self._z_slice_sb)
@@ -493,7 +493,7 @@ def _on_level_radio_clicked(self, button: QRadioButton) -> None:
493493
self._force_level = button.property("force_level")
494494

495495
def _on_z_slice_changed(self, value: int) -> None:
496-
self._scene_2d.dims.slice_indices = (value,)
496+
self._scene_2d.dims.selection.slice_indices = {0: value}
497497
# All cached tiles are from the old z-plane — clear and re-fetch.
498498
gfx_visual = self._get_gfx_visual_2d()
499499
gfx_visual._block_cache_2d.tile_manager.clear()

‎scripts/v2/integration_2d_3d/example_combined_camera_redraw.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def __init__(self, data_store: MultiscaleZarrDataStore) -> None:
227227
self._scene_2d = self._controller.add_scene(
228228
dim="2d", coordinate_system=cs, name="scene_2d"
229229
)
230-
self._scene_2d.dims.slice_indices = (z_depth // 2,)
230+
self._scene_2d.dims.selection.slice_indices = {0: z_depth // 2}
231231

232232
self._visual_2d = self._controller.add_image(
233233
data=data_store,
@@ -348,7 +348,7 @@ def _setup_ui(self) -> None:
348348

349349
self._z_slice_sb = QSpinBox()
350350
self._z_slice_sb.setRange(0, self._z_max)
351-
self._z_slice_sb.setValue(self._scene_2d.dims.slice_indices[0])
351+
self._z_slice_sb.setValue(self._scene_2d.dims.selection.slice_indices[0])
352352
self._z_slice_sb.valueChanged.connect(self._on_z_slice_changed)
353353
pl.addWidget(self._z_slice_sb)
354354
self._widget_2d.append(self._z_slice_sb)
@@ -486,7 +486,7 @@ def _on_level_radio_clicked(self, button: QRadioButton) -> None:
486486
self._force_level = button.property("force_level")
487487

488488
def _on_z_slice_changed(self, value: int) -> None:
489-
self._scene_2d.dims.slice_indices = (value,)
489+
self._scene_2d.dims.selection.slice_indices = {0: value}
490490
# All cached tiles are from the old z-plane — clear and re-fetch.
491491
gfx_visual = self._get_gfx_visual_2d()
492492
gfx_visual._block_cache_2d.tile_manager.clear()

‎scripts/v2/lut_texture_multiscale_frustum_async_cellier/example_cellier_2d.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ def __init__(
9494
self._z_slice = z_slice
9595

9696
# Set initial slice index on the scene's dims.
97-
self._scene.dims.slice_indices = (self._z_slice,)
97+
self._scene.dims.selection.slice_indices = {0: self._z_slice}
9898

9999
self._visual = self._controller.add_image(
100100
data=data_store,
@@ -201,7 +201,7 @@ def _on_z_slice_changed(self, value: int) -> None:
201201
self._z_slice = value
202202
# Update the scene's dims — this is what SceneManager reads
203203
# when building slice requests.
204-
self._scene.dims.slice_indices = (value,)
204+
self._scene.dims.selection.slice_indices = {0: value}
205205
# Clear the 2D tile cache so all tiles are re-fetched at the
206206
# new Z slice.
207207
gfx_visual = self._get_gfx_visual()

‎scripts/v2/lut_texture_multiscale_frustum_async_cellier/example_v2.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
QWidget,
5353
)
5454

55+
from cellier.v2._state import AxisAlignedSelectionState
5556
from cellier.v2.data.image import MultiscaleZarrDataStore
5657
from cellier.v2.render import DimsState, RenderManager
5758
from cellier.v2.render._frustum import frustum_planes_from_corners
@@ -277,7 +278,12 @@ async def _on_update_clicked(self) -> None:
277278
config.force_level = self._force_level
278279
config.frustum_cull = self._frustum_cull_cb.isChecked()
279280

280-
dims_state = DimsState(displayed_axes=(0, 1, 2), slice_indices=())
281+
dims_state = DimsState(
282+
axis_labels=("z", "y", "x"),
283+
selection=AxisAlignedSelectionState(
284+
displayed_axes=(0, 1, 2), slice_indices={}
285+
),
286+
)
281287
self._render_manager.trigger_update(dims_state)
282288

283289
visual = self._visual

‎src/cellier/v2/_state.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,40 @@
55
from typing import Literal, NamedTuple
66

77

8+
class AxisAlignedSelectionState(NamedTuple):
9+
"""Immutable snapshot of an axis-aligned selection."""
10+
11+
displayed_axes: tuple[int, ...]
12+
slice_indices: dict[int, int]
13+
14+
def to_index_selection(self, ndim: int) -> tuple[int | slice, ...]:
15+
"""Return a per-axis numpy indexer in axis order.
16+
17+
Displayed axes → ``slice(None)``, sliced axes → their int value.
18+
"""
19+
result: list[int | slice] = []
20+
for axis in range(ndim):
21+
if axis in self.slice_indices:
22+
result.append(self.slice_indices[axis])
23+
else:
24+
result.append(slice(None))
25+
return tuple(result)
26+
27+
28+
class PlaneSelectionState(NamedTuple):
29+
"""Stub — not yet implemented."""
30+
31+
pass
32+
33+
34+
SelectionState = AxisAlignedSelectionState | PlaneSelectionState
35+
36+
837
class DimsState(NamedTuple):
938
"""Current dimension display state for a scene."""
1039

11-
displayed_axes: tuple[int, ...]
12-
slice_indices: tuple[int, ...]
40+
axis_labels: tuple[str, ...]
41+
selection: SelectionState
1342

1443

1544
class CameraState(NamedTuple):

‎src/cellier/v2/controller.py‎

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88

99
import numpy as np
1010

11-
from cellier.v2._state import CameraState, DimsState
1211
from cellier.v2.events import (
1312
AppearanceChangedEvent,
1413
CameraChangedEvent,
@@ -29,7 +28,11 @@
2928
PerspectiveCamera,
3029
)
3130
from cellier.v2.scene.canvas import Canvas
32-
from cellier.v2.scene.dims import CoordinateSystem, DimsManager
31+
from cellier.v2.scene.dims import (
32+
AxisAlignedSelection,
33+
CoordinateSystem,
34+
DimsManager,
35+
)
3336
from cellier.v2.scene.scene import Scene
3437
from cellier.v2.viewer_model import DataManager, ViewerModel
3538
from cellier.v2.visuals._image import MultiscaleImageVisual
@@ -40,6 +43,7 @@
4043
from psygnal import EmissionInfo
4144
from PySide6.QtWidgets import QWidget
4245

46+
from cellier.v2._state import CameraState, DimsState
4347
from cellier.v2.data._base_data_store import BaseDataStore
4448
from cellier.v2.visuals._image import ImageAppearance
4549

@@ -174,19 +178,23 @@ def add_scene(
174178
Scene
175179
The live model object. Use ``scene.id`` in subsequent calls.
176180
"""
181+
ndim = len(coordinate_system.axis_labels)
177182
if dim == "3d":
178-
displayed_axes = (0, 1, 2)
179-
slice_indices: tuple[int, ...] = ()
183+
displayed_axes = tuple(range(ndim))
184+
slice_indices_dict: dict[int, int] = {}
180185
elif dim == "2d":
181-
displayed_axes = (0, 1)
182-
slice_indices = (0,)
186+
# Display the last two axes; slice all others at 0.
187+
displayed_axes = tuple(range(ndim - 2, ndim))
188+
slice_indices_dict = {i: 0 for i in range(ndim - 2)}
183189
else:
184190
raise ValueError(f"dim must be '2d' or '3d', got {dim!r}")
185191

186192
dims = DimsManager(
187193
coordinate_system=coordinate_system,
188-
displayed_axes=displayed_axes,
189-
slice_indices=slice_indices,
194+
selection=AxisAlignedSelection(
195+
displayed_axes=displayed_axes,
196+
slice_indices=slice_indices_dict,
197+
),
190198
)
191199
scene = Scene(name=name, dims=dims)
192200
self._model.scenes[scene.id] = scene
@@ -267,7 +275,7 @@ def add_image(
267275
self._model.data.stores[data.id] = data
268276

269277
scene = self._model.scenes[scene_id]
270-
dim = scene.dims.displayed_axes # (0,1,2) → 3d, (0,1) → 2d
278+
displayed_axes = scene.dims.selection.displayed_axes
271279

272280
downscale_factors = [2**i for i in range(data.n_levels)]
273281

@@ -280,12 +288,13 @@ def add_image(
280288

281289
scene.visuals.append(visual_model)
282290

283-
render_modes = {"3d"} if len(dim) == 3 else {"2d"}
291+
render_modes = {"3d"} if len(displayed_axes) == 3 else {"2d"}
284292

285293
level_shapes = list(data.level_shapes)
286294
gfx_visual = GFXMultiscaleImageVisual.from_cellier_model(
287295
model=visual_model,
288296
level_shapes=level_shapes,
297+
displayed_axes=displayed_axes,
289298
render_modes=render_modes,
290299
block_size=block_size,
291300
gpu_budget_bytes=gpu_budget_bytes,
@@ -358,11 +367,13 @@ def add_visual(
358367
scene = self._model.scenes[scene_id]
359368
scene.visuals.append(visual_model)
360369

361-
render_modes = {"3d"} if len(scene.dims.displayed_axes) == 3 else {"2d"}
370+
displayed_axes = scene.dims.selection.displayed_axes
371+
render_modes = {"3d"} if len(displayed_axes) == 3 else {"2d"}
362372
level_shapes = list(data_store.level_shapes)
363373
gfx_visual = GFXMultiscaleImageVisual.from_cellier_model(
364374
model=visual_model,
365375
level_shapes=level_shapes,
376+
displayed_axes=displayed_axes,
366377
render_modes=render_modes,
367378
block_size=block_size,
368379
gpu_budget_bytes=gpu_budget_bytes,
@@ -408,7 +419,9 @@ def add_canvas(
408419
# Infer dimensionality from the scene if not specified.
409420
if available_dims is None:
410421
scene = self._model.scenes[scene_id]
411-
available_dims = "3d" if len(scene.dims.displayed_axes) == 3 else "2d"
422+
available_dims = (
423+
"3d" if len(scene.dims.selection.displayed_axes) == 3 else "2d"
424+
)
412425

413426
camera_model = PerspectiveCamera(
414427
fov=fov,
@@ -487,33 +500,29 @@ def _build_visual_configs_for_scene(
487500

488501
def _dims_state_for_scene(self, scene_id: UUID) -> DimsState:
489502
"""Derive a DimsState from the scene's DimsManager."""
490-
dims = self._model.scenes[scene_id].dims
491-
return DimsState(
492-
displayed_axes=dims.displayed_axes,
493-
slice_indices=dims.slice_indices,
494-
)
503+
return self._model.scenes[scene_id].dims.to_state()
495504

496505
# ------------------------------------------------------------------
497506
# psygnal bridges
498507
# ------------------------------------------------------------------
499508

500509
def _wire_dims_model(self, scene: Scene) -> None:
501510
"""Subscribe to all field changes on a scene's DimsManager."""
502-
self._dims_cache[scene.id] = scene.dims.displayed_axes
511+
self._dims_cache[scene.id] = scene.dims.selection.displayed_axes
503512
scene.dims.events.connect(self._make_dims_handler(scene.id))
504513

505514
def _make_dims_handler(self, scene_id: UUID) -> Callable:
506515
"""Return a psygnal catch-all handler for a scene's DimsManager."""
507516

508517
def _on_dims_psygnal(info: EmissionInfo) -> None:
509-
dims = self._model.scenes[scene_id].dims
510-
new_state = DimsState(
511-
displayed_axes=dims.displayed_axes,
512-
slice_indices=dims.slice_indices,
513-
)
518+
new_state = self._model.scenes[scene_id].dims.to_state()
514519
prev_axes = self._dims_cache[scene_id]
515-
displayed_axes_changed = prev_axes != new_state.displayed_axes
516-
self._dims_cache[scene_id] = new_state.displayed_axes
520+
displayed_axes_changed = prev_axes != new_state.selection.displayed_axes
521+
self._dims_cache[scene_id] = new_state.selection.displayed_axes
522+
if displayed_axes_changed:
523+
self._rebuild_visuals_geometry(
524+
scene_id, new_state.selection.displayed_axes
525+
)
517526
self._event_bus.emit(
518527
DimsChangedEvent(
519528
source_id=self._id,
@@ -525,6 +534,28 @@ def _on_dims_psygnal(info: EmissionInfo) -> None:
525534

526535
return _on_dims_psygnal
527536

537+
def _rebuild_visuals_geometry(
538+
self, scene_id: UUID, displayed_axes: tuple[int, ...]
539+
) -> None:
540+
"""Rebuild geometry for all visuals in a scene after displayed_axes change."""
541+
scene = self._model.scenes[scene_id]
542+
scene_manager = self._render_manager._scenes[scene_id]
543+
544+
for visual_model in scene.visuals:
545+
data_store = self._model.data.stores[UUID(visual_model.data_store_id)]
546+
level_shapes = list(data_store.level_shapes)
547+
gfx_visual = scene_manager.get_visual(visual_model.id)
548+
549+
old_node, new_node = gfx_visual.rebuild_geometry(
550+
level_shapes, displayed_axes
551+
)
552+
553+
# Swap node in the pygfx scene graph.
554+
if old_node is not None:
555+
scene_manager.scene.remove(old_node)
556+
if new_node is not None:
557+
scene_manager.scene.add(new_node)
558+
528559
def _wire_appearance(self, visual: MultiscaleImageVisual) -> None:
529560
"""Subscribe to all field changes on a visual's ImageAppearance."""
530561
visual.appearance.events.connect(self._make_appearance_handler(visual.id))

‎src/cellier/v2/data/image/_image_requests.py‎

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,44 +19,24 @@ class ChunkRequest(NamedTuple):
1919
may be negative or exceed the store bounds; ``get_data()`` on the
2020
``MultiscaleZarrDataStore`` is responsible for clamping and
2121
zero-padding so the returned array always has the full requested
22-
shape ``(z_stop-z_start, y_stop-y_start, x_stop-x_start)`` for 3D
23-
requests, or ``(y_stop-y_start, x_stop-x_start)`` for 2D requests
24-
(when ``z_slice`` is set).
22+
shape.
2523
2624
Parameters
2725
----------
2826
chunk_request_id :
29-
Unique ID for this individual chunk. Used by the callback to look
30-
up the corresponding ``TileSlot`` in the ``slot_map`` closure.
27+
Unique ID for this individual chunk.
3128
slice_request_id :
32-
Shared ID for all chunks that belong to the same planning event
33-
(one Update press → one ``slice_request_id``). Used by
34-
``AsyncSlicer.cancel()`` to cancel all in-flight work for a
35-
single update cycle.
29+
Shared ID for all chunks that belong to the same planning event.
3630
scale_index :
37-
0-based index into ``MultiscaleZarrDataStore`` levels
38-
(0 = finest). Note: this is ``scale_index``, not
39-
``BrickKey.level`` (which is 1-based).
40-
z_start, y_start, x_start :
41-
Padded region origin in voxels at this scale level. May be
42-
negative for boundary bricks.
43-
z_stop, y_stop, x_stop :
44-
Padded region end (exclusive) in voxels. May exceed store
45-
bounds for boundary bricks.
46-
z_slice :
47-
If not ``None``, this is a 2D request: the data store reads a
48-
single z-plane at this index (at the current scale level) and
49-
returns a 2D ``(H, W)`` array. ``z_start`` and ``z_stop`` are
50-
ignored when ``z_slice`` is set.
31+
0-based index into ``MultiscaleZarrDataStore`` levels (0 = finest).
32+
axis_selections :
33+
Per-axis selection in data axis order. Each element is either:
34+
- ``int`` → axis is sliced (single plane, already scaled to this level)
35+
- ``(start, stop)`` → axis is displayed (windowed range; may extend
36+
outside bounds)
5137
"""
5238

5339
chunk_request_id: UUID
5440
slice_request_id: UUID
5541
scale_index: int
56-
z_start: int
57-
y_start: int
58-
x_start: int
59-
z_stop: int
60-
y_stop: int
61-
x_stop: int
62-
z_slice: int | None = None
42+
axis_selections: tuple[int | tuple[int, int], ...]

0 commit comments

Comments
 (0)