Coverage for python/lsst/images/_polygon.py: 42%

118 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-03 08:08 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ("Polygon", "Region", "RegionSerializationModel") 

15 

16from typing import TYPE_CHECKING, Any, Literal, overload 

17 

18import numpy as np 

19import numpy.typing as npt 

20import pydantic 

21import shapely 

22 

23from ._geom import Bounds, Box 

24from .utils import round_half_down, round_half_up 

25 

26if TYPE_CHECKING: 

27 try: 

28 from lsst.afw.geom import Polygon as LegacyPolygon 

29 except ImportError: 

30 type LegacyPolygon = Any # type: ignore[no-redef] 

31 

32 

33class Region: 

34 """A 2-d Euclidean region represented as one or more polygons with 

35 optional holes. 

36 

37 Parameters 

38 ---------- 

39 geometry 

40 A polygon or multi-polygon from the Shapely library. 

41 """ 

42 

43 def __init__(self, geometry: shapely.Polygon | shapely.MultiPolygon): 

44 self._impl = geometry 

45 

46 @property 

47 def area(self) -> float: 

48 """The area of the region (`float`).""" 

49 return self._impl.area 

50 

51 @property 

52 def bbox(self) -> Box: 

53 """The integer-coordinate bounding box of the region (`Box`). 

54 

55 Because a `Box` logically contains the entirety of the pixels on its 

56 boundary, but the centers of those pixels are the numerical values of 

57 its bounds, the region may contain vertices that are up to 0.5 beyond 

58 the integer box coordinates in either dimension. 

59 """ 

60 x_min, y_min, x_max, y_max = self._impl.bounds 

61 return Box.factory[ 

62 round_half_up(y_min) : round_half_down(y_max) + 1, 

63 round_half_up(x_min) : round_half_down(x_max) + 1, 

64 ] 

65 

66 @property 

67 def wkt(self) -> str: 

68 """The 'Well-Known Text' representation of this region (`str`).""" 

69 return self._impl.wkt 

70 

71 @staticmethod 

72 def from_wkt(wkt: str) -> Region: 

73 """Construct from a 'Well-Known Text' string.""" 

74 impl = shapely.from_wkt(wkt) 

75 if not isinstance(impl, shapely.Polygon | shapely.MultiPolygon): 

76 raise ValueError("Only Polygon and MultiPolygon geometries can be converted to Regions.") 

77 return Region(impl).try_to_polygon() 

78 

79 def __str__(self) -> str: 

80 return self._impl.wkt 

81 

82 def __repr__(self) -> str: 

83 return f"Region.from_wkt({self._impl.wkt!r})" 

84 

85 def __eq__(self, other: object) -> bool: 

86 if isinstance(other, Region): 

87 return bool(shapely.equals(self._impl, other._impl)) 

88 return NotImplemented 

89 

90 @overload 

91 def contains(self, other: Polygon) -> bool: ... 91 ↛ exitline 91 didn't return from function 'contains' because

92 

93 @overload 

94 def contains(self, *, x: int, y: int) -> bool: ... 94 ↛ exitline 94 didn't return from function 'contains' because

95 

96 @overload 

97 def contains(self, *, x: float, y: float) -> bool: ... 97 ↛ exitline 97 didn't return from function 'contains' because

98 

99 @overload 

100 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 100 ↛ exitline 100 didn't return from function 'contains' because

101 

102 def contains( 

103 self, 

104 other: Region | None = None, 

105 *, 

106 x: float | int | np.ndarray | None = None, 

107 y: float | int | np.ndarray | None = None, 

108 ) -> bool | np.ndarray: 

109 """Test whether the geometry contains the given points or another 

110 geometry. 

111 

112 Parameters 

113 ---------- 

114 other 

115 Another geometry to compare to. Not compatible with the ``y`` and 

116 ``x`` arguments. 

117 x 

118 One or more floating-point or integer X coordinates to test for 

119 containment. If an array, an array of results will be returned. 

120 y 

121 One or more floating-point or integer Y coordinates to test for 

122 containment. If an array, an array of results will be returned. 

123 """ 

124 if other is not None: 

125 if x is not None or y is not None: 

126 raise TypeError("Too many arguments to 'Region.contains'.") 

127 return self._impl.contains(other._impl) 

128 elif x is None or y is None: 

129 raise TypeError("Not enough arguments to 'Region.contains'.") 

130 else: 

131 # Quibbles about bool vs numpy.bool_ as the return type. 

132 return shapely.contains_xy(self._impl, x=x, y=y) # type: ignore[return-value] 

133 

134 def intersection(self, other: Bounds) -> Bounds: 

135 """Compute the intersection of this region with a `Bounds` object. 

136 

137 Notes 

138 ----- 

139 Because `Region` implements the `Bounds` interface, its intersections 

140 need to support all other `Bounds` objects. This is not true of other 

141 `Region` point-set operations like `union` and `difference`. 

142 """ 

143 from ._concrete_bounds import _intersect_region 

144 

145 return _intersect_region(self, other) 

146 

147 def union(self, other: Region) -> Region: 

148 """Compute the point-set union of this region with another.""" 

149 impl = shapely.union(self._impl, other._impl) 

150 assert isinstance(impl, shapely.Polygon | shapely.MultiPolygon), ( 

151 "A union of Polygons and MultiPolygons should be one of those." 

152 ) 

153 return Region(impl).try_to_polygon() 

154 

155 def difference(self, other: Region) -> Region: 

156 """Compute the point-set difference of this region with another.""" 

157 impl = shapely.difference(self._impl, other._impl) 

158 assert isinstance(impl, shapely.Polygon | shapely.MultiPolygon), ( 

159 "A difference of Polygons and MultiPolygons should be one of those." 

160 ) 

161 return Region(impl).try_to_polygon() 

162 

163 def try_to_polygon(self) -> Region: 

164 """If the underlying geometry is a single polygon with no holes, 

165 return a `Polygon` instance holding it. 

166 

167 In all other cases ``self`` is returned. 

168 """ 

169 impl = self._impl 

170 if isinstance(impl, shapely.MultiPolygon) and len(impl.geoms) == 1: 

171 impl = impl.geoms[0] 

172 if isinstance(impl, shapely.Polygon) and not impl.interiors: 

173 vertices = np.array(impl.exterior.coords) 

174 return Polygon(x_vertices=vertices[:, 0], y_vertices=vertices[:, 1]) 

175 return self 

176 

177 def try_to_box(self) -> Region | Box: 

178 """If the underlying geometry is a rectangle that fully covers integer 

179 pixels (i.e. has all vertices at half-integer positions), return the 

180 equivalent `Box`. 

181 

182 In all other cases ``self`` is returned. 

183 """ 

184 if Polygon.from_box(self.bbox) == self: 

185 return self.bbox 

186 return self 

187 

188 def to_shapely(self) -> shapely.Polygon | shapely.MultiPolygon: 

189 """Convert to a `shapely.Polygon` or `shapely.MultiPolygon` object.""" 

190 return self._impl 

191 

192 def serialize(self) -> RegionSerializationModel: 

193 """Serialize the region to a Pydantic model. 

194 

195 Region serialization uses a subset of the GeoJSON specification (IETF 

196 RFC 7946). 

197 """ 

198 return RegionSerializationModel.model_validate_json(shapely.to_geojson(self._impl)) 

199 

200 

201class Polygon(Region): 

202 """A simple 2-d polygon in Euclidean coordinates, with no holes. 

203 

204 Parameters 

205 ---------- 

206 x_vertices 

207 The x coordinates of the vertices of the polygon. 

208 y_vertices 

209 The y coordinate of the vertices of the polygon. 

210 """ 

211 

212 def __init__(self, *, x_vertices: npt.ArrayLike, y_vertices: npt.ArrayLike): 

213 self._vertices = np.stack( 

214 [np.asarray(x_vertices).flat, np.asarray(y_vertices).flat], dtype=np.float64 

215 ).transpose() 

216 self._vertices.flags.writeable = False 

217 super().__init__(shapely.Polygon(self._vertices)) 

218 

219 @staticmethod 

220 def from_box(box: Box) -> Polygon: 

221 """Construct from an integer-coordinate box. 

222 

223 Notes 

224 ----- 

225 Because the integer min and max coordinates of the box are 

226 interpreted as pixel centers, these are expanded by 0.5 on all sides 

227 before using them to form the polygon vertices. 

228 """ 

229 return Polygon( 

230 x_vertices=[box.x.min - 0.5, box.x.min - 0.5, box.x.max + 0.5, box.x.max + 0.5], 

231 y_vertices=[box.y.min - 0.5, box.y.max + 0.5, box.y.max + 0.5, box.y.min - 0.5], 

232 ) 

233 

234 @property 

235 def n_vertices(self) -> int: 

236 """The number of vertices in the polygon.""" 

237 return self._vertices.shape[0] 

238 

239 @property 

240 def x_vertices(self) -> np.ndarray: 

241 """The x coordinates of the vertices of the polygon. 

242 

243 This is a read-only array; polygons are immutable. 

244 """ 

245 return self._vertices[:, 0] 

246 

247 @property 

248 def y_vertices(self) -> np.ndarray: 

249 """The y coordinates of the vertices of the polygon. 

250 

251 This is a read-only array; polygons are immutable. 

252 """ 

253 return self._vertices[:, 1] 

254 

255 def __repr__(self) -> str: 

256 return f"Polygon(x_vertices={self.x_vertices!r}, y_vertices={self.y_vertices!r})" 

257 

258 @staticmethod 

259 def from_legacy(legacy: LegacyPolygon) -> Polygon: 

260 """Convert from a legacy `lsst.afw.geom.Polygon` instance.""" 

261 vertices = legacy.getVertices() 

262 x_vertices = np.zeros(len(vertices), dtype=np.float64) 

263 y_vertices = np.zeros(len(vertices), dtype=np.float64) 

264 for n, point in enumerate(vertices): 

265 x_vertices[n] = point.x 

266 y_vertices[n] = point.y 

267 return Polygon(x_vertices=x_vertices, y_vertices=y_vertices) 

268 

269 def to_legacy(self) -> LegacyPolygon: 

270 """Convert to a legacy `lsst.afw.geom.Polygon` instance.""" 

271 from lsst.afw.geom import Polygon as LegacyPolygon 

272 from lsst.geom import Point2D 

273 

274 return LegacyPolygon([Point2D(x, y) for x, y in zip(self.x_vertices, self.y_vertices)]) 

275 

276 

277class RegionSerializationModel(pydantic.BaseModel): 

278 """Serialization model for `Region` and `Polygon`. 

279 

280 This model is a subset of the GeoJSON specification (IETF RFC 7946). 

281 """ 

282 

283 type: Literal["Polygon", "MultiPolygon"] = pydantic.Field(description="Geometry type.") 

284 

285 coordinates: list[list[tuple[float, float] | list[tuple[float, float]]]] = pydantic.Field( 

286 description="Vertices of the polygon or polygons." 

287 ) 

288 

289 def deserialize(self) -> Region: 

290 """Deserialize into a `Region` (a `Polygon`, if possible).""" 

291 region_impl = shapely.from_geojson(self.model_dump_json()) 

292 assert isinstance(region_impl, shapely.Polygon | shapely.MultiPolygon), ( 

293 "Other geometry types are not used." 

294 ) 

295 return Region(region_impl).try_to_polygon()