Coverage for tests/test_geom.py: 100%

337 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -0700

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 pickle 

15from typing import get_overloads, get_type_hints 

16 

17import numpy as np 

18import pydantic 

19import pytest 

20 

21from lsst.images import XY, YX, Bounds, Box, Interval, NoOverlapError 

22from lsst.images.tests import assert_close, check_bounds_contains_broadcasting 

23 

24 

25class IntervalModel(pydantic.BaseModel): 

26 """Test Pydantic model with an Interval.""" 

27 

28 interval1: Interval 

29 interval2: Interval 

30 

31 

32class BoxModel(pydantic.BaseModel): 

33 """Test Pydantic model with a box.""" 

34 

35 box: Box 

36 

37 

38def _assert_type_hints_resolve(obj: object) -> None: 

39 """Assert that runtime annotations and overload annotations resolve.""" 

40 get_type_hints(obj) 

41 for overload in get_overloads(obj): 

42 get_type_hints(overload) 

43 

44 

45def test_yx() -> None: 

46 """Test YX.""" 

47 yx = YX(5, 7) 

48 assert yx == (5, 7) 

49 assert yx.y == 5 

50 assert yx.x == 7 

51 

52 def _plus_one(v: int) -> int: 

53 return v + 1 

54 

55 new = yx.map(_plus_one) 

56 assert new == (6, 8) 

57 

58 xy = yx.xy 

59 assert xy == (7, 5) 

60 

61 

62def test_xy() -> None: 

63 """Test XY.""" 

64 xy = XY(5, 7) 

65 assert xy == (5, 7) 

66 assert xy.y == 7 

67 assert xy.x == 5 

68 

69 def _plus_one(v: int) -> int: 

70 return v + 1 

71 

72 new = xy.map(_plus_one) 

73 assert new == (6, 8) 

74 

75 yx = xy.yx 

76 assert yx == (7, 5) 

77 

78 

79def test_interval_constructor() -> None: 

80 """Test simple Interval construction and arithmetic.""" 

81 i = Interval(start=1, stop=10) 

82 assert i.start == 1 

83 assert i.stop == 10 

84 assert i.min == 1 

85 assert i.max == 9 

86 assert i.size == 9 

87 assert i.center == 5.0 

88 assert Interval(1, 10) == i 

89 

90 assert str(i) == "1:10" 

91 

92 shifted = i + 10 

93 assert shifted.start == 11 

94 assert shifted.stop == 20 

95 

96 shifted = i - 10 

97 assert shifted.start == -9 

98 assert shifted.stop == 0 

99 

100 assert shifted == i - 10 

101 assert shifted != i 

102 

103 sized = Interval.from_size(10) 

104 assert sized == Interval(0, 10) 

105 sized = Interval.from_size(10, 5) 

106 assert sized == Interval(5, 15) 

107 

108 h = Interval.hull(2, -1, 3, 6) 

109 assert h == Interval(start=-1, stop=7) 

110 h2 = Interval.hull(h, 3, 40, Interval(start=-10, stop=2)) 

111 assert h2 == Interval(start=-10, stop=41) 

112 

113 

114def test_interval_contains() -> None: 

115 """Test containment.""" 

116 i = Interval(start=1, stop=10) 

117 assert 5 in i 

118 assert 10 not in i 

119 

120 i2 = Interval(start=2, stop=4) 

121 assert i.contains(i2) 

122 

123 i3 = Interval(start=-1, stop=5) 

124 assert not i.contains(i3) 

125 

126 assert i3.contains(2.5) 

127 

128 containment = i3.contains(np.array([-2, -1, 0, 1, 2, 3, 4, 5, 6])) 

129 assert list(containment) == [False, True, True, True, True, True, True, False, False] 

130 

131 inter = i2.intersection(i) 

132 assert inter == Interval(start=2, stop=4), f"Intersection of {i2} with {i}" 

133 with pytest.raises(NoOverlapError): 

134 i.intersection(Interval.factory[20:30]) 

135 assert i != [] 

136 

137 

138def test_interval_contains_broadcasting() -> None: 

139 """Test that Interval.contains accepts array-like inputs and broadcasts.""" 

140 interval = Interval(start=2, stop=8) 

141 # One point outside each end, one on each boundary, one in the interior. 

142 xs = np.array( 

143 [ 

144 interval.start - 1, 

145 interval.start, 

146 (interval.start + interval.stop) // 2, 

147 interval.stop - 1, 

148 interval.stop, 

149 ] 

150 ) 

151 expected_1d = np.array([interval.contains(int(x)) for x in xs]) 

152 # 1-D ndarray. 

153 np.testing.assert_array_equal(interval.contains(xs), expected_1d) 

154 # list input (array-like). 

155 np.testing.assert_array_equal(interval.contains(xs.tolist()), expected_1d) 

156 # 2-D array: same values reshaped to (len, 1). 

157 np.testing.assert_array_equal(interval.contains(xs.reshape(-1, 1)), expected_1d.reshape(-1, 1)) 

158 

159 

160def test_runtime_type_hints_resolve() -> None: 

161 """Public geometry annotations can be resolved at runtime.""" 

162 _assert_type_hints_resolve(Interval.contains) 

163 _assert_type_hints_resolve(Box.contains) 

164 _assert_type_hints_resolve(Bounds.contains) 

165 

166 

167def test_interval_slice() -> None: 

168 """Test Interval construction and subsetting with slice notation.""" 

169 i = Interval.factory[3:20] 

170 assert i.start == 3 

171 assert i.stop == 20 

172 assert i.absolute[::] == i 

173 assert i.local[::] == i 

174 

175 subset = i.absolute[5:10] 

176 assert subset.start == 5 

177 assert subset.stop == 10 

178 

179 subset = i.local[5:10] 

180 assert subset.start == 8 

181 assert subset.stop == 13 

182 

183 subset = i.absolute[:10] 

184 assert subset.start == 3 

185 assert subset.stop == 10 

186 

187 subset = i.local[:10] 

188 assert subset.start == 3 

189 assert subset.stop == 13 

190 

191 subset = i.absolute[10:] 

192 assert subset.start == 10 

193 assert subset.stop == 20 

194 

195 subset = i.local[10:] 

196 assert subset.start == 13 

197 assert subset.stop == 20 

198 

199 subset = i.local[3:-2] 

200 assert subset.start == 6 

201 assert subset.stop == 18 

202 

203 subset = i.local[-5:-2] 

204 assert subset.start == 15 

205 assert subset.stop == 18 

206 

207 with pytest.raises(IndexError): 

208 i.absolute[:30] 

209 

210 # It might seem surprising that this does not raise, but it's exactly 

211 # what what list(range(3, 20))[:30] does: 

212 subset = i.local[:30] 

213 assert subset.start == 3 

214 assert subset.stop == 20 

215 

216 with pytest.raises(IndexError): 

217 i.absolute[30:] 

218 

219 with pytest.raises(IndexError): 

220 i.local[30:] 

221 

222 with pytest.raises(IndexError): 

223 i.absolute[-1:10] 

224 

225 with pytest.raises(IndexError): 

226 i.local[-1:10] 

227 

228 with pytest.raises(ValueError): 

229 i.absolute[::2] 

230 

231 with pytest.raises(ValueError): 

232 i.local[::2] 

233 

234 with pytest.raises(ValueError): 

235 Interval.factory[1:2:2] 

236 

237 

238def test_interval_usage() -> None: 

239 """Test using intervals.""" 

240 i = Interval(start=1, stop=10) 

241 d = i.dilated_by(5) 

242 assert d == Interval(start=-4, stop=15) 

243 

244 s = i.slice_within(Interval(start=-1, stop=12)) 

245 assert s.start == 2 

246 assert s.stop == 11 

247 

248 with pytest.raises(IndexError): 

249 i.slice_within(Interval(start=3, stop=5)) 

250 

251 val = i.linspace() 

252 assert_close(val, np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])) 

253 val = i.linspace(step=2.0) 

254 assert_close(val, np.array([1.0, 3.0, 5.0, 7.0, 9.0])) 

255 val = i.linspace(n=3) 

256 assert_close(val, np.array([1.0, 5.0, 9.0])) 

257 with pytest.raises(TypeError): 

258 i.linspace(n=2, step=3.0) 

259 

260 assert list(i.range) == [1, 2, 3, 4, 5, 6, 7, 8, 9] 

261 val = i.arange 

262 assert_close(val, np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0])) 

263 

264 

265def test_interval_pydantic() -> None: 

266 """Test roundtrip through pydantic serialization.""" 

267 i1 = Interval(start=2, stop=5) 

268 i2 = Interval(start=-5, stop=10) 

269 model = IntervalModel(interval1=i1, interval2=i2) 

270 j_str = model.model_dump_json() 

271 jmodel = IntervalModel.model_validate_json(j_str) 

272 assert jmodel == model 

273 assert jmodel.interval1 == i1 

274 

275 

276def test_interval_pickle() -> None: 

277 """Test pickle roundtrip.""" 

278 i = Interval(start=5, stop=10) 

279 d = pickle.dumps(i) 

280 copy = pickle.loads(d) 

281 assert copy == i 

282 

283 

284def test_box_constructor() -> None: 

285 """Test Box construction and factory methods.""" 

286 y = Interval(-5, 5) 

287 x = Interval(32, 64) 

288 box = Box(y, x) 

289 

290 assert box.start == YX(-5, 32) 

291 assert box.shape == YX(10, 32) 

292 assert box.area == 320 

293 assert box.x == x 

294 assert box.y == y 

295 assert box != [] 

296 

297 box2 = Box.factory[-5:5, 32:64] 

298 assert box2 == box 

299 

300 sbox = Box.from_shape((10, 5)) 

301 assert sbox == Box.factory[0:10, 0:5] 

302 sbox = Box.from_shape((10, 5), start=(2, 3)) 

303 assert sbox == Box.factory[2:12, 3:8] 

304 

305 sbox = Box.from_shape(YX(10, 5)) 

306 assert sbox == Box.factory[0:10, 0:5] 

307 sbox = Box.from_shape((10, 5), start=YX(2, 3)) 

308 assert sbox == Box.factory[2:12, 3:8] 

309 

310 sbox = Box.from_shape(XY(5, 10)) 

311 assert sbox == Box.factory[0:10, 0:5] 

312 sbox = Box.from_shape((10, 5), start=XY(3, 2)) 

313 assert sbox == Box.factory[2:12, 3:8] 

314 

315 with pytest.raises(TypeError): 

316 Box.from_shape(42) 

317 with pytest.raises(ValueError): 

318 Box.from_shape([42]) 

319 with pytest.raises(ValueError): 

320 Box.from_shape([42, 33], start=[1, 2, 3]) 

321 

322 box = Box.factory[1:2, -1:1] 

323 grown = box.dilated_by(2) 

324 assert grown == Box.factory[-1:4, -3:3] 

325 

326 

327def test_box_constructor_type_checking() -> None: 

328 """Verify that the Box constructor rejects non-Interval arguments.""" 

329 y = Interval(-5, 5) 

330 x = Interval(32, 64) 

331 with pytest.raises(TypeError, match="Interval"): 

332 Box((-5, 5), (32, 64)) # type: ignore[arg-type] 

333 with pytest.raises(TypeError, match="Interval"): 

334 Box(slice(-5, 5), slice(32, 64)) # type: ignore[arg-type] 

335 with pytest.raises(TypeError, match="Interval"): 

336 Box(-5, 32) # type: ignore[arg-type] 

337 # Each argument is checked, not just the first. 

338 with pytest.raises(TypeError, match="Interval"): 

339 Box(y, (32, 64)) # type: ignore[arg-type] 

340 with pytest.raises(TypeError, match="Interval"): 

341 Box((-5, 5), x) # type: ignore[arg-type] 

342 with pytest.raises(TypeError, match="Interval"): 

343 Box(y=y, x=(32, 64)) # type: ignore[arg-type] 

344 

345 

346def test_box_contains() -> None: 

347 """Test box containment against other boxes and points.""" 

348 box = Box.factory[0:20, 10:40] 

349 

350 assert box.contains(Box.factory[4:10, 20:25]) 

351 assert not box.contains(Box.factory[4:10, 35:45]) 

352 assert box.contains(y=4, x=15) 

353 assert not box.contains(x=4, y=15) 

354 

355 contains = box.contains( 

356 # Half pixel leeway. 

357 x=np.array([-1, 10, 20, 30, 40, 41]), 

358 y=np.array([-1, 5, 19, 20, 20, 20]), 

359 ) 

360 assert list(contains) == [False, True, True, False, False, False] 

361 

362 with pytest.raises(TypeError): 

363 box.contains(box, x=3, y=2) 

364 with pytest.raises(TypeError): 

365 box.contains() 

366 

367 

368def test_box_contains_xy_yx() -> None: 

369 """Verify that Box.contains accepts XY and YX positional arguments.""" 

370 box = Box.factory[0:20, 10:40] 

371 # Scalar XY and YX — results must match the keyword form. 

372 assert box.contains(XY(x=15, y=4)) == box.contains(x=15, y=4) 

373 assert box.contains(YX(y=4, x=15)) == box.contains(x=15, y=4) 

374 assert not box.contains(XY(x=4, y=15)) 

375 assert not box.contains(YX(y=15, x=4)) 

376 # Array XY and YX. 

377 xv = np.array([-1, 10, 20, 30, 40, 41]) 

378 yv = np.array([-1, 5, 19, 20, 20, 20]) 

379 np.testing.assert_array_equal(box.contains(XY(xv, yv)), box.contains(x=xv, y=yv)) 

380 np.testing.assert_array_equal(box.contains(YX(yv, xv)), box.contains(x=xv, y=yv)) 

381 # Mixing positional point with keyword x= or y= must raise TypeError. 

382 with pytest.raises(TypeError): 

383 box.contains(XY(x=15, y=4), x=15) 

384 with pytest.raises(TypeError): 

385 box.contains(YX(y=4, x=15), y=4) 

386 

387 

388def test_box_contains_broadcasting() -> None: 

389 """Test that Box.contains broadcasts like a numpy ufunc.""" 

390 check_bounds_contains_broadcasting(Box.factory[0:20, 10:40]) 

391 

392 

393def test_box_intersection() -> None: 

394 """Test box intersection.""" 

395 box1 = Box.factory[0:20, 30:50] 

396 box2 = Box.factory[10:30, 40:42] 

397 assert box1.intersection(box2) == Box.factory[10:20, 40:42] 

398 with pytest.raises(NoOverlapError): 

399 box1.intersection(Box.factory[50:70, -10:-5]) 

400 

401 

402def test_box_slicing() -> None: 

403 """Test slicing.""" 

404 box = Box.factory[:10, :20] 

405 sbox = box.absolute[4:6, :3] 

406 assert sbox == Box.factory[4:6, 0:3] 

407 sbox = box.local[4:6, :3] 

408 assert sbox == Box.factory[4:6, 0:3] 

409 sbox = box.absolute[4:, 5:] 

410 assert sbox == Box.factory[4:10, 5:20] 

411 sbox = box.local[4:, 5:] 

412 assert sbox == Box.factory[4:10, 5:20] 

413 sbox = box.absolute[XY(slice(5, None), slice(4, None))] 

414 assert sbox == Box.factory[4:10, 5:20] 

415 sbox = box.local[XY(slice(5, None), slice(4, None))] 

416 assert sbox == Box.factory[4:10, 5:20] 

417 

418 assert Box.factory[4:10, -1:2] == Box.factory[XY(slice(-1, 2), slice(4, 10))] 

419 

420 slices = sbox.slice_within(box) 

421 assert slices.x.start == 5 

422 assert slices.y.start == 4 

423 assert slices.x.stop == 20 

424 assert slices.y.stop == 10 

425 

426 slices = Box.factory[:5, 110:119].slice_within(Box.factory[-15:12, 90:120]) 

427 assert slices.x.start == 20 

428 assert slices.y.start == 15 

429 assert slices.x.stop == 29 

430 assert slices.y.stop == 20 

431 

432 with pytest.raises(IndexError): 

433 box.absolute[-1:5, 3:] 

434 with pytest.raises(IndexError): 

435 box.local[-1:5, 3:] 

436 with pytest.raises(TypeError): 

437 box.absolute[3:5, :5, 4:] 

438 with pytest.raises(TypeError): 

439 box.local[3:5, :5, 4:] 

440 with pytest.raises(TypeError): 

441 Box.factory[3:5, :6, 4:] 

442 

443 

444def test_box_mesh() -> None: 

445 """Test grid creation.""" 

446 box = Box.factory[0:2, 0:3] 

447 

448 grid = box.meshgrid() 

449 assert_close(grid.x, np.array([[0.0, 1.0, 2.0], [0.0, 1.0, 2.0]])) 

450 assert_close(grid.y, np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]])) 

451 

452 grid = box.meshgrid(2) 

453 assert_close(grid.x, np.array([[0.0, 2.0], [0.0, 2.0]])) 

454 assert_close(grid.y, np.array([[0.0, 0.0], [1.0, 1.0]])) 

455 

456 grid = box.meshgrid([2, 1]) 

457 assert_close(grid.x, np.array([[0.0, 2.0]])) 

458 assert_close(grid.y, np.array([[0.0, 0.0]])) 

459 

460 grid = box.meshgrid(XY(2, 1)) 

461 assert_close(grid.x, np.array([[0.0, 2.0]])) 

462 assert_close(grid.y, np.array([[0.0, 0.0]])) 

463 

464 grid = box.meshgrid(YX(1, 2)) 

465 assert_close(grid.x, np.array([[0.0, 2.0]])) 

466 assert_close(grid.y, np.array([[0.0, 0.0]])) 

467 

468 grid = box.meshgrid(step=3) 

469 assert_close(grid.x, np.array([[0.0]])) 

470 assert_close(grid.y, np.array([[0.0]])) 

471 

472 with pytest.raises(TypeError): 

473 box.meshgrid(2, step=3) 

474 

475 with pytest.raises(ValueError): 

476 box.meshgrid("n") 

477 

478 

479def test_box_boundary() -> None: 

480 """Test we can find the boundary.""" 

481 box = Box.factory[-1:9, 7:15] 

482 corners = list(box.boundary()) 

483 assert corners[0] == (-1, 7) 

484 assert corners[1] == (-1, 14) 

485 assert corners[2] == (8, 14) 

486 assert corners[3] == (8, 7) 

487 

488 

489def test_box_pydantic() -> None: 

490 """Test roundtrip through pydantic serialization.""" 

491 box = Box.factory[-1:1, 5:10] 

492 model = BoxModel(box=box) 

493 j_str = model.model_dump_json() 

494 jmodel = BoxModel.model_validate_json(j_str) 

495 assert jmodel == model 

496 assert jmodel.box == box 

497 

498 

499def test_box_pickle() -> None: 

500 """Test pickle roundtrip.""" 

501 box = Box.factory[-1:1, 5:10] 

502 d = pickle.dumps(box) 

503 copy = pickle.loads(d) 

504 assert copy == box 

505 

506 

507def test_box_from_float_bounds() -> None: 

508 """Test Box.from_float_bounds rounds outward to integer pixel bounds.""" 

509 # x in [4.6, 9.4], y in [2.6, 5.4] -> box [3:6, 5:10] in [y, x]. 

510 box = Box.from_float_bounds(x_min=4.6, x_max=9.4, y_min=2.6, y_max=5.4) 

511 assert box == Box.factory[3:6, 5:10]