Coverage for python/lsst/images/_geom.py: 41%
368 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 03:25 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-10 03:25 -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
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 start(self) -> int:
224 """Inclusive minimum point in the interval (`int`)."""
225 return self._start
227 @property
228 def stop(self) -> int:
229 """One past the maximum point in the interval (`int`)."""
230 return self._stop
232 @property
233 def min(self) -> int:
234 """Inclusive minimum point in the interval (`int`)."""
235 return self.start
237 @property
238 def max(self) -> int:
239 """Inclusive maximum point in the interval (`int`)."""
240 return self.stop - 1
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:
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 start(self) -> YX[int]:
598 """Tuple holding the starts of the intervals ordered ``(y, x)``
599 (`YX` [`int`]).
600 """
601 return YX(self.y.start, self.x.start)
603 @property
604 def shape(self) -> YX[int]:
605 """Tuple holding the sizes of the intervals ordered ``(y, x)``
606 (`YX` [`int`]).
607 """
608 return YX(self.y.size, self.x.size)
610 @property
611 def x(self) -> Interval:
612 """The x-dimension interval (`int`)."""
613 return self._intervals[-1]
615 @property
616 def y(self) -> Interval:
617 """The y-dimension interval (`int`)."""
618 return self._intervals[-2]
620 @property
621 def min_yx(self) -> YX[int]:
622 """The minimum bounds of the box as a `YX` tuple."""
623 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
625 @property
626 def max_yx(self) -> YX[int]:
627 """The maximum bounds of the box as a `YX` tuple."""
628 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
630 @property
631 def absolute(self) -> BoxSliceFactory:
632 """A factory for constructing a contained `Box` using slice
633 syntax and absolute coordinates.
635 Notes
636 -----
637 Slice bounds that are absent are replaced with the bounds of ``self``.
638 """
639 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
641 @property
642 def local(self) -> BoxSliceFactory:
643 """A factory for constructing a contained `Interval` using a slice
644 relative to the start of this one (`BoxSliceFactory`).
646 Notes
647 -----
648 This factory interprets slices as "local" coordinates, in which ``0``
649 corresponds to ``self.start``. Negative bounds are relative to
650 ``self.stop``, as is usually the case for Python sequences.
651 """
652 return BoxSliceFactory(y=self.y.local, x=self.x.local)
654 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
655 """Return a pair of 2-d arrays of the coordinate values of the box.
657 Parameters
658 ----------
659 n
660 Number of points in each dimension. If a sequence, points are
661 assumed to be ordered ``(x, y)`` unless a `YX` instance is
662 provided.
663 step
664 Set ``n`` such that the distance between points is equal to or
665 just less than this in each dimension. Mutually exclusive with
666 ``n``.
668 Returns
669 -------
670 `XY` [`numpy.ndarray`]
671 A pair of arrays, each of which is 2-d with floating-point values.
673 See Also
674 --------
675 numpy.meshgrid
676 """
677 if n is not None and step is not None:
678 raise TypeError("'n' and 'step' cannot both be provided.")
679 match n:
680 case int():
681 ax = self.x.linspace(n)
682 ay = self.y.linspace(n)
683 case YX(y=ny, x=nx):
684 ax = self.x.linspace(nx)
685 ay = self.y.linspace(ny)
686 case [nx, ny]:
687 ax = self.x.linspace(nx)
688 ay = self.y.linspace(ny)
689 case None:
690 ax = self.x.linspace(step=step)
691 ay = self.y.linspace(step=step)
692 case _:
693 raise ValueError(f"Unexpected values for n ({n})")
694 return XY(*np.meshgrid(ax, ay))
696 def padded(self, padding: int) -> Box:
697 """Return a new box expanded by the given padding on
698 all sides.
699 """
700 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
702 def __eq__(self, other: object) -> bool:
703 if type(other) is Box:
704 return self._intervals == other._intervals
705 return False
707 def __str__(self) -> str:
708 return f"[y={self.y}, x={self.x}]"
710 def __repr__(self) -> str:
711 return f"Box(y={self.y!r}, x={self.x!r})"
713 @overload
714 def contains(self, other: Box, /) -> bool: ... 714 ↛ exitline 714 didn't return from function 'contains' because
716 @overload
717 def contains(self, *, y: int, x: int) -> bool: ... 717 ↛ exitline 717 didn't return from function 'contains' because
719 @overload
720 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 720 ↛ exitline 720 didn't return from function 'contains' because
722 def contains(
723 self,
724 other: Box | None = None,
725 *,
726 y: int | np.ndarray | None = None,
727 x: int | np.ndarray | None = None,
728 ) -> bool | np.ndarray:
729 """Test whether this box fully contains another or one or more points.
731 Parameters
732 ----------
733 other
734 Another box to compare to. Not compatible with the ``y`` and ``x``
735 arguments.
736 y
737 One or more integer Y coordinates to test for containment.
738 If an array, an array of results will be returned.
739 x
740 One or more integer X coordinates to test for containment.
741 If an array, an array of results will be returned.
743 Returns
744 -------
745 `bool` | `numpy.ndarray`
746 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
747 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
748 array with their broadcasted shape.
750 Notes
751 -----
752 In order to yield the desired behavior for floating-point arguments,
753 points are actually tested against an interval that is 0.5 larger on
754 both sides: this makes positions within the outer boundary of pixels
755 (but beyond the centers of those pixels, which have integer positions)
756 appear "on the image".
757 """
758 if other is not None:
759 if x is not None or y is not None:
760 raise TypeError("Too many arguments to 'Box.contains'.")
761 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
762 elif x is None or y is None:
763 raise TypeError("Not enough arguments to 'Box.contains'.")
764 else:
765 result = np.logical_and(self.x.contains(x), self.y.contains(y))
766 if not result.shape:
767 return bool(result)
768 return result
770 @overload
771 def intersection(self, other: Box) -> Box: ... 771 ↛ exitline 771 didn't return from function 'intersection' because
773 @overload
774 def intersection(self, other: Bounds) -> Bounds: ... 774 ↛ exitline 774 didn't return from function 'intersection' because
776 def intersection(self, other: Bounds) -> Bounds:
777 """Return a bounds object that is contained by both ``self`` and
778 ``other``.
780 When there is no overlap, `NoOverlapError` is raised.
781 """
782 from ._concrete_bounds import _intersect_box
784 return _intersect_box(self, other)
786 def dilated_by(self, padding: int) -> Box:
787 """Return a new box padded by the given amount on all sides."""
788 return Box(*[i.dilated_by(padding) for i in self._intervals])
790 def slice_within(self, other: Box) -> YX[slice]:
791 """Return a `tuple` of `slice` objects that correspond to the
792 positions in this box when the items of the container being sliced
793 correspond to ``other``.
795 This assumes ``other.contains(self)``.
796 """
797 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
799 @property
800 def bbox(self) -> Box:
801 """The box itself (`Box`).
803 This is provided for compatibility with the `Bounds` interface.
804 """
805 return self
807 def boundary(self) -> Iterator[YX[int]]:
808 """Iterate over the corners of the box as ``(y, x)`` tuples."""
809 if len(self._intervals) != 2:
810 raise TypeError("Box is not 2-d.")
811 yield YX(self.y.min, self.x.min)
812 yield YX(self.y.min, self.x.max)
813 yield YX(self.y.max, self.x.max)
814 yield YX(self.y.max, self.x.min)
816 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
817 return (Box, self._intervals)
819 @classmethod
820 def from_legacy(cls, legacy: Any) -> Box:
821 """Convert from an `lsst.geom.Box2I` instance."""
822 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
824 def to_legacy(self) -> Any:
825 """Convert to an `lsst.geom.BoxI` instance."""
826 from lsst.geom import Box2I
828 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
830 @classmethod
831 def __get_pydantic_core_schema__(
832 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
833 ) -> pcs.CoreSchema:
834 from_typed_dict = pcs.chain_schema(
835 [
836 handler(_SerializedBox),
837 pcs.no_info_plain_validator_function(cls._validate),
838 ]
839 )
840 return pcs.json_or_python_schema(
841 json_schema=from_typed_dict,
842 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
843 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
844 )
846 @classmethod
847 def __get_pydantic_json_schema__(
848 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
849 ) -> JsonSchemaValue:
850 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
852 @classmethod
853 def _validate(cls, data: _SerializedBox) -> Box:
854 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
856 def _serialize(self) -> _SerializedBox:
857 return {"y": self.y._serialize(), "x": self.x._serialize()}
859 def serialize(self) -> Box:
860 """Return a Pydantic-friendly representation of this object.
862 This method just returns the `Box` itself, since that already provides
863 Pydantic serialization hooks. It exists for compatibility with the
864 `Bounds` protocol.
865 """
866 return self
868 def deserialize(self) -> Box:
869 """Deserialize a bounds object on the assumption it is a `Box`.
871 This method just returns the `Box` itself, since that already provides
872 Pydantic serialization hooks. It exists for compatibility with the
873 `Bounds` protocol.
874 """
875 return self
878class BoxSliceFactory:
879 """A factory for `Box` objects using array-slice syntax.
881 Notes
882 -----
883 When `Box.factory` is indexed with a pair of slices, this returns a
884 `Box` with exactly those bounds::
886 assert (
887 Box.factory[3:6, -1:2]
888 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
889 )
891 A missing start bound is replaced by ``0``, but a missing stop bound is
892 not allowed.
894 When obtained from the `Box.absolute` property, indices are absolute
895 coordinate values, but any omitted bounds are replaced with the parent
896 box's bounds::
898 parent = Box.factory[3:6, -1:2]
899 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
901 The final box is also checked to be contained by the parent box.
903 When obtained from the `Box.local` property, indices are interpreted
904 as relative to the parent box, and negative indices are relative to
905 the end (like `~collections.abc.Sequence` indexing)::
907 parent = Box.factory[3:6, -1:2]
908 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
909 """
911 def __init__(
912 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
913 ):
914 self._y = y
915 self._x = x
917 def __getitem__(self, key: tuple[slice, slice]) -> Box:
918 match key:
919 case XY(x=x, y=y):
920 return Box(y=self._y[y], x=self._x[x])
921 case (y, x):
922 return Box(y=self._y[y], x=self._x[x])
923 case _:
924 raise TypeError("Expected exactly two slices.")
927Box.factory = BoxSliceFactory()
930class Bounds(Protocol):
931 """A protocol for objects that represent the validity region for a function
932 defined in 2-d pixel coordinates.
934 Notes
935 -----
936 Most objects natively have a simple 2-d bounding box as their bounds
937 (typically the boundary of a sensor), and the `Box` class is hence the
938 most common bounds implementation. But sometimes a large chunk of that
939 box may be missing due to vignetting or bad amplifiers, and we may want to
940 transform from one coordinate system to another. The Bounds interface is
941 intended to handle both of these cases as well.
942 """
944 @property
945 def bbox(self) -> Box: ... 945 ↛ exitline 945 didn't return from function 'bbox' because
947 @overload
948 def contains(self, *, x: int, y: int) -> bool: ... 948 ↛ exitline 948 didn't return from function 'contains' because
950 @overload
951 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 951 ↛ exitline 951 didn't return from function 'contains' because
953 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
954 """Test whether this box fully contains another or one or more points.
956 Parameters
957 ----------
958 x
959 One or more integer X coordinates to test for containment.
960 If an array, an array of results will be returned.
961 y
962 One or more integer Y coordinates to test for containment.
963 If an array, an array of results will be returned.
965 Returns
966 -------
967 `bool` | `numpy.ndarray`
968 If ``x`` and ``y`` are both scalars, a single `bool` value. If
969 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
970 shape.
971 """
972 ...
974 def intersection(self, other: Bounds) -> Bounds:
975 """Compute the intersection of this bounds object with another."""
976 ...
978 def serialize(self) -> SerializableBounds:
979 """Convert a bounds instance into a serializable object.
981 Notes
982 -----
983 The returned object must support direct nesting with Pydantic models
984 and have a ``deserialize`` method (taking no arguments) that converts
985 back to this `Bounds` type. It is common for `serialize` and
986 ``deserialize`` to just return ``self``, when the bounds object is
987 natively serializable.
988 """
989 ...
992class BoundsError(ValueError):
993 """Exception raised when an object is evaluated outside its bounds."""
996class NoOverlapError(ValueError):
997 """Exception raised when intervals or bounds do not overlap."""