Coverage for tests/test_polygon.py: 98%

112 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-01 09:14 +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 

14import unittest 

15 

16import astropy.units as u 

17import numpy as np 

18import pydantic 

19 

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 

31 

32try: 

33 import lsst.afw.geom # noqa: F401 

34 

35 have_legacy = True 

36except ImportError: 

37 have_legacy = False 

38 

39 

40class SimplePolygonTestCase(unittest.TestCase): 

41 """Tests for the Polygon class. 

42 

43 This includes most of the test coverage for the Region base class. 

44 """ 

45 

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) 

51 

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 

61 

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)) 

81 

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) 

94 

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 ) 

104 

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) 

116 

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 ) 

127 

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) 

134 

135 

136class RegionTestCase(unittest.TestCase): 

137 """Tests for `Region` objects that are not necessarily polygons, including 

138 point-set operations. 

139 

140 Notes 

141 ----- 

142 This test uses test geometries (all boxes) with the following rough layout 

143 (with y increasing upwards): 

144 

145 .. _code-block:: 

146 ┌─────┐ 

147 ┌───┼─┐B┌─┼────┐ 

148 │ A└─┼─┼─┘ ┌─┐│ 

149 └─────┘ │ C │D││ 

150 │ └─┘│ 

151 └──────┘ 

152 """ 

153 

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]) 

159 

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 ) 

178 

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) 

189 

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) 

203 

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) 

215 

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 ) 

225 

226 

227class _PolygonHolder(pydantic.BaseModel): 

228 polygon: Polygon 

229 

230 

231class _RegionHolder(pydantic.BaseModel): 

232 region: Region 

233 

234 

235if __name__ == "__main__": 

236 unittest.main()