Coverage for python/lsst/images/_geom.py: 94%
371 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 08:57 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 08:57 +0000
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
14__all__ = (
15 "XY",
16 "YX",
17 "Bounds",
18 "BoundsError",
19 "Box",
20 "BoxSliceFactory",
21 "Interval",
22 "IntervalSliceFactory",
23 "NoOverlapError",
24)
26import math
27from collections.abc import Callable, Iterator, Sequence
28from typing import (
29 TYPE_CHECKING,
30 Any,
31 ClassVar,
32 NamedTuple,
33 Protocol,
34 TypedDict,
35 TypeVar,
36 final,
37 overload,
38)
40import numpy as np
41import pydantic
42import pydantic_core.core_schema as pcs
43from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue
45if TYPE_CHECKING:
46 from ._concrete_bounds import SerializableBounds
48 try:
49 from lsst.geom import Extent2I as LegacyExtent2I
50 from lsst.geom import Point2I as LegacyPoint2I
51 except ImportError:
52 type LegacyExtent2I = Any # type: ignore[no-redef]
53 type LegacyPoint2I = Any # type: ignore[no-redef]
55# This pre-python-3.12 declaration is needed by Sphinx (probably the
56# autodoc-typehints plugin.
57T = TypeVar("T")
59# Interval and Box are defined as regular Python classes rather than
60# dataclasses or Pydantic models because we might want to implement them as
61# compiled-extension types in the future, and we want that to be transparent.
63# In a similar vein, we avoid declaring specific types for multidimensional
64# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
65# shapes) in order to leave room for more fully-featured types to be added
66# upstream of this package in the future.
69class YX[T](NamedTuple):
70 """A pair of per-dimension objects, ordered ``(y, x)``.
72 Notes
73 -----
74 `YX` is used for slices, shapes, and other 2-d pairs when the most
75 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
76 arithmetic operations behave as they would on a
77 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
78 concatenates).
80 See Also
81 --------
82 XY
83 """
85 y: T
86 """The y / row object."""
88 x: T
89 """The x / column object."""
91 @property
92 def xy(self) -> XY:
93 """A tuple of the same objects in the opposite order."""
94 return XY(x=self.x, y=self.y)
96 def map[U](self, func: Callable[[T], U]) -> YX[U]:
97 """Apply a function to both objects."""
98 return YX(y=func(self.y), x=func(self.x))
100 def to_legacy_extent(self) -> LegacyExtent2I:
101 """Convert to a legacy `lsst.geom.Extent2I` object."""
102 from lsst.geom import Extent2I as LegacyExtent2I
104 return LegacyExtent2I(self.x, self.y)
106 def to_legacy_point(self) -> LegacyPoint2I:
107 """Convert to a legacy `lsst.geom.Point2I` object."""
108 from lsst.geom import Point2I as LegacyPoint2I
110 return LegacyPoint2I(self.x, self.y)
113class XY[T](NamedTuple):
114 """A pair of per-dimension objects, ordered ``(x, y)``.
116 Notes
117 -----
118 `XY` is used for points and other 2-d pairs when the most natural ordering
119 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
120 behave as they would on a `collections.abc.Sequence`, not a mathematical
121 vector (e.g. adding concatenates).
123 See Also
124 --------
125 YX
126 """
128 x: T
129 """The x / column object."""
131 y: T
132 """The y / row object."""
134 @property
135 def yx(self) -> YX:
136 """A tuple of the same objects in the opposite order."""
137 return YX(y=self.y, x=self.x)
139 def map[U](self, func: Callable[[T], U]) -> XY[U]:
140 """Apply a function to both objects."""
141 return XY(x=func(self.x), y=func(self.y))
143 def to_legacy_extent(self) -> LegacyExtent2I:
144 """Convert to a legacy `lsst.geom.Extent2I` object."""
145 from lsst.geom import Extent2I as LegacyExtent2I
147 return LegacyExtent2I(self.x, self.y)
149 def to_legacy_point(self) -> LegacyPoint2I:
150 """Convert to a legacy `lsst.geom.Point2I` object."""
151 from lsst.geom import Point2I as LegacyPoint2I
153 return LegacyPoint2I(self.x, self.y)
156class _SerializedInterval(TypedDict):
157 start: int
158 stop: int
161@final
162class Interval:
163 """A 1-d integer interval with positive size.
165 Parameters
166 ----------
167 start
168 Inclusive minimum point in the interval.
169 stop
170 One past the maximum point in the interval.
172 Notes
173 -----
174 Adding or subtracting an `int` from an interval returns a shifted interval.
176 `Interval` implements the necessary hooks to be included directly in a
177 `pydantic.BaseModel`, even though it is neither a built-in type nor a
178 Pydantic model itself.
179 """
181 def __init__(self, start: int, stop: int):
182 # Coerce to be defensive against numpy int scalars.
183 self._start = int(start)
184 self._stop = int(stop)
185 if not (self._stop > self._start):
186 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
188 __slots__ = ("_start", "_stop")
190 factory: ClassVar[IntervalSliceFactory]
191 """A factory for creating intervals using slice syntax.
193 For example::
195 interval = Interval.factory[2:5]
196 """
198 @classmethod
199 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
200 """Construct an interval that includes all of the given points and/or
201 intervals.
202 """
203 if type(first) is Interval:
204 rmin = first.min
205 rmax = first.max
206 else:
207 rmin = rmax = first
208 for arg in args:
209 if type(arg) is Interval:
210 rmin = min(rmin, arg.min)
211 rmax = max(rmax, arg.max)
212 else:
213 rmin = min(rmin, arg)
214 rmax = max(rmax, arg)
215 return Interval(start=rmin, stop=rmax + 1)
217 @classmethod
218 def from_size(cls, size: int, start: int = 0) -> Interval:
219 """Construct an interval from its size and optional start."""
220 return cls(start=start, stop=start + size)
222 @property
223 def min(self) -> int:
224 """Inclusive minimum point in the interval (`int`)."""
225 return self.start
227 @property
228 def max(self) -> int:
229 """Inclusive maximum point in the interval (`int`)."""
230 return self.stop - 1
232 @property
233 def start(self) -> int:
234 """Inclusive minimum point in the interval (`int`)."""
235 return self._start
237 @property
238 def stop(self) -> int:
239 """One past the maximum point in the interval (`int`)."""
240 return self._stop
242 @property
243 def size(self) -> int:
244 """Size of the interval (`int`)."""
245 return self.stop - self.start
247 @property
248 def range(self) -> __builtins__.range:
249 """An iterable over all values in the interval
250 (`__builtins__.range`).
251 """
252 return range(self.start, self.stop)
254 @property
255 def arange(self) -> np.ndarray:
256 """An array of all the values in the interval (`numpy.ndarray`).
258 Array values are integers.
259 """
260 return np.arange(self.start, self.stop)
262 @property
263 def absolute(self) -> IntervalSliceFactory:
264 """A factory for constructing a contained `Interval` using slice
265 syntax and absolute coordinates.
267 Notes
268 -----
269 Slice bounds that are absent are replaced with the bounds of ``self``.
270 """
271 return IntervalSliceFactory(self, is_local=False)
273 @property
274 def local(self) -> IntervalSliceFactory:
275 """A factory for constructing a contained `Interval` using a slice
276 relative to the start of this one (`IntervalSliceFactory`).
278 Notes
279 -----
280 This factory interprets slices as "local" coordinates, in which ``0``
281 corresponds to ``self.start``. Negative bounds are relative to
282 ``self.stop``, as is usually the case for Python sequences.
283 """
284 return IntervalSliceFactory(self, is_local=True)
286 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
287 """Return an array of values that spans the interval.
289 Parameters
290 ----------
291 n
292 How many values to return. The default (if ``step`` is also not
293 provided) is the size of the interval, i.e. equivalent to the
294 `arange` property (but converted to `float`).
295 step
296 Set ``n`` such that the distance between points is equal to or
297 just less than this. Mutually exclusive with ``n``.
299 Returns
300 -------
301 numpy.ndarray
302 Array of `float` values.
304 See Also
305 --------
306 numpy.linspace
307 """
308 if n is None:
309 if step is None:
310 return self.arange.astype(np.float64)
311 n = math.ceil(self.size / step)
312 elif step is not None:
313 raise TypeError("'n' and 'step' cannot both be provided.")
314 return np.linspace(self.min, self.max, n, dtype=np.float64)
316 @property
317 def center(self) -> float:
318 """The center of the interval (`float`)."""
319 return 0.5 * (self.min + self.max)
321 def padded(self, padding: int) -> Interval:
322 """Return a new interval expanded by the given padding on
323 either side.
324 """
325 return Interval(self.start - padding, self.stop + padding)
327 def __str__(self) -> str:
328 return f"{self.start}:{self.stop}"
330 def __repr__(self) -> str:
331 return f"Interval(start={self.start}, stop={self.stop})"
333 def __eq__(self, other: object) -> bool:
334 if type(other) is Interval:
335 return self._start == other._start and self._stop == other._stop
336 return False
338 def __add__(self, other: int) -> Interval:
339 return Interval(start=self.start + other, stop=self.stop + other)
341 def __sub__(self, other: int) -> Interval:
342 return Interval(start=self.start - other, stop=self.stop - other)
344 def __contains__(self, x: int) -> bool:
345 return x >= self.start and x < self.stop
347 @overload
348 def contains(self, other: Interval | int | float) -> bool: ... 348 ↛ exitline 348 didn't return from function 'contains' because
350 @overload
351 def contains(self, other: np.ndarray) -> np.ndarray: ... 351 ↛ exitline 351 didn't return from function 'contains' because
353 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
354 """Test whether this interval fully contains another or one or more
355 points.
357 Parameters
358 ----------
359 other
360 Another interval to compare to, or one or more position values.
362 Returns
363 -------
364 `bool` | `numpy.ndarray`
365 If a single interval or value was passed, a single `bool`. If an
366 array was passed, an array with the same shape.
368 Notes
369 -----
370 In order to yield the desired behavior for floating-point arguments,
371 points are actually tested against an interval that is 0.5 larger on
372 both sides: this makes positions within the outer boundary of pixels
373 (but beyond the centers of those pixels, which have integer positions)
374 appear "on the image".
375 """
376 if isinstance(other, Interval):
377 return self.start <= other.start and self.stop >= other.stop
378 else:
379 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
380 if not result.shape:
381 return bool(result)
382 return result
384 def intersection(self, other: Interval) -> Interval:
385 """Return an interval that is contained by both ``self`` and ``other``.
387 When there is no overlap between the intervals, `NoOverlapError` is
388 raised.
389 """
390 new_start = max(self.start, other.start)
391 new_stop = min(self.stop, other.stop)
392 if new_start < new_stop:
393 return Interval(start=new_start, stop=new_stop)
394 raise NoOverlapError(f"No overlap between {self} and {other}.")
396 def dilated_by(self, padding: int) -> Interval:
397 """Return a new interval padded by the given amount on both sides."""
398 return Interval(start=self._start - padding, stop=self._stop + padding)
400 def slice_within(self, other: Interval) -> slice:
401 """Return the `slice` that corresponds to the values in this interval
402 when the items of the container being sliced correspond to ``other``.
404 This assumes ``other.contains(self)``.
405 """
406 if not other.contains(self):
407 raise IndexError(
408 f"Can not calculate a slice of {other} within {self} "
409 "since the given interval does not contain this one."
410 )
411 return slice(self.start - other.start, self.stop - other.start)
413 @classmethod
414 def from_legacy(cls, legacy: Any) -> Interval:
415 """Convert from an `lsst.geom.IntervalI` instance."""
416 return cls(legacy.begin, legacy.end)
418 def to_legacy(self) -> Any:
419 """Convert to an `lsst.geom.IntervalI` instance."""
420 from lsst.geom import IntervalI
422 return IntervalI(min=self.min, max=self.max)
424 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
425 return (
426 Interval,
427 (
428 self._start,
429 self._stop,
430 ),
431 )
433 @classmethod
434 def __get_pydantic_core_schema__(
435 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
436 ) -> pcs.CoreSchema:
437 from_typed_dict = pcs.chain_schema(
438 [
439 handler(_SerializedInterval),
440 pcs.no_info_plain_validator_function(cls._validate),
441 ]
442 )
443 return pcs.json_or_python_schema(
444 json_schema=from_typed_dict,
445 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
446 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
447 )
449 @classmethod
450 def __get_pydantic_json_schema__(
451 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
452 ) -> JsonSchemaValue:
453 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
455 @classmethod
456 def _validate(cls, data: _SerializedInterval) -> Interval:
457 return cls(**data)
459 def _serialize(self) -> _SerializedInterval:
460 return {"start": self._start, "stop": self._stop}
463class IntervalSliceFactory:
464 """A factory for `Interval` objects using array-slice syntax.
466 Notes
467 -----
468 When indexed with a single slice on the `Interval.factory` attribute, this
469 returns an `Interval` with exactly the given bounds::
471 assert Interval.factory[3:6] == Interval(start=3, stop=6)
473 A missing start bound is replaced by ``0``, but a missing stop bound is
474 not allowed.
476 When obtained from the `Interval.absolute` property, indices are absolute
477 coordinate values, but any omitted bounds are replaced with the parent
478 interval's bounds::
480 parent = Interval.factory[3:6]
481 assert Interval.factory[4:5] == parent.absolute[:5]
483 The final interval is also checked to be contained by the parent interval.
485 When obtained from the `Interval.local` property, indices are interpreted
486 as relative to the parent interval, and negative indices are relative to
487 the end (like `~collections.abc.Sequence` indexing)::
489 parent = Interval.factory[3:6]
490 assert Interval.factory[4:5] == parent.local[1:-1]
492 When the stop bound is greater than the size of the parent interval, the
493 returned interval is clipped to be contained by the parent (as in
494 `~collections.abc.Sequence` indexing).
495 """
497 def __init__(self, parent: Interval | None = None, is_local: bool = False):
498 self._parent = parent
499 self._is_local = is_local
501 def __getitem__(self, s: slice) -> Interval:
502 if s.step is not None and s.step != 1:
503 raise ValueError(f"Slice {s} has non-unit step.")
504 if self._is_local:
505 assert self._parent is not None, "is_local=True requires a parent interval"
506 start, stop, _ = s.indices(self._parent.size)
507 start += self._parent.start
508 stop += self._parent.start
509 else:
510 start = s.start
511 stop = s.stop
512 if start is None:
513 if self._parent is None:
514 start = 0
515 else:
516 start = self._parent.start
517 if stop is None:
518 if self._parent is None: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true
519 raise IndexError("An Interval cannot have an empty upper bound.")
520 stop = self._parent.stop
521 if self._parent is not None:
522 if start < self._parent.start:
523 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
524 if stop > self._parent.stop:
525 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
526 return Interval(start=start, stop=stop)
529Interval.factory = IntervalSliceFactory()
532class _SerializedBox(TypedDict):
533 y: _SerializedInterval
534 x: _SerializedInterval
537class Box:
538 """An axis-aligned 2-d rectangular region.
540 Parameters
541 ----------
542 y
543 Interval for the y dimension.
544 x
545 Interval for the x dimension.
547 Notes
548 -----
549 `Box` implements the necessary hooks to be included directly in a
550 `pydantic.BaseModel`, even though it is neither a built-in type nor a
551 Pydantic model itself.
552 """
554 def __init__(self, y: Interval, x: Interval):
555 self._intervals = YX(y, x)
557 __slots__ = ("_intervals",)
559 factory: ClassVar[BoxSliceFactory]
560 """A factory for creating boxes using slice syntax.
562 For example::
564 box = Box.factory[2:5, 3:9]
565 """
567 @classmethod
568 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
569 """Construct a box from its shape and optional start.
571 Parameters
572 ----------
573 shape
574 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
575 start
576 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
577 """
578 if start is None:
579 start = (0,) * len(shape)
580 match shape:
581 case XY(x=x_size, y=y_size):
582 pass
583 case [y_size, x_size]:
584 pass
585 case _:
586 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
587 match start:
588 case XY(x=x_start, y=y_start):
589 pass
590 case [y_start, x_start]:
591 pass
592 case _:
593 raise ValueError(f"Invalid sequence for start: {start!r}.")
594 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
596 @property
597 def min(self) -> YX[int]:
598 """The inclusive minimum bounds of the box, ordered ``(y, x)``
599 (`YX` [`int`]).
600 """
601 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
603 @property
604 def max(self) -> YX[int]:
605 """The inclusive maximum bounds of the box, ordered ``(y, x)``
606 (`YX` [`int`]).
607 """
608 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
610 @property
611 def start(self) -> YX[int]:
612 """Tuple holding the inclusive `Interval.start` bvound, ordered
613 ``(y, x)`` (`YX` [`int`]).
615 This is an alias for `min`, typically paired with `stop` for
616 half-exclusive ranges.
617 """
618 return YX(self.y.start, self.x.start)
620 @property
621 def stop(self) -> YX[int]:
622 """Tuple holding the exclusive `Interval.stop` bound, ordered
623 ``(y, x)`` (`YX` [`int`]).
625 The values in this tuple are one greater than those in `max`. It is
626 typically paired with `start` for half-exclusive ranges.
627 """
628 return YX(self.y.stop, self.x.stop)
630 @property
631 def shape(self) -> YX[int]:
632 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
633 (`YX` [`int`]).
634 """
635 return YX(self.y.size, self.x.size)
637 @property
638 def x(self) -> Interval:
639 """The x-dimension interval (`int`)."""
640 return self._intervals[-1]
642 @property
643 def y(self) -> Interval:
644 """The y-dimension interval (`int`)."""
645 return self._intervals[-2]
647 @property
648 def absolute(self) -> BoxSliceFactory:
649 """A factory for constructing a contained `Box` using slice
650 syntax and absolute coordinates.
652 Notes
653 -----
654 Slice bounds that are absent are replaced with the bounds of ``self``.
655 """
656 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
658 @property
659 def local(self) -> BoxSliceFactory:
660 """A factory for constructing a contained `Interval` using a slice
661 relative to the start of this one (`BoxSliceFactory`).
663 Notes
664 -----
665 This factory interprets slices as "local" coordinates, in which ``0``
666 corresponds to ``self.start``. Negative bounds are relative to
667 ``self.stop``, as is usually the case for Python sequences.
668 """
669 return BoxSliceFactory(y=self.y.local, x=self.x.local)
671 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
672 """Return a pair of 2-d arrays of the coordinate values of the box.
674 Parameters
675 ----------
676 n
677 Number of points in each dimension. If a sequence, points are
678 assumed to be ordered ``(x, y)`` unless a `YX` instance is
679 provided.
680 step
681 Set ``n`` such that the distance between points is equal to or
682 just less than this in each dimension. Mutually exclusive with
683 ``n``.
685 Returns
686 -------
687 `XY` [`numpy.ndarray`]
688 A pair of arrays, each of which is 2-d with floating-point values.
690 See Also
691 --------
692 numpy.meshgrid
693 """
694 if n is not None and step is not None:
695 raise TypeError("'n' and 'step' cannot both be provided.")
696 match n:
697 case int():
698 ax = self.x.linspace(n)
699 ay = self.y.linspace(n)
700 case YX(y=ny, x=nx):
701 ax = self.x.linspace(nx)
702 ay = self.y.linspace(ny)
703 case [nx, ny]:
704 ax = self.x.linspace(nx)
705 ay = self.y.linspace(ny)
706 case None:
707 ax = self.x.linspace(step=step)
708 ay = self.y.linspace(step=step)
709 case _:
710 raise ValueError(f"Unexpected values for n ({n})")
711 return XY(*np.meshgrid(ax, ay))
713 def padded(self, padding: int) -> Box:
714 """Return a new box expanded by the given padding on
715 all sides.
716 """
717 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
719 def __eq__(self, other: object) -> bool:
720 if type(other) is Box:
721 return self._intervals == other._intervals
722 return False
724 def __str__(self) -> str:
725 return f"[y={self.y}, x={self.x}]"
727 def __repr__(self) -> str:
728 return f"Box(y={self.y!r}, x={self.x!r})"
730 @overload
731 def contains(self, other: Box, /) -> bool: ... 731 ↛ exitline 731 didn't return from function 'contains' because
733 @overload
734 def contains(self, *, y: int, x: int) -> bool: ... 734 ↛ exitline 734 didn't return from function 'contains' because
736 @overload
737 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 737 ↛ exitline 737 didn't return from function 'contains' because
739 def contains(
740 self,
741 other: Box | None = None,
742 *,
743 y: int | np.ndarray | None = None,
744 x: int | np.ndarray | None = None,
745 ) -> bool | np.ndarray:
746 """Test whether this box fully contains another or one or more points.
748 Parameters
749 ----------
750 other
751 Another box to compare to. Not compatible with the ``y`` and ``x``
752 arguments.
753 y
754 One or more integer Y coordinates to test for containment.
755 If an array, an array of results will be returned.
756 x
757 One or more integer X coordinates to test for containment.
758 If an array, an array of results will be returned.
760 Returns
761 -------
762 `bool` | `numpy.ndarray`
763 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
764 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
765 array with their broadcasted shape.
767 Notes
768 -----
769 In order to yield the desired behavior for floating-point arguments,
770 points are actually tested against an interval that is 0.5 larger on
771 both sides: this makes positions within the outer boundary of pixels
772 (but beyond the centers of those pixels, which have integer positions)
773 appear "on the image".
774 """
775 if other is not None:
776 if x is not None or y is not None:
777 raise TypeError("Too many arguments to 'Box.contains'.")
778 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
779 elif x is None or y is None:
780 raise TypeError("Not enough arguments to 'Box.contains'.")
781 else:
782 result = np.logical_and(self.x.contains(x), self.y.contains(y))
783 if not result.shape:
784 return bool(result)
785 return result
787 @overload
788 def intersection(self, other: Box) -> Box: ... 788 ↛ exitline 788 didn't return from function 'intersection' because
790 @overload
791 def intersection(self, other: Bounds) -> Bounds: ... 791 ↛ exitline 791 didn't return from function 'intersection' because
793 def intersection(self, other: Bounds) -> Bounds:
794 """Return a bounds object that is contained by both ``self`` and
795 ``other``.
797 When there is no overlap, `NoOverlapError` is raised.
798 """
799 from ._concrete_bounds import _intersect_box
801 return _intersect_box(self, other)
803 def dilated_by(self, padding: int) -> Box:
804 """Return a new box padded by the given amount on all sides."""
805 return Box(*[i.dilated_by(padding) for i in self._intervals])
807 def slice_within(self, other: Box) -> YX[slice]:
808 """Return a `tuple` of `slice` objects that correspond to the
809 positions in this box when the items of the container being sliced
810 correspond to ``other``.
812 This assumes ``other.contains(self)``.
813 """
814 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
816 @property
817 def bbox(self) -> Box:
818 """The box itself (`Box`).
820 This is provided for compatibility with the `Bounds` interface.
821 """
822 return self
824 def boundary(self) -> Iterator[YX[int]]:
825 """Iterate over the corners of the box as ``(y, x)`` tuples."""
826 if len(self._intervals) != 2: 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true
827 raise TypeError("Box is not 2-d.")
828 yield YX(self.y.min, self.x.min)
829 yield YX(self.y.min, self.x.max)
830 yield YX(self.y.max, self.x.max)
831 yield YX(self.y.max, self.x.min)
833 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
834 return (Box, self._intervals)
836 @classmethod
837 def from_legacy(cls, legacy: Any) -> Box:
838 """Convert from an `lsst.geom.Box2I` instance."""
839 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
841 def to_legacy(self) -> Any:
842 """Convert to an `lsst.geom.BoxI` instance."""
843 from lsst.geom import Box2I
845 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
847 @classmethod
848 def __get_pydantic_core_schema__(
849 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
850 ) -> pcs.CoreSchema:
851 from_typed_dict = pcs.chain_schema(
852 [
853 handler(_SerializedBox),
854 pcs.no_info_plain_validator_function(cls._validate),
855 ]
856 )
857 return pcs.json_or_python_schema(
858 json_schema=from_typed_dict,
859 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
860 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
861 )
863 @classmethod
864 def __get_pydantic_json_schema__(
865 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
866 ) -> JsonSchemaValue:
867 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
869 @classmethod
870 def _validate(cls, data: _SerializedBox) -> Box:
871 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
873 def _serialize(self) -> _SerializedBox:
874 return {"y": self.y._serialize(), "x": self.x._serialize()}
876 def serialize(self) -> Box:
877 """Return a Pydantic-friendly representation of this object.
879 This method just returns the `Box` itself, since that already provides
880 Pydantic serialization hooks. It exists for compatibility with the
881 `Bounds` protocol.
882 """
883 return self
885 def deserialize(self) -> Box:
886 """Deserialize a bounds object on the assumption it is a `Box`.
888 This method just returns the `Box` itself, since that already provides
889 Pydantic serialization hooks. It exists for compatibility with the
890 `Bounds` protocol.
891 """
892 return self
895class BoxSliceFactory:
896 """A factory for `Box` objects using array-slice syntax.
898 Notes
899 -----
900 When `Box.factory` is indexed with a pair of slices, this returns a
901 `Box` with exactly those bounds::
903 assert (
904 Box.factory[3:6, -1:2]
905 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
906 )
908 A missing start bound is replaced by ``0``, but a missing stop bound is
909 not allowed.
911 When obtained from the `Box.absolute` property, indices are absolute
912 coordinate values, but any omitted bounds are replaced with the parent
913 box's bounds::
915 parent = Box.factory[3:6, -1:2]
916 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
918 The final box is also checked to be contained by the parent box.
920 When obtained from the `Box.local` property, indices are interpreted
921 as relative to the parent box, and negative indices are relative to
922 the end (like `~collections.abc.Sequence` indexing)::
924 parent = Box.factory[3:6, -1:2]
925 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
926 """
928 def __init__(
929 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
930 ):
931 self._y = y
932 self._x = x
934 def __getitem__(self, key: tuple[slice, slice]) -> Box:
935 match key:
936 case XY(x=x, y=y):
937 return Box(y=self._y[y], x=self._x[x])
938 case (y, x):
939 return Box(y=self._y[y], x=self._x[x])
940 case _:
941 raise TypeError("Expected exactly two slices.")
944Box.factory = BoxSliceFactory()
947class Bounds(Protocol):
948 """A protocol for objects that represent the validity region for a function
949 defined in 2-d pixel coordinates.
951 Notes
952 -----
953 Most objects natively have a simple 2-d bounding box as their bounds
954 (typically the boundary of a sensor), and the `Box` class is hence the
955 most common bounds implementation. But sometimes a large chunk of that
956 box may be missing due to vignetting or bad amplifiers, and we may want to
957 transform from one coordinate system to another. The Bounds interface is
958 intended to handle both of these cases as well.
959 """
961 @property
962 def bbox(self) -> Box: ... 962 ↛ exitline 962 didn't return from function 'bbox' because
964 @overload
965 def contains(self, *, x: int, y: int) -> bool: ... 965 ↛ exitline 965 didn't return from function 'contains' because
967 @overload
968 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 968 ↛ exitline 968 didn't return from function 'contains' because
970 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
971 """Test whether this box fully contains another or one or more points.
973 Parameters
974 ----------
975 x
976 One or more integer X coordinates to test for containment.
977 If an array, an array of results will be returned.
978 y
979 One or more integer Y coordinates to test for containment.
980 If an array, an array of results will be returned.
982 Returns
983 -------
984 `bool` | `numpy.ndarray`
985 If ``x`` and ``y`` are both scalars, a single `bool` value. If
986 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
987 shape.
988 """
989 ...
991 def intersection(self, other: Bounds) -> Bounds:
992 """Compute the intersection of this bounds object with another."""
993 ...
995 def serialize(self) -> SerializableBounds:
996 """Convert a bounds instance into a serializable object.
998 Notes
999 -----
1000 The returned object must support direct nesting with Pydantic models
1001 and have a ``deserialize`` method (taking no arguments) that converts
1002 back to this `Bounds` type. It is common for `serialize` and
1003 ``deserialize`` to just return ``self``, when the bounds object is
1004 natively serializable.
1005 """
1006 ...
1009class BoundsError(ValueError):
1010 """Exception raised when an object is evaluated outside its bounds."""
1013class NoOverlapError(ValueError):
1014 """Exception raised when intervals or bounds do not overlap."""