44import numpy as np
55from pydantic import field_validator
66from shapely .geometry import MultiPolygon , Polygon
7+ from shapely import geometry as geom
78
89from ..data import MaskData
910from .geometry import Geometry
1011
1112
13+ class NormalizedShapelyWrapper :
14+ """Wrapper for shapely objects that normalizes coordinate format in __geo_interface__"""
15+
16+ def __init__ (self , shapely_obj , normalizer_func ):
17+ self ._shapely_obj = shapely_obj
18+ self ._normalizer_func = normalizer_func
19+
20+ def __getattr__ (self , name ):
21+ """Delegate all attributes to the wrapped shapely object"""
22+ return getattr (self ._shapely_obj , name )
23+
24+ @property
25+ def __geo_interface__ (self ):
26+ """Return normalized coordinates"""
27+ return self ._normalizer_func (self ._shapely_obj .__geo_interface__ )
28+
29+
1230class Mask (Geometry ):
1331 """Mask used to represent a single class in a larger segmentation mask
1432
@@ -35,8 +53,71 @@ class Mask(Geometry):
3553 mask : MaskData
3654 color : Union [Tuple [int , int , int ], int ]
3755
56+ def _normalize_coordinates (self , geometry_dict : Dict ) -> Dict :
57+ """
58+ Normalize coordinate format to match expected GeoJSON structure.
59+
60+ Ensures that coordinate pairs are tuples (x, y) and coordinate rings are tuples,
61+ but preserves the outer list structure to match GeoJSON specification.
62+
63+ Args:
64+ geometry_dict: GeoJSON-style geometry dictionary
65+
66+ Returns:
67+ Geometry dictionary with coordinates normalized to expected format
68+ """
69+
70+ def normalize_coord_sequence (coords , level = 0 ):
71+ """Recursively normalize coordinate sequences"""
72+ if isinstance (coords , (list , tuple )):
73+ if len (coords ) == 2 and isinstance (coords [0 ], (int , float )):
74+ # This is a coordinate pair [x, y] or (x, y) - convert to tuple
75+ return (float (coords [0 ]), float (coords [1 ]))
76+ else :
77+ # This is a sequence of coordinates or nested sequences
78+ # For MultiPolygon: preserve outermost list, convert inner structures to tuples
79+ if level == 0 :
80+ # Keep outermost as list for GeoJSON compatibility
81+ return [
82+ normalize_coord_sequence (item , level + 1 )
83+ for item in coords
84+ ]
85+ else :
86+ # Convert inner coordinate rings to tuples
87+ return tuple (
88+ normalize_coord_sequence (item , level + 1 )
89+ for item in coords
90+ )
91+ return coords
92+
93+ if "coordinates" in geometry_dict :
94+ geometry_dict = geometry_dict .copy ()
95+ geometry_dict ["coordinates" ] = normalize_coord_sequence (
96+ geometry_dict ["coordinates" ]
97+ )
98+
99+ return geometry_dict
100+
101+ @property
102+ def shapely (
103+ self ,
104+ ) -> Union [
105+ geom .Point ,
106+ geom .LineString ,
107+ geom .Polygon ,
108+ geom .MultiPoint ,
109+ geom .MultiLineString ,
110+ geom .MultiPolygon ,
111+ ]:
112+ """Override shapely property to ensure normalized coordinates via wrapper"""
113+ original_shapely = geom .shape (self .geometry )
114+ return NormalizedShapelyWrapper (
115+ original_shapely , self ._normalize_coordinates
116+ )
117+
38118 @property
39119 def geometry (self ) -> Dict [str , Tuple [int , int , int ]]:
120+ # Extract mask contours and build geometry
40121 mask = self .draw (color = 1 )
41122 contours , hierarchy = cv2 .findContours (
42123 image = mask , mode = cv2 .RETR_TREE , method = cv2 .CHAIN_APPROX_NONE
@@ -62,7 +143,10 @@ def geometry(self) -> Dict[str, Tuple[int, int, int]]:
62143 if not holes .is_valid :
63144 holes = holes .buffer (0 )
64145
65- return external_polygons .difference (holes ).__geo_interface__
146+ # Get geometry from shapely and normalize coordinates for consistency
147+ # This ensures customers always get list format regardless of shapely version
148+ geometry_dict = external_polygons .difference (holes ).__geo_interface__
149+ return self ._normalize_coordinates (geometry_dict )
66150
67151 def draw (
68152 self ,
0 commit comments