Coverage for tests/test_geom.py: 100%
322 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-11 02:06 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-11 02:06 -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_contains() -> None:
328 """Test box containment against other boxes and points."""
329 box = Box.factory[0:20, 10:40]
331 assert box.contains(Box.factory[4:10, 20:25])
332 assert not box.contains(Box.factory[4:10, 35:45])
333 assert box.contains(y=4, x=15)
334 assert not box.contains(x=4, y=15)
336 contains = box.contains(
337 # Half pixel leeway.
338 x=np.array([-1, 10, 20, 30, 40, 41]),
339 y=np.array([-1, 5, 19, 20, 20, 20]),
340 )
341 assert list(contains) == [False, True, True, False, False, False]
343 with pytest.raises(TypeError):
344 box.contains(box, x=3, y=2)
345 with pytest.raises(TypeError):
346 box.contains()
349def test_box_contains_xy_yx() -> None:
350 """Verify that Box.contains accepts XY and YX positional arguments."""
351 box = Box.factory[0:20, 10:40]
352 # Scalar XY and YX — results must match the keyword form.
353 assert box.contains(XY(x=15, y=4)) == box.contains(x=15, y=4)
354 assert box.contains(YX(y=4, x=15)) == box.contains(x=15, y=4)
355 assert not box.contains(XY(x=4, y=15))
356 assert not box.contains(YX(y=15, x=4))
357 # Array XY and YX.
358 xv = np.array([-1, 10, 20, 30, 40, 41])
359 yv = np.array([-1, 5, 19, 20, 20, 20])
360 np.testing.assert_array_equal(box.contains(XY(xv, yv)), box.contains(x=xv, y=yv))
361 np.testing.assert_array_equal(box.contains(YX(yv, xv)), box.contains(x=xv, y=yv))
362 # Mixing positional point with keyword x= or y= must raise TypeError.
363 with pytest.raises(TypeError):
364 box.contains(XY(x=15, y=4), x=15)
365 with pytest.raises(TypeError):
366 box.contains(YX(y=4, x=15), y=4)
369def test_box_contains_broadcasting() -> None:
370 """Test that Box.contains broadcasts like a numpy ufunc."""
371 check_bounds_contains_broadcasting(Box.factory[0:20, 10:40])
374def test_box_intersection() -> None:
375 """Test box intersection."""
376 box1 = Box.factory[0:20, 30:50]
377 box2 = Box.factory[10:30, 40:42]
378 assert box1.intersection(box2) == Box.factory[10:20, 40:42]
379 with pytest.raises(NoOverlapError):
380 box1.intersection(Box.factory[50:70, -10:-5])
383def test_box_slicing() -> None:
384 """Test slicing."""
385 box = Box.factory[:10, :20]
386 sbox = box.absolute[4:6, :3]
387 assert sbox == Box.factory[4:6, 0:3]
388 sbox = box.local[4:6, :3]
389 assert sbox == Box.factory[4:6, 0:3]
390 sbox = box.absolute[4:, 5:]
391 assert sbox == Box.factory[4:10, 5:20]
392 sbox = box.local[4:, 5:]
393 assert sbox == Box.factory[4:10, 5:20]
394 sbox = box.absolute[XY(slice(5, None), slice(4, None))]
395 assert sbox == Box.factory[4:10, 5:20]
396 sbox = box.local[XY(slice(5, None), slice(4, None))]
397 assert sbox == Box.factory[4:10, 5:20]
399 assert Box.factory[4:10, -1:2] == Box.factory[XY(slice(-1, 2), slice(4, 10))]
401 slices = sbox.slice_within(box)
402 assert slices.x.start == 5
403 assert slices.y.start == 4
404 assert slices.x.stop == 20
405 assert slices.y.stop == 10
407 slices = Box.factory[:5, 110:119].slice_within(Box.factory[-15:12, 90:120])
408 assert slices.x.start == 20
409 assert slices.y.start == 15
410 assert slices.x.stop == 29
411 assert slices.y.stop == 20
413 with pytest.raises(IndexError):
414 box.absolute[-1:5, 3:]
415 with pytest.raises(IndexError):
416 box.local[-1:5, 3:]
417 with pytest.raises(TypeError):
418 box.absolute[3:5, :5, 4:]
419 with pytest.raises(TypeError):
420 box.local[3:5, :5, 4:]
421 with pytest.raises(TypeError):
422 Box.factory[3:5, :6, 4:]
425def test_box_mesh() -> None:
426 """Test grid creation."""
427 box = Box.factory[0:2, 0:3]
429 grid = box.meshgrid()
430 assert_close(grid.x, np.array([[0.0, 1.0, 2.0], [0.0, 1.0, 2.0]]))
431 assert_close(grid.y, np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]))
433 grid = box.meshgrid(2)
434 assert_close(grid.x, np.array([[0.0, 2.0], [0.0, 2.0]]))
435 assert_close(grid.y, np.array([[0.0, 0.0], [1.0, 1.0]]))
437 grid = box.meshgrid([2, 1])
438 assert_close(grid.x, np.array([[0.0, 2.0]]))
439 assert_close(grid.y, np.array([[0.0, 0.0]]))
441 grid = box.meshgrid(XY(2, 1))
442 assert_close(grid.x, np.array([[0.0, 2.0]]))
443 assert_close(grid.y, np.array([[0.0, 0.0]]))
445 grid = box.meshgrid(YX(1, 2))
446 assert_close(grid.x, np.array([[0.0, 2.0]]))
447 assert_close(grid.y, np.array([[0.0, 0.0]]))
449 grid = box.meshgrid(step=3)
450 assert_close(grid.x, np.array([[0.0]]))
451 assert_close(grid.y, np.array([[0.0]]))
453 with pytest.raises(TypeError):
454 box.meshgrid(2, step=3)
456 with pytest.raises(ValueError):
457 box.meshgrid("n")
460def test_box_boundary() -> None:
461 """Test we can find the boundary."""
462 box = Box.factory[-1:9, 7:15]
463 corners = list(box.boundary())
464 assert corners[0] == (-1, 7)
465 assert corners[1] == (-1, 14)
466 assert corners[2] == (8, 14)
467 assert corners[3] == (8, 7)
470def test_box_pydantic() -> None:
471 """Test roundtrip through pydantic serialization."""
472 box = Box.factory[-1:1, 5:10]
473 model = BoxModel(box=box)
474 j_str = model.model_dump_json()
475 jmodel = BoxModel.model_validate_json(j_str)
476 assert jmodel == model
477 assert jmodel.box == box
480def test_box_pickle() -> None:
481 """Test pickle roundtrip."""
482 box = Box.factory[-1:1, 5:10]
483 d = pickle.dumps(box)
484 copy = pickle.loads(d)
485 assert copy == box
488def test_box_from_float_bounds() -> None:
489 """Test Box.from_float_bounds rounds outward to integer pixel bounds."""
490 # x in [4.6, 9.4], y in [2.6, 5.4] -> box [3:6, 5:10] in [y, x].
491 box = Box.from_float_bounds(x_min=4.6, x_max=9.4, y_min=2.6, y_max=5.4)
492 assert box == Box.factory[3:6, 5:10]