Skip to content

Commit 28f670c

Browse files
committed
Improve ROI streaming metadata handling
1 parent e5909ba commit 28f670c

10 files changed

Lines changed: 501 additions & 253 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ classifiers = [
3737
]
3838

3939
dependencies = [
40+
"arraybridge>=0.2.9",
4041
"numpy>=1.26.0",
4142
"portalocker>=2.8.0", # Cross-platform file locking
4243
"metaclass-registry",

src/polystore/fiji_stream.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,9 @@ class FijiStreamingBackend(StreamingBackend):
3131
"""Fiji streaming backend with ZMQ publisher pattern (matches Napari architecture)."""
3232
_backend_type = Backend.FIJI_STREAM.value
3333

34-
# Configure ABC attributes
3534
VIEWER_TYPE = 'fiji'
3635
SHM_PREFIX = 'fiji_'
3736

38-
# __init__, _get_publisher, save, cleanup now inherited from ABC
39-
4037
def _prepare_rois_data(self, data: Any, file_path: Union[str, Path]) -> dict:
4138
"""
4239
Prepare ROIs data for transmission.
@@ -90,6 +87,7 @@ def save_batch(self, data_list: List[Any], file_paths: List[Union[str, Path]], *
9087
source = kwargs.get('source', 'unknown_source') # Pre-built source value
9188
images_dir = kwargs.get('images_dir') # Source image subdirectory for ROI mapping
9289
plate_path = kwargs.get('plate_path')
90+
component_metadata = kwargs.get('component_metadata')
9391
logger.info(f"🏷️ FIJI BACKEND: plate_path = {plate_path}")
9492
logger.info(f"🏷️ FIJI BACKEND: microscope_handler = {microscope_handler}")
9593
display_payload_extra = {
@@ -108,6 +106,7 @@ def save_batch(self, data_list: List[Any], file_paths: List[Union[str, Path]], *
108106
display_config,
109107
self._prepare_batch_item,
110108
plate_path=plate_path,
109+
component_metadata=component_metadata,
111110
component_names_kwargs={"log_prefix": "🏷️ FIJI BACKEND", "verbose": True},
112111
display_payload_extra=display_payload_extra,
113112
message_extra=message_extra,

src/polystore/napari_stream.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
import zmq
2121

2222
from .constants import Backend, TransportMode
23-
from .streaming_constants import StreamingDataType
2423
from .streaming import StreamingBackend
2524
from .roi_converters import NapariROIConverter
2625
from zmqruntime.transport import get_zmq_transport_url, coerce_transport_mode
@@ -32,12 +31,9 @@ class NapariStreamingBackend(StreamingBackend):
3231
"""Napari streaming backend with automatic registration."""
3332
_backend_type = Backend.NAPARI_STREAM.value
3433

35-
# Configure ABC attributes
3634
VIEWER_TYPE = 'napari'
3735
SHM_PREFIX = 'napari_'
3836

39-
# __init__, _get_publisher, save, cleanup now inherited from ABC
40-
4137
def _prepare_shapes_data(self, data: Any, file_path: Union[str, Path]) -> dict:
4238
"""
4339
Prepare shapes data for transmission.
@@ -57,7 +53,7 @@ def _prepare_shapes_data(self, data: Any, file_path: Union[str, Path]) -> dict:
5753
}
5854

5955
def _prepare_batch_item(self, data: Any, file_path: Union[str, Path], data_type):
60-
if data_type in (StreamingDataType.SHAPES, StreamingDataType.POINTS):
56+
if data_type.uses_napari_vector_payload:
6157
item_data = self._prepare_shapes_data(data, file_path)
6258
data_type_value = data_type.value
6359
else:
@@ -88,6 +84,7 @@ def save_batch(self, data_list: List[Any], file_paths: List[Union[str, Path]], *
8884
microscope_handler = kwargs['microscope_handler']
8985
source = kwargs.get('source', 'unknown_source') # Pre-built source value
9086
plate_path = kwargs.get('plate_path')
87+
component_metadata = kwargs.get('component_metadata')
9188
display_payload_extra = {
9289
"colormap": display_config.get_colormap_name(),
9390
"variable_size_handling": display_config.variable_size_handling.value
@@ -103,6 +100,7 @@ def save_batch(self, data_list: List[Any], file_paths: List[Union[str, Path]], *
103100
display_config,
104101
self._prepare_batch_item,
105102
plate_path=plate_path,
103+
component_metadata=component_metadata,
106104
display_payload_extra=display_payload_extra,
107105
)
108106

src/polystore/roi.py

Lines changed: 105 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
"""
77

88
import logging
9+
from abc import ABC, abstractmethod
910
from dataclasses import dataclass, field
1011
from enum import Enum
1112
from pathlib import Path
12-
from typing import Any, Dict, List, Optional, Tuple, Union
13+
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
1314

1415
import numpy as np
16+
from metaclass_registry import AutoRegisterMeta
1517

1618
from .constants import Backend
1719

@@ -27,8 +29,14 @@ class ShapeType(Enum):
2729
ELLIPSE = "ellipse"
2830

2931

32+
class ROIShape(ABC):
33+
"""Nominal base for all ROI shape records."""
34+
35+
shape_type: ShapeType
36+
37+
3038
@dataclass(frozen=True)
31-
class PolygonShape:
39+
class PolygonShape(ROIShape):
3240
"""Polygon ROI shape defined by vertex coordinates."""
3341
coordinates: np.ndarray # Nx2 array of (y, x) coordinates
3442
shape_type: ShapeType = field(default=ShapeType.POLYGON, init=False)
@@ -41,7 +49,7 @@ def __post_init__(self):
4149

4250

4351
@dataclass(frozen=True)
44-
class PolylineShape:
52+
class PolylineShape(ROIShape):
4553
"""Polyline ROI shape defined by path coordinates (open path, not closed polygon)."""
4654
coordinates: np.ndarray # Nx2 array of (y, x) coordinates
4755
shape_type: ShapeType = field(default=ShapeType.POLYLINE, init=False)
@@ -54,7 +62,7 @@ def __post_init__(self):
5462

5563

5664
@dataclass(frozen=True)
57-
class MaskShape:
65+
class MaskShape(ROIShape):
5866
"""Binary mask ROI shape."""
5967
mask: np.ndarray # 2D boolean array
6068
bbox: Tuple[int, int, int, int] # (min_y, min_x, max_y, max_x)
@@ -68,15 +76,15 @@ def __post_init__(self):
6876

6977

7078
@dataclass(frozen=True)
71-
class PointShape:
79+
class PointShape(ROIShape):
7280
"""Point ROI shape."""
7381
y: float
7482
x: float
7583
shape_type: ShapeType = field(default=ShapeType.POINT, init=False)
7684

7785

7886
@dataclass(frozen=True)
79-
class EllipseShape:
87+
class EllipseShape(ROIShape):
8088
"""Ellipse ROI shape."""
8189
center_y: float
8290
center_x: float
@@ -95,14 +103,82 @@ def __post_init__(self):
95103
if not self.shapes:
96104
raise ValueError("ROI must have at least one shape")
97105
for shape in self.shapes:
98-
if not hasattr(shape, "shape_type"):
99-
raise ValueError(f"Shape {shape} must have shape_type attribute")
106+
if not isinstance(shape, ROIShape):
107+
raise ValueError(f"Shape {shape} must be an ROIShape")
108+
109+
110+
class ROIJsonShapeDecoder(ABC, metaclass=AutoRegisterMeta):
111+
"""Decode one serialized ROI shape variant."""
112+
113+
__registry_key__ = "shape_type"
114+
__skip_if_no_key__ = True
115+
116+
shape_type: ClassVar[ShapeType | None] = None
117+
118+
@classmethod
119+
def for_serialized_shape(cls, shape_dict: Dict[str, Any]) -> "ROIJsonShapeDecoder | None":
120+
shape_type = shape_dict.get("type")
121+
try:
122+
shape_key = ShapeType(shape_type)
123+
except ValueError:
124+
logger.warning(f"Unknown shape type: {shape_type}, skipping")
125+
return None
126+
return cls.__registry__[shape_key]()
127+
128+
@abstractmethod
129+
def decode(self, shape_dict: Dict[str, Any]) -> Any:
130+
"""Return the concrete ROI shape represented by ``shape_dict``."""
131+
132+
133+
class PolygonROIJsonShapeDecoder(ROIJsonShapeDecoder):
134+
shape_type = ShapeType.POLYGON
135+
136+
def decode(self, shape_dict: Dict[str, Any]) -> PolygonShape:
137+
return PolygonShape(coordinates=np.array(shape_dict["coordinates"]))
138+
139+
140+
class PolylineROIJsonShapeDecoder(ROIJsonShapeDecoder):
141+
shape_type = ShapeType.POLYLINE
142+
143+
def decode(self, shape_dict: Dict[str, Any]) -> PolylineShape:
144+
return PolylineShape(coordinates=np.array(shape_dict["coordinates"]))
145+
146+
147+
class MaskROIJsonShapeDecoder(ROIJsonShapeDecoder):
148+
shape_type = ShapeType.MASK
149+
150+
def decode(self, shape_dict: Dict[str, Any]) -> MaskShape:
151+
return MaskShape(
152+
mask=np.array(shape_dict["mask"], dtype=bool),
153+
bbox=tuple(shape_dict["bbox"]),
154+
)
155+
156+
157+
class PointROIJsonShapeDecoder(ROIJsonShapeDecoder):
158+
shape_type = ShapeType.POINT
159+
160+
def decode(self, shape_dict: Dict[str, Any]) -> PointShape:
161+
return PointShape(y=shape_dict["y"], x=shape_dict["x"])
162+
163+
164+
class EllipseROIJsonShapeDecoder(ROIJsonShapeDecoder):
165+
shape_type = ShapeType.ELLIPSE
166+
167+
def decode(self, shape_dict: Dict[str, Any]) -> EllipseShape:
168+
return EllipseShape(
169+
center_y=shape_dict["center_y"],
170+
center_x=shape_dict["center_x"],
171+
radius_y=shape_dict["radius_y"],
172+
radius_x=shape_dict["radius_x"],
173+
)
100174

101175

102176
def extract_rois_from_labeled_mask(
103177
labeled_mask: np.ndarray,
104178
min_area: int = 10,
105179
extract_contours: bool = True,
180+
spatial_origin_yx: Optional[Tuple[int, int]] = None,
181+
source_spatial_shape_yx: Optional[Tuple[int, int]] = None,
106182
) -> List[ROI]:
107183
"""Extract ROIs from a labeled segmentation mask."""
108184
from skimage import measure
@@ -117,19 +193,33 @@ def extract_rois_from_labeled_mask(
117193

118194
regions = regionprops(labeled_mask)
119195
slices = find_objects(labeled_mask)
196+
origin_y, origin_x = spatial_origin_yx or (0, 0)
120197

121198
rois = []
122199
for region in regions:
123200
if region.area < min_area:
124201
continue
202+
min_y, min_x, max_y, max_x = region.bbox
125203

126204
metadata = {
127205
"label": int(region.label),
128206
"area": float(region.area),
129207
"perimeter": float(region.perimeter),
130-
"centroid": tuple(float(c) for c in region.centroid),
131-
"bbox": tuple(int(b) for b in region.bbox),
208+
"centroid": (
209+
float(region.centroid[0] + origin_y),
210+
float(region.centroid[1] + origin_x),
211+
),
212+
"bbox": (
213+
int(min_y + origin_y),
214+
int(min_x + origin_x),
215+
int(max_y + origin_y),
216+
int(max_x + origin_x),
217+
),
132218
}
219+
if source_spatial_shape_yx is not None:
220+
metadata["source_spatial_shape_yx"] = tuple(
221+
int(value) for value in source_spatial_shape_yx
222+
)
133223

134224
shapes = []
135225
if extract_contours:
@@ -142,14 +232,14 @@ def extract_rois_from_labeled_mask(
142232
contours = measure.find_contours(padded_mask, level=0.5)
143233
offset_y = slice_y.start
144234
offset_x = slice_x.start
145-
padding_offset = np.array([offset_y, offset_x]) - 1
235+
padding_offset = np.array([offset_y + origin_y, offset_x + origin_x]) - 1
146236
for contour in contours:
147237
if len(contour) >= 3:
148238
contour_full = contour + padding_offset
149239
shapes.append(PolygonShape(coordinates=contour_full))
150240
else:
151241
binary_mask = (labeled_mask == region.label)
152-
shapes.append(MaskShape(mask=binary_mask, bbox=region.bbox))
242+
shapes.append(MaskShape(mask=binary_mask, bbox=metadata["bbox"]))
153243

154244
if shapes:
155245
rois.append(ROI(shapes=shapes, metadata=metadata))
@@ -203,31 +293,9 @@ def load_rois_from_json(json_path: Path) -> List[ROI]:
203293
metadata = roi_dict.get("metadata", {})
204294
shapes = []
205295
for shape_dict in roi_dict.get("shapes", []):
206-
shape_type = shape_dict.get("type")
207-
208-
if shape_type == "polygon":
209-
coordinates = np.array(shape_dict["coordinates"])
210-
shapes.append(PolygonShape(coordinates=coordinates))
211-
elif shape_type == "polyline":
212-
coordinates = np.array(shape_dict["coordinates"])
213-
shapes.append(PolylineShape(coordinates=coordinates))
214-
elif shape_type == "mask":
215-
mask = np.array(shape_dict["mask"], dtype=bool)
216-
bbox = tuple(shape_dict["bbox"])
217-
shapes.append(MaskShape(mask=mask, bbox=bbox))
218-
elif shape_type == "point":
219-
shapes.append(PointShape(y=shape_dict["y"], x=shape_dict["x"]))
220-
elif shape_type == "ellipse":
221-
shapes.append(
222-
EllipseShape(
223-
center_y=shape_dict["center_y"],
224-
center_x=shape_dict["center_x"],
225-
radius_y=shape_dict["radius_y"],
226-
radius_x=shape_dict["radius_x"],
227-
)
228-
)
229-
else:
230-
logger.warning(f"Unknown shape type: {shape_type}, skipping")
296+
decoder = ROIJsonShapeDecoder.for_serialized_shape(shape_dict)
297+
if decoder is not None:
298+
shapes.append(decoder.decode(shape_dict))
231299

232300
if shapes:
233301
rois.append(ROI(shapes=shapes, metadata=metadata))

0 commit comments

Comments
 (0)