Coverage for python/lsst/images/_polygon.py: 49%
162 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 10:20 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-06 10:20 +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.
12from __future__ import annotations
14__all__ = ("Polygon", "Region", "RegionDomainError", "RegionSerializationModel")
16from typing import TYPE_CHECKING, Any, Literal, assert_type, overload
18import numpy as np
19import numpy.typing as npt
20import pydantic
21import pydantic_core.core_schema as pcs
22import shapely
23from pydantic.json_schema import JsonSchemaValue
25from ._geom import XY, YX, Bounds, Box
26from .describe import DescribableMixin, DescribeOptions, Report, ReportField
27from .utils import round_half_down, round_half_up
29if TYPE_CHECKING:
30 from ._geom import Bounds
31 from ._transforms import Transform
33 try:
34 from lsst.afw.geom import Polygon as LegacyPolygon
35 except ImportError:
36 type LegacyPolygon = Any # type: ignore[no-redef]
39class RegionDomainError(ValueError):
40 """Exception raised when a region is transformed to non-finite
41 coordinates, i.e. it lies outside the domain of the transform.
42 """
45class Region(DescribableMixin):
46 """A 2-d Euclidean region represented as one or more polygons with
47 optional holes.
49 Parameters
50 ----------
51 geometry
52 A polygon or multi-polygon from the Shapely library.
53 """
55 def __init__(self, geometry: shapely.Polygon | shapely.MultiPolygon) -> None:
56 self._impl = geometry
58 @property
59 def area(self) -> float:
60 """The area of the region (`float`)."""
61 return self._impl.area
63 @property
64 def bbox(self) -> Box:
65 """The integer-coordinate bounding box of the region (`Box`).
67 Because a `Box` logically contains the entirety of the pixels on its
68 boundary, but the centers of those pixels are the numerical values of
69 its bounds, the region may contain vertices that are up to 0.5 beyond
70 the integer box coordinates in either dimension.
71 """
72 x_min, y_min, x_max, y_max = self._impl.bounds
73 return Box.factory[
74 round_half_up(y_min) : round_half_down(y_max) + 1,
75 round_half_up(x_min) : round_half_down(x_max) + 1,
76 ]
78 @property
79 def wkt(self) -> str:
80 """The 'Well-Known Text' representation of this region (`str`)."""
81 return self._impl.wkt
83 @staticmethod
84 def from_wkt(wkt: str) -> Region:
85 """Construct from a 'Well-Known Text' string.
87 Parameters
88 ----------
89 wkt
90 Well-Known Text representation of the region.
91 """
92 impl = shapely.from_wkt(wkt)
93 if not isinstance(impl, shapely.Polygon | shapely.MultiPolygon): 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 raise ValueError("Only Polygon and MultiPolygon geometries can be converted to Regions.")
95 return Region(impl).try_to_polygon()
97 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
98 """Return a `Report` describing this region.
100 Parameters
101 ----------
102 options : `DescribeOptions`, optional
103 Unused; accepted for interface compatibility.
104 """
105 return Report(
106 type_name="Region",
107 summary=self._impl.wkt,
108 fields=[
109 ReportField(
110 label="wkt",
111 value=self._impl.wkt,
112 repr_value=repr(self._impl.wkt),
113 positional=True,
114 )
115 ],
116 )
118 def __repr__(self) -> str:
119 return f"Region.from_wkt({self._impl.wkt!r})"
121 def __eq__(self, other: object) -> bool:
122 if isinstance(other, Region):
123 return bool(shapely.equals(self._impl, other._impl))
124 return NotImplemented
126 @overload
127 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
129 @overload
130 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
132 @overload
133 def contains(self, /, *, x: int | float, y: int | float) -> bool: ...
135 @overload
136 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ...
138 @overload
139 def contains(self, other: Polygon, /) -> bool: ...
141 def contains(
142 self,
143 other: Region | XY[Any] | YX[Any] | None = None,
144 /,
145 *,
146 x: Any = None,
147 y: Any = None,
148 ) -> bool | np.ndarray:
149 """Test whether the geometry contains the given points or another
150 geometry.
152 Parameters
153 ----------
154 other
155 Another geometry, or an `XY` or `YX` coordinate pair, to compare
156 to. Mutually exclusive with ``x`` and ``y``.
157 x
158 One or more X coordinates to test for containment, as a scalar or
159 any array-like. Results are broadcast against ``y``.
160 Mutually exclusive with ``other``.
161 y
162 One or more Y coordinates to test for containment, as a scalar or
163 any array-like. Results are broadcast against ``x``.
164 Mutually exclusive with ``other``.
165 """
166 match other:
167 case None:
168 if x is None or y is None: 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 raise TypeError("Pass either a point or both x= and y= to 'Region.contains'.")
170 case Region():
171 if x is not None or y is not None: 171 ↛ 172line 171 didn't jump to line 172 because the condition on line 171 was never true
172 raise TypeError("'Region.contains' point argument is mutually exclusive with x= and y=.")
173 return self._impl.contains(other._impl)
174 case XY() | YX(): 174 ↛ 178line 174 didn't jump to line 178 because the pattern on line 174 always matched
175 if x is not None or y is not None:
176 raise TypeError("'Region.contains' point argument is mutually exclusive with x= and y=.")
177 x, y = other.x, other.y
178 case _:
179 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
180 return shapely.contains_xy(self._impl, x=x, y=y)
182 @overload
183 def intersection(self, other: Region) -> Region: ...
185 @overload
186 def intersection(self, other: Box) -> Region | Box: ...
188 @overload
189 def intersection(self, other: Bounds) -> Bounds: ...
191 def intersection(self, other: Bounds) -> Bounds:
192 """Compute the intersection of this region with a `Bounds` object.
194 Notes
195 -----
196 Because `Region` implements the `Bounds` interface, its intersections
197 need to support all other `Bounds` objects. This is not true of other
198 `Region` point-set operations like `union` and `difference`.
200 Parameters
201 ----------
202 other
203 Bounds to intersect with this region.
204 """
205 from ._concrete_bounds import _intersect_region
207 return _intersect_region(self, other)
209 def union(self, other: Region) -> Region:
210 """Compute the point-set union of this region with another.
212 Parameters
213 ----------
214 other
215 Region to union with this one.
216 """
217 impl = shapely.union(self._impl, other._impl)
218 assert isinstance(impl, shapely.Polygon | shapely.MultiPolygon), (
219 "A union of Polygons and MultiPolygons should be one of those."
220 )
221 return Region(impl).try_to_polygon()
223 def difference(self, other: Region) -> Region:
224 """Compute the point-set difference of this region with another.
226 Parameters
227 ----------
228 other
229 Region to subtract from this one.
230 """
231 impl = shapely.difference(self._impl, other._impl)
232 assert isinstance(impl, shapely.Polygon | shapely.MultiPolygon), (
233 "A difference of Polygons and MultiPolygons should be one of those."
234 )
235 return Region(impl).try_to_polygon()
237 def try_to_polygon(self) -> Region:
238 """If the underlying geometry is a single polygon with no holes,
239 return a `Polygon` instance holding it.
241 In all other cases ``self`` is returned.
242 """
243 impl = self._impl
244 if isinstance(impl, shapely.MultiPolygon) and len(impl.geoms) == 1: 244 ↛ 245line 244 didn't jump to line 245 because the condition on line 244 was never true
245 impl = impl.geoms[0]
246 if isinstance(impl, shapely.Polygon) and not impl.interiors:
247 vertices = np.array(impl.exterior.coords)
248 return Polygon(x_vertices=vertices[:, 0], y_vertices=vertices[:, 1])
249 return self
251 def try_to_box(self) -> Region | Box:
252 """If the underlying geometry is a rectangle that fully covers integer
253 pixels (i.e. has all vertices at half-integer positions), return the
254 equivalent `Box`.
256 In all other cases ``self`` is returned.
257 """
258 if Polygon.from_box(self.bbox) == self:
259 return self.bbox
260 return self
262 def to_shapely(self) -> shapely.Polygon | shapely.MultiPolygon:
263 """Convert to a `shapely.Polygon` or `shapely.MultiPolygon` object."""
264 return self._impl
266 def transform(self, transform: Transform[Any, Any]) -> Region:
267 """Transform the coordinates of the region, returning a new one.
269 Parameters
270 ----------
271 transform
272 Coordinate transform to apply (in the forward direction).
274 Returns
275 -------
276 `Region`
277 The transformed region, as a `Polygon` if it is a single polygon
278 with no holes.
280 Raises
281 ------
282 RegionDomainError
283 Raised if any transformed vertex is not finite, as happens when
284 the region lies outside the domain of a projection.
286 Notes
287 -----
288 This applies the transform to all vertices, assuming that the
289 transform is close enough to affine that the topology of the geometry
290 does not change and straight-line edges do not need to be subsampled.
291 """
293 def wrapper(x: np.ndarray, y: np.ndarray) -> XY[np.ndarray]:
294 result = transform.apply_forward(x=x, y=y)
295 if not (np.isfinite(result.x).all() and np.isfinite(result.y).all()):
296 raise RegionDomainError(f"Region {self} transforms to non-finite coordinates.")
297 return result
299 return Region(
300 # Shapely overloads don't seem to have been annotated rigorously
301 shapely.transform(self._impl, wrapper, interleaved=False) # type: ignore[arg-type]
302 ).try_to_polygon()
304 def serialize(self) -> RegionSerializationModel:
305 """Serialize the region to a Pydantic model.
307 Region serialization uses a subset of the GeoJSON specification (IETF
308 RFC 7946).
309 """
310 return RegionSerializationModel.model_validate_json(shapely.to_geojson(self._impl))
312 @classmethod
313 def __get_pydantic_core_schema__(
314 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
315 ) -> pcs.CoreSchema:
316 from_model_schema = pcs.chain_schema(
317 [
318 handler(RegionSerializationModel),
319 pcs.no_info_plain_validator_function(RegionSerializationModel.deserialize),
320 ]
321 )
322 return pcs.json_or_python_schema(
323 json_schema=from_model_schema,
324 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), from_model_schema]),
325 serialization=pcs.plain_serializer_function_ser_schema(cls.serialize),
326 )
328 @classmethod
329 def __get_pydantic_json_schema__(
330 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
331 ) -> JsonSchemaValue:
332 return handler(RegionSerializationModel.__pydantic_core_schema__)
335class Polygon(Region):
336 """A simple 2-d polygon in Euclidean coordinates, with no holes.
338 Parameters
339 ----------
340 x_vertices
341 The x coordinates of the vertices of the polygon.
342 y_vertices
343 The y coordinate of the vertices of the polygon.
344 """
346 def __init__(self, *, x_vertices: npt.ArrayLike, y_vertices: npt.ArrayLike) -> None:
347 self._vertices = np.stack(
348 [np.asarray(x_vertices).flat, np.asarray(y_vertices).flat], dtype=np.float64
349 ).transpose()
350 self._vertices.flags.writeable = False
351 super().__init__(shapely.Polygon(self._vertices))
353 @staticmethod
354 def from_box(box: Box) -> Polygon:
355 """Construct from an integer-coordinate box.
357 Parameters
358 ----------
359 box
360 Integer-coordinate box to convert to a polygon.
362 Notes
363 -----
364 Because the integer min and max coordinates of the box are
365 interpreted as pixel centers, these are expanded by 0.5 on all sides
366 before using them to form the polygon vertices.
367 """
368 return Polygon(
369 x_vertices=[box.x.min - 0.5, box.x.min - 0.5, box.x.max + 0.5, box.x.max + 0.5],
370 y_vertices=[box.y.min - 0.5, box.y.max + 0.5, box.y.max + 0.5, box.y.min - 0.5],
371 )
373 @property
374 def n_vertices(self) -> int:
375 """The number of vertices in the polygon."""
376 return self._vertices.shape[0]
378 @property
379 def x_vertices(self) -> np.ndarray:
380 """The x coordinates of the vertices of the polygon.
382 This is a read-only array; polygons are immutable.
383 """
384 return self._vertices[:, 0]
386 @property
387 def y_vertices(self) -> np.ndarray:
388 """The y coordinates of the vertices of the polygon.
390 This is a read-only array; polygons are immutable.
391 """
392 return self._vertices[:, 1]
394 @property
395 def centroid(self) -> XY[float]:
396 """The centroid of the polygon (`XY` [`float`])."""
397 c = self._impl.centroid
398 return XY(x=c.x, y=c.y)
400 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report:
401 """Return a `Report` describing this polygon.
403 Parameters
404 ----------
405 options : `DescribeOptions`, optional
406 Unused; accepted for interface compatibility.
407 """
408 return Report(
409 type_name="Polygon",
410 summary=self._impl.wkt,
411 fields=[
412 ReportField(label="x_vertices", value=self.x_vertices, repr_value=repr(self.x_vertices)),
413 ReportField(label="y_vertices", value=self.y_vertices, repr_value=repr(self.y_vertices)),
414 ],
415 )
417 def __repr__(self) -> str:
418 return f"Polygon(x_vertices={self.x_vertices!r}, y_vertices={self.y_vertices!r})"
420 def transform(self, transform: Transform[Any, Any]) -> Polygon:
421 # Docstring inherited.
422 result = super().transform(transform)
423 assert isinstance(result, Polygon), "Transforming a polygon should not change its topology."
424 return result
426 @staticmethod
427 def from_legacy(legacy: LegacyPolygon) -> Polygon:
428 """Convert from a legacy `lsst.afw.geom.Polygon` instance.
430 Parameters
431 ----------
432 legacy
433 Legacy `lsst.afw.geom.Polygon` to convert.
434 """
435 vertices = legacy.getVertices()
436 x_vertices = np.zeros(len(vertices), dtype=np.float64)
437 y_vertices = np.zeros(len(vertices), dtype=np.float64)
438 for n, point in enumerate(vertices):
439 x_vertices[n] = point.x
440 y_vertices[n] = point.y
441 return Polygon(x_vertices=x_vertices, y_vertices=y_vertices)
443 def to_legacy(self) -> LegacyPolygon:
444 """Convert to a legacy `lsst.afw.geom.Polygon` instance."""
445 from lsst.afw.geom import Polygon as LegacyPolygon
446 from lsst.geom import Point2D
448 return LegacyPolygon([Point2D(x, y) for x, y in zip(self.x_vertices, self.y_vertices)])
451class RegionSerializationModel(pydantic.BaseModel):
452 """Serialization model for `Region` and `Polygon`.
454 This model is a subset of the GeoJSON specification (IETF RFC 7946).
455 """
457 type: Literal["Polygon", "MultiPolygon"] = pydantic.Field(description="Geometry type.")
459 coordinates: list[list[tuple[float, float] | list[tuple[float, float]]]] = pydantic.Field(
460 description="Vertices of the polygon or polygons."
461 )
463 def deserialize(self) -> Region:
464 """Deserialize into a `Region` (a `Polygon`, if possible)."""
465 region_impl = shapely.from_geojson(self.model_dump_json())
466 assert isinstance(region_impl, shapely.Polygon | shapely.MultiPolygon), (
467 "Other geometry types are not used."
468 )
469 return Region(region_impl).try_to_polygon()
472if TYPE_CHECKING:
474 def _test_types() -> None:
475 region = Region(shapely.Point(0, 0).buffer(1.0))
476 arr = np.zeros(3)
478 # Region satisfies the Bounds Protocol.
479 bounds: Bounds = region
481 # Region.contains: XY/YX, scalar, array-like
482 assert_type(region.contains(x=1.0, y=2.0), bool)
483 assert_type(region.contains(x=arr, y=arr), np.ndarray)
484 assert_type(region.contains(XY(1.0, 2.0)), bool)
485 assert_type(region.contains(YX(2.0, 1.0)), bool)
486 assert_type(region.contains(XY(arr, arr)), np.ndarray)
487 assert_type(region.contains(YX(arr, arr)), np.ndarray)
489 # Via the Bounds Protocol view, same signatures hold.
490 assert_type(bounds.contains(x=1, y=1), bool)
491 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
492 assert_type(bounds.contains(XY(1, 1)), bool)
493 assert_type(bounds.contains(YX(1, 1)), bool)
494 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
495 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)