Coverage for tests/test_polygon.py: 98%
112 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 17:19 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-04 17:19 +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
14import unittest
16import astropy.units as u
17import numpy as np
18import pydantic
20from lsst.images import (
21 XY,
22 Box,
23 GeneralFrame,
24 NoOverlapError,
25 Polygon,
26 Region,
27 RegionSerializationModel,
28 Transform,
29)
30from lsst.images.tests import assert_close
32try:
33 import lsst.afw.geom # noqa: F401
35 have_legacy = True
36except ImportError:
37 have_legacy = False
40class SimplePolygonTestCase(unittest.TestCase):
41 """Tests for the Polygon class.
43 This includes most of the test coverage for the Region base class.
44 """
46 def setUp(self) -> None:
47 # A quadrilateral that's almost a box, so it's easy to reason about.
48 self.x_vertices = [32.0, 31.0, 50.0, 53.0]
49 self.y_vertices = [-5.0, 7.0, 7.2, -4.8]
50 self.polygon = Polygon(x_vertices=self.x_vertices, y_vertices=self.y_vertices)
52 def test_vertices(self) -> None:
53 """Test the vertices accessors."""
54 self.assertEqual(self.polygon.n_vertices, 4)
55 np.testing.assert_array_equal(self.polygon.x_vertices, np.asarray(self.x_vertices))
56 np.testing.assert_array_equal(self.polygon.y_vertices, np.asarray(self.y_vertices))
57 with self.assertRaises(ValueError):
58 self.polygon.x_vertices[0] = 0.0
59 with self.assertRaises(ValueError):
60 self.polygon.y_vertices[0] = 0.0
62 def test_boxes(self) -> None:
63 """Test 'from_box', the `area` property, and the 'contains' method
64 with polygon arguments.
65 """
66 small = Polygon.from_box(Box.factory[-3:3, 40:45])
67 self.assertEqual(small.area, 30.0)
68 self.assertEqual(small.bbox, Box.factory[-3:3, 40:45])
69 self.assertTrue(self.polygon.contains(small))
70 self.assertFalse(small.contains(self.polygon))
71 self.assertEqual(small.centroid, XY(x=42.0, y=-0.5))
72 big = Polygon.from_box(Box.factory[-10:10, 20:60])
73 self.assertEqual(big.area, 800.0)
74 self.assertFalse(self.polygon.contains(big))
75 self.assertTrue(big.contains(self.polygon))
76 medium = Polygon.from_box(Box.factory[-4:8, 31:52])
77 self.assertEqual(medium.area, 252.0)
78 self.assertFalse(self.polygon.contains(medium))
79 self.assertFalse(medium.contains(self.polygon))
80 self.assertTrue(self.polygon.contains(self.polygon))
82 def test_transform(self) -> None:
83 """Test applying a coordinate transform to a polygon."""
84 matrix = np.array([[0.4, 0.25], [-0.20, 0.6]])
85 t = Transform.affine(GeneralFrame(unit=u.pix), GeneralFrame(unit=u.pix), matrix)
86 tp = self.polygon.transform(t)
87 self.assertIsInstance(tp, Polygon)
88 assert_close(self, tp.area, self.polygon.area * np.linalg.det(matrix))
89 xyt = t.apply_forward(x=self.polygon.x_vertices, y=self.polygon.y_vertices)
90 # Slicing below is because shapely sometimes adds a duplicate closing
91 # vertex.
92 assert_close(self, tp.x_vertices[: len(xyt.x)], xyt.x)
93 assert_close(self, tp.y_vertices[: len(xyt.y)], xyt.y)
95 def test_contains_points(self) -> None:
96 """Test the 'contains' method with points."""
97 self.assertTrue(self.polygon.contains(x=40.0, y=0.0))
98 self.assertFalse(self.polygon.contains(x=0.0, y=0.0))
99 self.assertFalse(self.polygon.contains(x=40.0, y=10.0))
100 np.testing.assert_array_equal(
101 self.polygon.contains(x=np.array([40.0, 0.0, 40.0]), y=np.array([0.0, 0.0, 10.0])),
102 np.array([True, False, False]),
103 )
105 def test_io(self) -> None:
106 """Test serialization and stringification."""
107 self.assertEqual(
108 RegionSerializationModel.model_validate_json(
109 self.polygon.serialize().model_dump_json()
110 ).deserialize(),
111 self.polygon,
112 )
113 self.assertEqual(Polygon.from_wkt(self.polygon.wkt), self.polygon)
114 self.assertEqual(Polygon.from_wkt(str(self.polygon)), self.polygon)
115 self.assertEqual(eval(repr(self.polygon), {"array": np.array, "Polygon": Polygon}), self.polygon)
117 def test_model_field(self) -> None:
118 """Test that we can use a Polygon directly as a Pydantic model
119 field.
120 """
121 holder = _PolygonHolder(polygon=self.polygon)
122 self.assertEqual(self.polygon, holder.model_validate_json(holder.model_dump_json()).polygon)
123 self.assertEqual(
124 _PolygonHolder.model_json_schema()["properties"]["polygon"],
125 RegionSerializationModel.model_json_schema(),
126 )
128 @unittest.skipUnless(have_legacy, "lsst legacy packages could not be imported.")
129 def test_legacy(self) -> None:
130 """Test conversion to/from lsst.afw.geom.Polygon."""
131 legacy_polygon = self.polygon.to_legacy()
132 self.assertEqual(legacy_polygon.calculateArea(), self.polygon.area)
133 self.assertEqual(Polygon.from_legacy(legacy_polygon), self.polygon)
136class RegionTestCase(unittest.TestCase):
137 """Tests for `Region` objects that are not necessarily polygons, including
138 point-set operations.
140 Notes
141 -----
142 This test uses test geometries (all boxes) with the following rough layout
143 (with y increasing upwards):
145 .. _code-block::
146 ┌─────┐
147 ┌───┼─┐B┌─┼────┐
148 │ A└─┼─┼─┘ ┌─┐│
149 └─────┘ │ C │D││
150 │ └─┘│
151 └──────┘
152 """
154 def setUp(self) -> None:
155 self.a = Polygon.from_box(Box.factory[3:6, 0:5])
156 self.b = Polygon.from_box(Box.factory[4:7, 3:8])
157 self.c = Polygon.from_box(Box.factory[0:6, 6:12])
158 self.d = Polygon.from_box(Box.factory[1:4, 9:10])
160 def test_intersection(self) -> None:
161 """Test region intersection."""
162 # Usual case:
163 self.assertEqual(self.a.intersection(self.b), Polygon.from_box(Box.factory[4:6, 3:5]))
164 # No-overlap case:
165 with self.assertRaises(NoOverlapError):
166 self.a.intersection(self.c)
167 # LHS fully contains RHS:
168 self.assertEqual(self.c.intersection(self.d), self.d)
169 # Intersections with the boxes themselves should return boxes when
170 # possible.
171 self.assertEqual(self.a.intersection(self.b.bbox), Box.factory[4:6, 3:5])
172 self.assertEqual(self.a.bbox.intersection(self.b), Box.factory[4:6, 3:5])
173 self.assertEqual(
174 # A Box is not possible when the result is not simple.
175 self.a.union(self.c).intersection(self.b.bbox),
176 self.a.union(self.c).intersection(self.b),
177 )
179 def test_union(self) -> None:
180 """Test region union."""
181 # Usual case:
182 self.assertEqual(self.a.union(self.b).bbox, Box.factory[3:7, 0:8])
183 self.assertEqual(self.a.union(self.b).area, 15 + 15 - 4)
184 # Operands are disjoint, so union is not a single Polygon:
185 self.assertNotIsInstance(self.a.union(self.c), Polygon)
186 self.assertEqual(self.a.union(self.c).area, self.a.area + self.c.area)
187 # LHS fully contains RHS:
188 self.assertEqual(self.c.union(self.d), self.c)
190 def test_difference(self) -> None:
191 """Test region difference."""
192 # Usual case:
193 self.assertEqual(self.a.difference(self.b).bbox, self.a.bbox)
194 self.assertEqual(self.a.difference(self.b).area, 15 - 4)
195 # Operands are disjoint, so difference is just the LHS.
196 self.assertEqual(self.a.difference(self.c), self.a)
197 # LHS fully contains RHS -> polygon with hole is not a Polygon:
198 self.assertNotIsInstance(self.c.difference(self.d), Polygon)
199 self.assertEqual(self.c.difference(self.d).bbox, self.c.bbox)
200 self.assertEqual(self.c.difference(self.d).area, self.c.area - self.d.area)
201 # RHS fully contains LHS -> region is empty.
202 self.assertEqual(self.d.difference(self.d).area, 0)
204 def test_io(self) -> None:
205 """Test serialization and stringification of non-polygon regions."""
206 # A two-polygon region with a hole:
207 region = self.a.union(self.c).difference(self.d)
208 self.assertEqual(
209 RegionSerializationModel.model_validate_json(region.serialize().model_dump_json()).deserialize(),
210 region,
211 )
212 self.assertEqual(Region.from_wkt(region.wkt), region)
213 self.assertEqual(Region.from_wkt(str(region)), region)
214 self.assertEqual(eval(repr(region), {"Region": Region}), region)
216 def test_model_field(self) -> None:
217 """Test that we can use a Region directly as a Pydantic model field."""
218 region = self.a.union(self.c).difference(self.d)
219 holder = _RegionHolder(region=region)
220 self.assertEqual(region, holder.model_validate_json(holder.model_dump_json()).region)
221 self.assertEqual(
222 _RegionHolder.model_json_schema()["properties"]["region"],
223 RegionSerializationModel.model_json_schema(),
224 )
227class _PolygonHolder(pydantic.BaseModel):
228 polygon: Polygon
231class _RegionHolder(pydantic.BaseModel):
232 region: Region
235if __name__ == "__main__":
236 unittest.main()