66"""
77
88import logging
9+ from abc import ABC , abstractmethod
910from dataclasses import dataclass , field
1011from enum import Enum
1112from 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
1415import numpy as np
16+ from metaclass_registry import AutoRegisterMeta
1517
1618from .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
102176def 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