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
« 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.
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 (
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
34class IntervalModel(pydantic.BaseModel):
35 """Test Pydantic model with an Interval."""
37 interval1: Interval
38 interval2: Interval
41class BoxModel(pydantic.BaseModel):
42 """Test Pydantic model with a box."""
44 box: Box
47class PairModel(pydantic.BaseModel):
48 """Test Pydantic model with XY and YX pairs."""
50 xy: SerializableXY[float]
51 yx: SerializableYX[int]
52 bare: XY
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)
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
69 def _plus_one(v: int) -> int:
70 return v + 1
72 new = yx.map(_plus_one)
73 assert new == (6, 8)
75 xy = yx.xy
76 assert xy == (7, 5)
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
86 def _plus_one(v: int) -> int:
87 return v + 1
89 new = xy.map(_plus_one)
90 assert new == (6, 8)
92 yx = xy.yx
93 assert yx == (7, 5)
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
107 assert str(i) == "1:10"
109 shifted = i + 10
110 assert shifted.start == 11
111 assert shifted.stop == 20
113 shifted = i - 10
114 assert shifted.start == -9
115 assert shifted.stop == 0
117 assert shifted == i - 10
118 assert shifted != i
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)
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)
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
137 i2 = Interval(start=2, stop=4)
138 assert i.contains(i2)
140 i3 = Interval(start=-1, stop=5)
141 assert not i.contains(i3)
143 assert i3.contains(2.5)
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]
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 != []
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))
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)
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
192 subset = i.absolute[5:10]
193 assert subset.start == 5
194 assert subset.stop == 10
196 subset = i.local[5:10]
197 assert subset.start == 8
198 assert subset.stop == 13
200 subset = i.absolute[:10]
201 assert subset.start == 3
202 assert subset.stop == 10
204 subset = i.local[:10]
205 assert subset.start == 3
206 assert subset.stop == 13
208 subset = i.absolute[10:]
209 assert subset.start == 10
210 assert subset.stop == 20
212 subset = i.local[10:]
213 assert subset.start == 13
214 assert subset.stop == 20
216 subset = i.local[3:-2]
217 assert subset.start == 6
218 assert subset.stop == 18
220 subset = i.local[-5:-2]
221 assert subset.start == 15
222 assert subset.stop == 18
224 with pytest.raises(IndexError):
225 i.absolute[:30]
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
233 with pytest.raises(IndexError):
234 i.absolute[30:]
236 with pytest.raises(IndexError):
237 i.local[30:]
239 with pytest.raises(IndexError):
240 i.absolute[-1:10]
242 with pytest.raises(IndexError):
243 i.local[-1:10]
245 with pytest.raises(ValueError):
246 i.absolute[::2]
248 with pytest.raises(ValueError):
249 i.local[::2]
251 with pytest.raises(ValueError):
252 Interval.factory[1:2:2]
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)
261 s = i.slice_within(Interval(start=-1, stop=12))
262 assert s.start == 2
263 assert s.stop == 11
265 with pytest.raises(IndexError):
266 i.slice_within(Interval(start=3, stop=5))
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)
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]))
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
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 }
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
310 json_schema = PairModel.model_json_schema()
312 def resolve(node: dict) -> dict:
313 while "$ref" in node:
314 node = json_schema["$defs"][node["$ref"].rsplit("/", 1)[-1]]
315 return node
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}
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
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))
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
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)
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 != []
368 box2 = Box.factory[-5:5, 32:64]
369 assert box2 == box
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]
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]
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]
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])
393 box = Box.factory[1:2, -1:1]
394 grown = box.dilated_by(2)
395 assert grown == Box.factory[-1:4, -3:3]
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]
417def test_box_contains() -> None:
418 """Test box containment against other boxes and points."""
419 box = Box.factory[0:20, 10:40]
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)
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]
433 with pytest.raises(TypeError):
434 box.contains(box, x=3, y=2)
435 with pytest.raises(TypeError):
436 box.contains()
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)
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])
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])
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]
489 assert Box.factory[4:10, -1:2] == Box.factory[XY(slice(-1, 2), slice(4, 10))]
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
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
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:]
515def test_box_mesh() -> None:
516 """Test grid creation."""
517 box = Box.factory[0:2, 0:3]
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]]))
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]]))
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]]))
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]]))
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]]))
539 grid = box.meshgrid(step=3)
540 assert_close(grid.x, np.array([[0.0]]))
541 assert_close(grid.y, np.array([[0.0]]))
543 with pytest.raises(TypeError):
544 box.meshgrid(2, step=3)
546 with pytest.raises(ValueError):
547 box.meshgrid("n")
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)
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
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
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]