Coverage for tests/test_geom.py: 100%

374 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-18 02:07 -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 ( 

22 XY, 

23 YX, 

24 Bounds, 

25 Box, 

26 Interval, 

27 NoOverlapError, 

28 SerializableXY, 

29 SerializableYX, 

30) 

31from lsst.images.tests import assert_close, check_bounds_contains_broadcasting 

32 

33 

34class IntervalModel(pydantic.BaseModel): 

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

36 

37 interval1: Interval 

38 interval2: Interval 

39 

40 

41class BoxModel(pydantic.BaseModel): 

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

43 

44 box: Box 

45 

46 

47class PairModel(pydantic.BaseModel): 

48 """Test Pydantic model with XY and YX pairs.""" 

49 

50 xy: SerializableXY[float] 

51 yx: SerializableYX[int] 

52 bare: XY 

53 

54 

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

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

57 get_type_hints(obj) 

58 for overload in get_overloads(obj): 

59 get_type_hints(overload) 

60 

61 

62def test_yx() -> None: 

63 """Test YX.""" 

64 yx = YX(5, 7) 

65 assert yx == (5, 7) 

66 assert yx.y == 5 

67 assert yx.x == 7 

68 

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

70 return v + 1 

71 

72 new = yx.map(_plus_one) 

73 assert new == (6, 8) 

74 

75 xy = yx.xy 

76 assert xy == (7, 5) 

77 

78 

79def test_xy() -> None: 

80 """Test XY.""" 

81 xy = XY(5, 7) 

82 assert xy == (5, 7) 

83 assert xy.y == 7 

84 assert xy.x == 5 

85 

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

87 return v + 1 

88 

89 new = xy.map(_plus_one) 

90 assert new == (6, 8) 

91 

92 yx = xy.yx 

93 assert yx == (7, 5) 

94 

95 

96def test_interval_constructor() -> None: 

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

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

99 assert i.start == 1 

100 assert i.stop == 10 

101 assert i.min == 1 

102 assert i.max == 9 

103 assert i.size == 9 

104 assert i.center == 5.0 

105 assert Interval(1, 10) == i 

106 

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

108 

109 shifted = i + 10 

110 assert shifted.start == 11 

111 assert shifted.stop == 20 

112 

113 shifted = i - 10 

114 assert shifted.start == -9 

115 assert shifted.stop == 0 

116 

117 assert shifted == i - 10 

118 assert shifted != i 

119 

120 sized = Interval.from_size(10) 

121 assert sized == Interval(0, 10) 

122 sized = Interval.from_size(10, 5) 

123 assert sized == Interval(5, 15) 

124 

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

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

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

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

129 

130 

131def test_interval_contains() -> None: 

132 """Test containment.""" 

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

134 assert 5 in i 

135 assert 10 not in i 

136 

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

138 assert i.contains(i2) 

139 

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

141 assert not i.contains(i3) 

142 

143 assert i3.contains(2.5) 

144 

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

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

147 

148 inter = i2.intersection(i) 

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

150 with pytest.raises(NoOverlapError): 

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

152 assert i != [] 

153 

154 

155def test_interval_contains_broadcasting() -> None: 

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

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

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

159 xs = np.array( 

160 [ 

161 interval.start - 1, 

162 interval.start, 

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

164 interval.stop - 1, 

165 interval.stop, 

166 ] 

167 ) 

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

169 # 1-D ndarray. 

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

171 # list input (array-like). 

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

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

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

175 

176 

177def test_runtime_type_hints_resolve() -> None: 

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

179 _assert_type_hints_resolve(Interval.contains) 

180 _assert_type_hints_resolve(Box.contains) 

181 _assert_type_hints_resolve(Bounds.contains) 

182 

183 

184def test_interval_slice() -> None: 

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

186 i = Interval.factory[3:20] 

187 assert i.start == 3 

188 assert i.stop == 20 

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

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

191 

192 subset = i.absolute[5:10] 

193 assert subset.start == 5 

194 assert subset.stop == 10 

195 

196 subset = i.local[5:10] 

197 assert subset.start == 8 

198 assert subset.stop == 13 

199 

200 subset = i.absolute[:10] 

201 assert subset.start == 3 

202 assert subset.stop == 10 

203 

204 subset = i.local[:10] 

205 assert subset.start == 3 

206 assert subset.stop == 13 

207 

208 subset = i.absolute[10:] 

209 assert subset.start == 10 

210 assert subset.stop == 20 

211 

212 subset = i.local[10:] 

213 assert subset.start == 13 

214 assert subset.stop == 20 

215 

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

217 assert subset.start == 6 

218 assert subset.stop == 18 

219 

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

221 assert subset.start == 15 

222 assert subset.stop == 18 

223 

224 with pytest.raises(IndexError): 

225 i.absolute[:30] 

226 

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

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

229 subset = i.local[:30] 

230 assert subset.start == 3 

231 assert subset.stop == 20 

232 

233 with pytest.raises(IndexError): 

234 i.absolute[30:] 

235 

236 with pytest.raises(IndexError): 

237 i.local[30:] 

238 

239 with pytest.raises(IndexError): 

240 i.absolute[-1:10] 

241 

242 with pytest.raises(IndexError): 

243 i.local[-1:10] 

244 

245 with pytest.raises(ValueError): 

246 i.absolute[::2] 

247 

248 with pytest.raises(ValueError): 

249 i.local[::2] 

250 

251 with pytest.raises(ValueError): 

252 Interval.factory[1:2:2] 

253 

254 

255def test_interval_usage() -> None: 

256 """Test using intervals.""" 

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

258 d = i.dilated_by(5) 

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

260 

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

262 assert s.start == 2 

263 assert s.stop == 11 

264 

265 with pytest.raises(IndexError): 

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

267 

268 val = i.linspace() 

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

270 val = i.linspace(step=2.0) 

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

272 val = i.linspace(n=3) 

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

274 with pytest.raises(TypeError): 

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

276 

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

278 val = i.arange 

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

280 

281 

282def test_interval_pydantic() -> None: 

283 """Test roundtrip through pydantic serialization.""" 

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

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

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

287 j_str = model.model_dump_json() 

288 jmodel = IntervalModel.model_validate_json(j_str) 

289 assert jmodel == model 

290 assert jmodel.interval1 == i1 

291 

292 

293def test_xy_yx_pydantic() -> None: 

294 """Test that XY and YX serialize as dicts keyed by dimension name.""" 

295 model = PairModel(xy=XY(x=1.5, y=2.5), yx=YX(y=3, x=4), bare=XY(x=0.5, y=1)) 

296 data = model.model_dump() 

297 assert data == { 

298 "xy": {"x": 1.5, "y": 2.5}, 

299 "yx": {"y": 3, "x": 4}, 

300 "bare": {"x": 0.5, "y": 1}, 

301 } 

302 

303 roundtripped = PairModel.model_validate_json(model.model_dump_json()) 

304 assert roundtripped == model 

305 assert isinstance(roundtripped.xy, XY) 

306 assert isinstance(roundtripped.yx, YX) 

307 assert roundtripped.yx.y == 3 

308 assert roundtripped.yx.x == 4 

309 

310 json_schema = PairModel.model_json_schema() 

311 

312 def resolve(node: dict) -> dict: 

313 while "$ref" in node: 

314 node = json_schema["$defs"][node["$ref"].rsplit("/", 1)[-1]] 

315 return node 

316 

317 xy_object, xy_array = (resolve(s) for s in json_schema["properties"]["xy"]["anyOf"]) 

318 assert xy_object["type"] == "object" 

319 assert set(xy_object["required"]) == {"x", "y"} 

320 assert xy_object["properties"]["x"]["type"] == "number" 

321 assert xy_array == {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2} 

322 yx_object, yx_array = (resolve(s) for s in json_schema["properties"]["yx"]["anyOf"]) 

323 assert yx_object["type"] == "object" 

324 assert set(yx_object["required"]) == {"x", "y"} 

325 assert yx_object["properties"]["y"]["type"] == "integer" 

326 assert yx_array == {"type": "array", "items": {"type": "integer"}, "minItems": 2, "maxItems": 2} 

327 

328 

329def test_xy_yx_pydantic_array_form() -> None: 

330 """Test that the array serialization emitted before the object form was 

331 introduced still validates, in both JSON and Python mode (already-parsed 

332 JSON is validated as Python objects when reading archives). 

333 """ 

334 from_json = PairModel.model_validate_json('{"xy": [1.5, 2.5], "yx": [3, 4], "bare": [0.5, 1.0]}') 

335 assert from_json.xy == XY(x=1.5, y=2.5) 

336 assert from_json.yx == YX(y=3, x=4) 

337 from_python = PairModel.model_validate({"xy": [1.5, 2.5], "yx": [3, 4], "bare": [0.5, 1.0]}) 

338 assert from_python == from_json 

339 

340 # The array form is restricted to lists so that a transposed pair (XY 

341 # and YX are both tuples) fails to validate rather than silently 

342 # swapping its elements. 

343 with pytest.raises(pydantic.ValidationError): 

344 PairModel(xy=YX(y=2.5, x=1.5), yx=YX(y=3, x=4), bare=XY(x=0.5, y=1.0)) 

345 

346 

347def test_interval_pickle() -> None: 

348 """Test pickle roundtrip.""" 

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

350 d = pickle.dumps(i) 

351 copy = pickle.loads(d) 

352 assert copy == i 

353 

354 

355def test_box_constructor() -> None: 

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

357 y = Interval(-5, 5) 

358 x = Interval(32, 64) 

359 box = Box(y, x) 

360 

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

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

363 assert box.area == 320 

364 assert box.x == x 

365 assert box.y == y 

366 assert box != [] 

367 

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

369 assert box2 == box 

370 

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

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

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

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

375 

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

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

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

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

380 

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

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

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

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

385 

386 with pytest.raises(TypeError): 

387 Box.from_shape(42) 

388 with pytest.raises(ValueError): 

389 Box.from_shape([42]) 

390 with pytest.raises(ValueError): 

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

392 

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

394 grown = box.dilated_by(2) 

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

396 

397 

398def test_box_constructor_type_checking() -> None: 

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

400 y = Interval(-5, 5) 

401 x = Interval(32, 64) 

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

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

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

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

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

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

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

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

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

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

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

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

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

415 

416 

417def test_box_contains() -> None: 

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

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

420 

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

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

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

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

425 

426 contains = box.contains( 

427 # Half pixel leeway. 

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

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

430 ) 

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

432 

433 with pytest.raises(TypeError): 

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

435 with pytest.raises(TypeError): 

436 box.contains() 

437 

438 

439def test_box_contains_xy_yx() -> None: 

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

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

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

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

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

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

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

447 # Array XY and YX. 

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

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

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

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

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

453 with pytest.raises(TypeError): 

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

455 with pytest.raises(TypeError): 

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

457 

458 

459def test_box_contains_broadcasting() -> None: 

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

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

462 

463 

464def test_box_intersection() -> None: 

465 """Test box intersection.""" 

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

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

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

469 with pytest.raises(NoOverlapError): 

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

471 

472 

473def test_box_slicing() -> None: 

474 """Test slicing.""" 

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

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

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

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

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

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

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

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

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

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

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

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

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

488 

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

490 

491 slices = sbox.slice_within(box) 

492 assert slices.x.start == 5 

493 assert slices.y.start == 4 

494 assert slices.x.stop == 20 

495 assert slices.y.stop == 10 

496 

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

498 assert slices.x.start == 20 

499 assert slices.y.start == 15 

500 assert slices.x.stop == 29 

501 assert slices.y.stop == 20 

502 

503 with pytest.raises(IndexError): 

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

505 with pytest.raises(IndexError): 

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

507 with pytest.raises(TypeError): 

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

509 with pytest.raises(TypeError): 

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

511 with pytest.raises(TypeError): 

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

513 

514 

515def test_box_mesh() -> None: 

516 """Test grid creation.""" 

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

518 

519 grid = box.meshgrid() 

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

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

522 

523 grid = box.meshgrid(2) 

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

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

526 

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

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

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

530 

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

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

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

534 

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

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

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

538 

539 grid = box.meshgrid(step=3) 

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

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

542 

543 with pytest.raises(TypeError): 

544 box.meshgrid(2, step=3) 

545 

546 with pytest.raises(ValueError): 

547 box.meshgrid("n") 

548 

549 

550def test_box_boundary() -> None: 

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

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

553 corners = list(box.boundary()) 

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

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

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

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

558 

559 

560def test_box_pydantic() -> None: 

561 """Test roundtrip through pydantic serialization.""" 

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

563 model = BoxModel(box=box) 

564 j_str = model.model_dump_json() 

565 jmodel = BoxModel.model_validate_json(j_str) 

566 assert jmodel == model 

567 assert jmodel.box == box 

568 

569 

570def test_box_pickle() -> None: 

571 """Test pickle roundtrip.""" 

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

573 d = pickle.dumps(box) 

574 copy = pickle.loads(d) 

575 assert copy == box 

576 

577 

578def test_box_from_float_bounds() -> None: 

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

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

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

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