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

118 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-27 08:32 +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) -> None: 

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 

75 Parameters 

76 ---------- 

77 wkt 

78 Well-Known Text representation of the region. 

79 """ 

80 impl = shapely.from_wkt(wkt) 

81 if not isinstance(impl, shapely.Polygon | shapely.MultiPolygon): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

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

83 return Region(impl).try_to_polygon() 

84 

85 def __str__(self) -> str: 

86 return self._impl.wkt 

87 

88 def __repr__(self) -> str: 

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

90 

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

92 if isinstance(other, Region): 

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

94 return NotImplemented 

95 

96 @overload 

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

98 

99 @overload 

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

101 

102 @overload 

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

104 

105 @overload 

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

107 

108 def contains( 

109 self, 

110 other: Region | None = None, 

111 *, 

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

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

114 ) -> bool | np.ndarray: 

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

116 geometry. 

117 

118 Parameters 

119 ---------- 

120 other 

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

122 ``x`` arguments. 

123 x 

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

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

126 y 

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

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

129 """ 

130 if other is not None: 

131 if x is not None or y is not None: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

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

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

134 elif x is None or y is None: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true

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

136 else: 

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

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

139 

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

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

142 

143 Notes 

144 ----- 

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

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

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

148 

149 Parameters 

150 ---------- 

151 other 

152 Bounds to intersect with this region. 

153 """ 

154 from ._concrete_bounds import _intersect_region 

155 

156 return _intersect_region(self, other) 

157 

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

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

160 

161 Parameters 

162 ---------- 

163 other 

164 Region to union with this one. 

165 """ 

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

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

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

169 ) 

170 return Region(impl).try_to_polygon() 

171 

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

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

174 

175 Parameters 

176 ---------- 

177 other 

178 Region to subtract from this one. 

179 """ 

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

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

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

183 ) 

184 return Region(impl).try_to_polygon() 

185 

186 def try_to_polygon(self) -> Region: 

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

188 return a `Polygon` instance holding it. 

189 

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

191 """ 

192 impl = self._impl 

193 if isinstance(impl, shapely.MultiPolygon) and len(impl.geoms) == 1: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 impl = impl.geoms[0] 

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

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

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

198 return self 

199 

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

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

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

203 equivalent `Box`. 

204 

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

206 """ 

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

208 return self.bbox 

209 return self 

210 

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

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

213 return self._impl 

214 

215 def serialize(self) -> RegionSerializationModel: 

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

217 

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

219 RFC 7946). 

220 """ 

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

222 

223 

224class Polygon(Region): 

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

226 

227 Parameters 

228 ---------- 

229 x_vertices 

230 The x coordinates of the vertices of the polygon. 

231 y_vertices 

232 The y coordinate of the vertices of the polygon. 

233 """ 

234 

235 def __init__(self, *, x_vertices: npt.ArrayLike, y_vertices: npt.ArrayLike) -> None: 

236 self._vertices = np.stack( 

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

238 ).transpose() 

239 self._vertices.flags.writeable = False 

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

241 

242 @staticmethod 

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

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

245 

246 Parameters 

247 ---------- 

248 box 

249 Integer-coordinate box to convert to a polygon. 

250 

251 Notes 

252 ----- 

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

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

255 before using them to form the polygon vertices. 

256 """ 

257 return Polygon( 

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

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

260 ) 

261 

262 @property 

263 def n_vertices(self) -> int: 

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

265 return self._vertices.shape[0] 

266 

267 @property 

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

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

270 

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

272 """ 

273 return self._vertices[:, 0] 

274 

275 @property 

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

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

278 

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

280 """ 

281 return self._vertices[:, 1] 

282 

283 def __repr__(self) -> str: 

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

285 

286 @staticmethod 

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

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

289 

290 Parameters 

291 ---------- 

292 legacy 

293 Legacy `lsst.afw.geom.Polygon` to convert. 

294 """ 

295 vertices = legacy.getVertices() 

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

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

298 for n, point in enumerate(vertices): 

299 x_vertices[n] = point.x 

300 y_vertices[n] = point.y 

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

302 

303 def to_legacy(self) -> LegacyPolygon: 

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

305 from lsst.afw.geom import Polygon as LegacyPolygon 

306 from lsst.geom import Point2D 

307 

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

309 

310 

311class RegionSerializationModel(pydantic.BaseModel): 

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

313 

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

315 """ 

316 

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

318 

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

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

321 ) 

322 

323 def deserialize(self) -> Region: 

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

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

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

327 "Other geometry types are not used." 

328 ) 

329 return Region(region_impl).try_to_polygon()