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
« 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.
12from __future__ import annotations
14import pickle
15from typing import get_overloads, get_type_hints
17import numpy as np
18import pydantic
19import pytest
21from lsst.images import XY, YX, Bounds, Box, Interval, NoOverlapError
22from lsst.images.tests import assert_close, check_bounds_contains_broadcasting
25class IntervalModel(pydantic.BaseModel):
26 """Test Pydantic model with an Interval."""
28 interval1: Interval
29 interval2: Interval
32class BoxModel(pydantic.BaseModel):
33 """Test Pydantic model with a box."""
35 box: Box
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)
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
52 def _plus_one(v: int) -> int:
53 return v + 1
55 new = yx.map(_plus_one)
56 assert new == (6, 8)
58 xy = yx.xy
59 assert xy == (7, 5)
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
69 def _plus_one(v: int) -> int:
70 return v + 1
72 new = xy.map(_plus_one)
73 assert new == (6, 8)
75 yx = xy.yx
76 assert yx == (7, 5)
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
90 assert str(i) == "1:10"
92 shifted = i + 10
93 assert shifted.start == 11
94 assert shifted.stop == 20
96 shifted = i - 10
97 assert shifted.start == -9
98 assert shifted.stop == 0
100 assert shifted == i - 10
101 assert shifted != i
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)
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)
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
120 i2 = Interval(start=2, stop=4)
121 assert i.contains(i2)
123 i3 = Interval(start=-1, stop=5)
124 assert not i.contains(i3)
126 assert i3.contains(2.5)
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]
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 != []
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))
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)
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
175 subset = i.absolute[5:10]
176 assert subset.start == 5
177 assert subset.stop == 10
179 subset = i.local[5:10]
180 assert subset.start == 8
181 assert subset.stop == 13
183 subset = i.absolute[:10]
184 assert subset.start == 3
185 assert subset.stop == 10
187 subset = i.local[:10]
188 assert subset.start == 3
189 assert subset.stop == 13
191 subset = i.absolute[10:]
192 assert subset.start == 10
193 assert subset.stop == 20
195 subset = i.local[10:]
196 assert subset.start == 13
197 assert subset.stop == 20
199 subset = i.local[3:-2]
200 assert subset.start == 6
201 assert subset.stop == 18
203 subset = i.local[-5:-2]
204 assert subset.start == 15
205 assert subset.stop == 18
207 with pytest.raises(IndexError):
208 i.absolute[:30]
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
216 with pytest.raises(IndexError):
217 i.absolute[30:]
219 with pytest.raises(IndexError):
220 i.local[30:]
222 with pytest.raises(IndexError):
223 i.absolute[-1:10]
225 with pytest.raises(IndexError):
226 i.local[-1:10]
228 with pytest.raises(ValueError):
229 i.absolute[::2]
231 with pytest.raises(ValueError):
232 i.local[::2]
234 with pytest.raises(ValueError):
235 Interval.factory[1:2:2]
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)
244 s = i.slice_within(Interval(start=-1, stop=12))
245 assert s.start == 2
246 assert s.stop == 11
248 with pytest.raises(IndexError):
249 i.slice_within(Interval(start=3, stop=5))
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)
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]))
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
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
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)
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 != []
297 box2 = Box.factory[-5:5, 32:64]
298 assert box2 == box
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]
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]
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]
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])
322 box = Box.factory[1:2, -1:1]
323 grown = box.dilated_by(2)
324 assert grown == Box.factory[-1:4, -3:3]
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]
346def test_box_contains() -> None:
347 """Test box containment against other boxes and points."""
348 box = Box.factory[0:20, 10:40]
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)
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]
362 with pytest.raises(TypeError):
363 box.contains(box, x=3, y=2)
364 with pytest.raises(TypeError):
365 box.contains()
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)
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])
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])
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]
418 assert Box.factory[4:10, -1:2] == Box.factory[XY(slice(-1, 2), slice(4, 10))]
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
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
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:]
444def test_box_mesh() -> None:
445 """Test grid creation."""
446 box = Box.factory[0:2, 0:3]
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]]))
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]]))
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]]))
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]]))
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]]))
468 grid = box.meshgrid(step=3)
469 assert_close(grid.x, np.array([[0.0]]))
470 assert_close(grid.y, np.array([[0.0]]))
472 with pytest.raises(TypeError):
473 box.meshgrid(2, step=3)
475 with pytest.raises(ValueError):
476 box.meshgrid("n")
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)
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
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
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]