Skip to content

Commit eae0ed3

Browse files
committed
Complete native Napari display handoff
Return the registered OpenHCS handler-owned display work unchanged through the generic Napari adapter so bounded rendering remains owned by the display leaf. Project registered ellipse ROIs through the shared coordinate converter as Napari four-corner bounding boxes, keeping the generic viewer blind to ROI subtype details. Add package-local regressions for both contracts.
1 parent 6951f02 commit eae0ed3

4 files changed

Lines changed: 113 additions & 32 deletions

File tree

src/polystore/roi_converters.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -404,21 +404,27 @@ def coordinates_yx(self, shape: PolylineShape) -> np.ndarray:
404404
return shape.coordinates
405405

406406

407-
class EllipseROIShapeNapariPayloadConverter(ROIShapeNapariPayloadConverter):
407+
class EllipseROIShapeNapariPayloadConverter(
408+
CoordinateROIShapeNapariPayloadConverter
409+
):
408410
shape_type = ShapeType.ELLIPSE
409-
410-
def shape_payloads(
411-
self,
412-
shape: EllipseShape,
413-
metadata: Dict[str, Any],
414-
) -> tuple[Dict[str, Any], ...]:
415-
return (
416-
{
417-
"type": "ellipse",
418-
"center": [shape.center_y, shape.center_x],
419-
"radii": [shape.radius_y, shape.radius_x],
420-
"metadata": metadata,
421-
},
411+
napari_payload_type = "ellipse"
412+
413+
def coordinates_yx(self, shape: EllipseShape) -> np.ndarray:
414+
"""Return Napari's four-corner ellipse bounding box in YX order."""
415+
416+
min_y = shape.center_y - shape.radius_y
417+
max_y = shape.center_y + shape.radius_y
418+
min_x = shape.center_x - shape.radius_x
419+
max_x = shape.center_x + shape.radius_x
420+
return np.asarray(
421+
(
422+
(min_y, min_x),
423+
(min_y, max_x),
424+
(max_y, max_x),
425+
(max_y, min_x),
426+
),
427+
dtype=float,
422428
)
423429

424430

src/polystore/streaming/receivers/napari/napari_batch_processor.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
2-
from dataclasses import dataclass
32
from collections.abc import Sequence
4-
from typing import Generic, Optional, TypeVar
3+
from dataclasses import dataclass
4+
from typing import Generic, TypeVar
55

66
logger = logging.getLogger(__name__)
77
NapariBatchItemT = TypeVar("NapariBatchItemT")
@@ -24,8 +24,8 @@ class NapariBatchDisplayRequest(
2424
display_payload: NapariDisplayPayloadT
2525
component_names_metadata: NapariComponentNamesMetadataT
2626

27-
def dispatch_to(self, napari_server) -> None:
28-
napari_server.display_layer_batch(
27+
def dispatch_to(self, napari_server) -> object:
28+
return napari_server.display_layer_batch(
2929
layer_key=self.layer_key,
3030
items=self.items,
3131
display_payload=self.display_payload,
@@ -41,17 +41,17 @@ class NapariBatchProcessor:
4141
Qt-thread debounce before this processor is called, so this class only
4242
adapts batch payloads into the server display operation.
4343
"""
44-
44+
4545
def __init__(
4646
self,
4747
napari_server,
48-
batch_size: Optional[int] = None,
48+
batch_size: int | None = None,
4949
debounce_delay_ms: int = 1000,
5050
max_debounce_wait_ms: int = 5000,
5151
):
5252
"""
5353
Initialize batch processor.
54-
54+
5555
Args:
5656
napari_server: Reference to NapariViewerServer for display operations
5757
batch_size: Reserved for compatibility with viewer configuration
@@ -62,29 +62,29 @@ def __init__(
6262
self.batch_size = batch_size
6363
self.debounce_delay_ms = debounce_delay_ms
6464
self.max_debounce_wait_ms = max_debounce_wait_ms
65-
65+
6666
logger.info(
6767
f"NapariBatchProcessor: Created with batch_size={batch_size}, "
6868
f"debounce={debounce_delay_ms}ms, max_wait={max_debounce_wait_ms}ms"
6969
)
70-
70+
7171
def add_items(
7272
self,
7373
layer_key: str,
7474
items: Sequence[NapariBatchItemT],
7575
display_payload: NapariDisplayPayloadT,
7676
component_names_metadata: NapariComponentNamesMetadataT,
77-
):
77+
) -> object:
7878
"""
7979
Display items already released by the Qt-thread debounce.
80-
80+
8181
Args:
8282
layer_key: Unique identifier for the layer
8383
items: List of items to add (images or ROIs)
8484
display_payload: Viewer-owned display payload object
8585
component_names_metadata: Component name mappings for dimension labels
8686
"""
87-
NapariBatchDisplayRequest(
87+
display_work = NapariBatchDisplayRequest(
8888
layer_key=layer_key,
8989
items=items,
9090
display_payload=display_payload,
@@ -95,6 +95,7 @@ def add_items(
9595
len(items),
9696
layer_key,
9797
)
98+
return display_work
9899

99100
def flush(self) -> None:
100101
"""Compatibility no-op; OpenHCS owns the Qt-thread debounce timer."""
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from polystore.streaming.receivers.napari import NapariBatchProcessor
2+
3+
4+
class RecordingNapariServer:
5+
def __init__(self, display_work):
6+
self.display_work = display_work
7+
self.calls = []
8+
9+
def display_layer_batch(self, **kwargs):
10+
self.calls.append(kwargs)
11+
return self.display_work
12+
13+
14+
def test_napari_batch_processor_returns_handler_owned_display_work():
15+
display_work = object()
16+
server = RecordingNapariServer(display_work)
17+
processor = NapariBatchProcessor(server)
18+
items = (object(), object())
19+
display_payload = object()
20+
component_names_metadata = object()
21+
22+
result = processor.add_items(
23+
"objects",
24+
items,
25+
display_payload,
26+
component_names_metadata,
27+
)
28+
29+
assert result is display_work
30+
assert server.calls == [
31+
{
32+
"layer_key": "objects",
33+
"items": items,
34+
"display_payload": display_payload,
35+
"component_names_metadata": component_names_metadata,
36+
}
37+
]

tests/test_roi.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,49 @@
22
import pytest
33

44
from polystore.disk import DiskStorageBackend
5-
from polystore.roi import ROI
6-
from polystore.roi import MaskShape
7-
from polystore.roi import PolygonShape
8-
from polystore.roi import load_rois_from_json
9-
from polystore.roi import load_rois_from_zip
10-
from polystore.roi import extract_rois_from_labeled_mask
5+
from polystore.roi import (
6+
ROI,
7+
EllipseShape,
8+
MaskShape,
9+
PolygonShape,
10+
extract_rois_from_labeled_mask,
11+
load_rois_from_json,
12+
load_rois_from_zip,
13+
)
14+
from polystore.roi_converters import NapariROIConverter
15+
16+
17+
def test_napari_roi_converter_projects_ellipse_as_native_bounding_box():
18+
metadata = {"label": 7, "area": 18.0, "centroid": (10.0, 20.0)}
19+
20+
payloads = NapariROIConverter.rois_to_shapes(
21+
[
22+
ROI(
23+
shapes=[
24+
EllipseShape(
25+
center_y=10.0,
26+
center_x=20.0,
27+
radius_y=3.0,
28+
radius_x=5.0,
29+
)
30+
],
31+
metadata=metadata,
32+
)
33+
]
34+
)
35+
36+
assert payloads == [
37+
{
38+
"type": "ellipse",
39+
"coordinates": [
40+
[7.0, 15.0],
41+
[7.0, 25.0],
42+
[13.0, 25.0],
43+
[13.0, 15.0],
44+
],
45+
"metadata": metadata,
46+
}
47+
]
1148

1249

1350
def test_extract_rois_from_labeled_mask_applies_spatial_origin_to_polygons():

0 commit comments

Comments
 (0)