Coverage for python/lsst/images/_geom.py: 94%
375 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-27 01:38 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-27 01:38 -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
45from .utils import round_half_down, round_half_up
47if TYPE_CHECKING:
48 from ._concrete_bounds import SerializableBounds
50 try:
51 from lsst.geom import Extent2I as LegacyExtent2I
52 from lsst.geom import Point2I as LegacyPoint2I
53 except ImportError:
54 type LegacyExtent2I = Any # type: ignore[no-redef]
55 type LegacyPoint2I = Any # type: ignore[no-redef]
57# This pre-python-3.12 declaration is needed by Sphinx (probably the
58# autodoc-typehints plugin.
59T = TypeVar("T")
61# Interval and Box are defined as regular Python classes rather than
62# dataclasses or Pydantic models because we might want to implement them as
63# compiled-extension types in the future, and we want that to be transparent.
65# In a similar vein, we avoid declaring specific types for multidimensional
66# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
67# shapes) in order to leave room for more fully-featured types to be added
68# upstream of this package in the future.
71class YX[T](NamedTuple):
72 """A pair of per-dimension objects, ordered ``(y, x)``.
74 Notes
75 -----
76 `YX` is used for slices, shapes, and other 2-d pairs when the most
77 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
78 arithmetic operations behave as they would on a
79 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
80 concatenates).
82 See Also
83 --------
84 XY
85 """
87 y: T
88 """The y / row object."""
90 x: T
91 """The x / column object."""
93 @property
94 def xy(self) -> XY:
95 """A tuple of the same objects in the opposite order."""
96 return XY(x=self.x, y=self.y)
98 def map[U](self, func: Callable[[T], U]) -> YX[U]:
99 """Apply a function to both objects.
101 Parameters
102 ----------
103 func
104 Callable applied to each of the two objects in turn.
105 """
106 return YX(y=func(self.y), x=func(self.x))
108 def to_legacy_extent(self) -> LegacyExtent2I:
109 """Convert to a legacy `lsst.geom.Extent2I` object."""
110 from lsst.geom import Extent2I as LegacyExtent2I
112 return LegacyExtent2I(self.x, self.y)
114 def to_legacy_point(self) -> LegacyPoint2I:
115 """Convert to a legacy `lsst.geom.Point2I` object."""
116 from lsst.geom import Point2I as LegacyPoint2I
118 return LegacyPoint2I(self.x, self.y)
121class XY[T](NamedTuple):
122 """A pair of per-dimension objects, ordered ``(x, y)``.
124 Notes
125 -----
126 `XY` is used for points and other 2-d pairs when the most natural ordering
127 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
128 behave as they would on a `collections.abc.Sequence`, not a mathematical
129 vector (e.g. adding concatenates).
131 See Also
132 --------
133 YX
134 """
136 x: T
137 """The x / column object."""
139 y: T
140 """The y / row object."""
142 @property
143 def yx(self) -> YX:
144 """A tuple of the same objects in the opposite order."""
145 return YX(y=self.y, x=self.x)
147 def map[U](self, func: Callable[[T], U]) -> XY[U]:
148 """Apply a function to both objects.
150 Parameters
151 ----------
152 func
153 Callable applied to each of the two objects in turn.
154 """
155 return XY(x=func(self.x), y=func(self.y))
157 def to_legacy_extent(self) -> LegacyExtent2I:
158 """Convert to a legacy `lsst.geom.Extent2I` object."""
159 from lsst.geom import Extent2I as LegacyExtent2I
161 return LegacyExtent2I(self.x, self.y)
163 def to_legacy_point(self) -> LegacyPoint2I:
164 """Convert to a legacy `lsst.geom.Point2I` object."""
165 from lsst.geom import Point2I as LegacyPoint2I
167 return LegacyPoint2I(self.x, self.y)
170class _SerializedInterval(TypedDict):
171 start: int
172 stop: int
175@final
176class Interval:
177 """A 1-d integer interval with positive size.
179 Parameters
180 ----------
181 start
182 Inclusive minimum point in the interval.
183 stop
184 One past the maximum point in the interval.
186 Notes
187 -----
188 Adding or subtracting an `int` from an interval returns a shifted interval.
190 `Interval` implements the necessary hooks to be included directly in a
191 `pydantic.BaseModel`, even though it is neither a built-in type nor a
192 Pydantic model itself.
193 """
195 def __init__(self, start: int, stop: int) -> None:
196 # Coerce to be defensive against numpy int scalars.
197 self._start = int(start)
198 self._stop = int(stop)
199 if not (self._stop > self._start):
200 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
202 __slots__ = ("_start", "_stop")
204 factory: ClassVar[IntervalSliceFactory]
205 """A factory for creating intervals using slice syntax.
207 For example::
209 interval = Interval.factory[2:5]
210 """
212 @classmethod
213 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
214 """Construct an interval that includes all of the given points and/or
215 intervals.
217 Parameters
218 ----------
219 first
220 First point or interval to include in the hull.
221 *args
222 Additional points and/or intervals to include in the hull.
223 """
224 if type(first) is Interval:
225 rmin = first.min
226 rmax = first.max
227 else:
228 rmin = rmax = first
229 for arg in args:
230 if type(arg) is Interval:
231 rmin = min(rmin, arg.min)
232 rmax = max(rmax, arg.max)
233 else:
234 rmin = min(rmin, arg)
235 rmax = max(rmax, arg)
236 return Interval(start=rmin, stop=rmax + 1)
238 @classmethod
239 def from_size(cls, size: int, start: int = 0) -> Interval:
240 """Construct an interval from its size and optional start.
242 Parameters
243 ----------
244 size
245 Number of points in the interval.
246 start
247 Inclusive minimum point in the interval.
248 """
249 return cls(start=start, stop=start + size)
251 @property
252 def min(self) -> int:
253 """Inclusive minimum point in the interval (`int`)."""
254 return self.start
256 @property
257 def max(self) -> int:
258 """Inclusive maximum point in the interval (`int`)."""
259 return self.stop - 1
261 @property
262 def start(self) -> int:
263 """Inclusive minimum point in the interval (`int`)."""
264 return self._start
266 @property
267 def stop(self) -> int:
268 """One past the maximum point in the interval (`int`)."""
269 return self._stop
271 @property
272 def size(self) -> int:
273 """Size of the interval (`int`)."""
274 return self.stop - self.start
276 @property
277 def range(self) -> __builtins__.range:
278 """An iterable over all values in the interval
279 (`__builtins__.range`).
280 """
281 return range(self.start, self.stop)
283 @property
284 def arange(self) -> np.ndarray:
285 """An array of all the values in the interval (`numpy.ndarray`).
287 Array values are integers.
288 """
289 return np.arange(self.start, self.stop)
291 @property
292 def absolute(self) -> IntervalSliceFactory:
293 """A factory for constructing a contained `Interval` using slice
294 syntax and absolute coordinates.
296 Notes
297 -----
298 Slice bounds that are absent are replaced with the bounds of ``self``.
299 """
300 return IntervalSliceFactory(self, is_local=False)
302 @property
303 def local(self) -> IntervalSliceFactory:
304 """A factory for constructing a contained `Interval` using a slice
305 relative to the start of this one (`IntervalSliceFactory`).
307 Notes
308 -----
309 This factory interprets slices as "local" coordinates, in which ``0``
310 corresponds to ``self.start``. Negative bounds are relative to
311 ``self.stop``, as is usually the case for Python sequences.
312 """
313 return IntervalSliceFactory(self, is_local=True)
315 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
316 """Return an array of values that spans the interval.
318 Parameters
319 ----------
320 n
321 How many values to return. The default (if ``step`` is also not
322 provided) is the size of the interval, i.e. equivalent to the
323 `arange` property (but converted to `float`).
324 step
325 Set ``n`` such that the distance between points is equal to or
326 just less than this. Mutually exclusive with ``n``.
328 Returns
329 -------
330 numpy.ndarray
331 Array of `float` values.
333 See Also
334 --------
335 numpy.linspace
336 """
337 if n is None:
338 if step is None:
339 return self.arange.astype(np.float64)
340 n = math.ceil(self.size / step)
341 elif step is not None:
342 raise TypeError("'n' and 'step' cannot both be provided.")
343 return np.linspace(self.min, self.max, n, dtype=np.float64)
345 @property
346 def center(self) -> float:
347 """The center of the interval (`float`)."""
348 return 0.5 * (self.min + self.max)
350 def padded(self, padding: int) -> Interval:
351 """Return a new interval expanded by the given padding on
352 either side.
354 Parameters
355 ----------
356 padding
357 Number of points to add to each side of the interval.
358 """
359 return Interval(self.start - padding, self.stop + padding)
361 def __str__(self) -> str:
362 return f"{self.start}:{self.stop}"
364 def __repr__(self) -> str:
365 return f"Interval(start={self.start}, stop={self.stop})"
367 def __eq__(self, other: object) -> bool:
368 if type(other) is Interval:
369 return self._start == other._start and self._stop == other._stop
370 return False
372 def __add__(self, other: int) -> Interval:
373 return Interval(start=self.start + other, stop=self.stop + other)
375 def __sub__(self, other: int) -> Interval:
376 return Interval(start=self.start - other, stop=self.stop - other)
378 def __contains__(self, x: int) -> bool:
379 return x >= self.start and x < self.stop
381 @overload
382 def contains(self, other: Interval | int | float) -> bool: ... 382 ↛ exitline 382 didn't return from function 'contains' because
384 @overload
385 def contains(self, other: np.ndarray) -> np.ndarray: ... 385 ↛ exitline 385 didn't return from function 'contains' because
387 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
388 """Test whether this interval fully contains another or one or more
389 points.
391 Parameters
392 ----------
393 other
394 Another interval to compare to, or one or more position values.
396 Returns
397 -------
398 `bool` | `numpy.ndarray`
399 If a single interval or value was passed, a single `bool`. If an
400 array was passed, an array with the same shape.
402 Notes
403 -----
404 In order to yield the desired behavior for floating-point arguments,
405 points are actually tested against an interval that is 0.5 larger on
406 both sides: this makes positions within the outer boundary of pixels
407 (but beyond the centers of those pixels, which have integer positions)
408 appear "on the image".
409 """
410 if isinstance(other, Interval):
411 return self.start <= other.start and self.stop >= other.stop
412 else:
413 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
414 if not result.shape:
415 return bool(result)
416 return result
418 def intersection(self, other: Interval) -> Interval:
419 """Return an interval that is contained by both ``self`` and ``other``.
421 When there is no overlap between the intervals, `NoOverlapError` is
422 raised.
424 Parameters
425 ----------
426 other
427 Interval to intersect with this one.
428 """
429 new_start = max(self.start, other.start)
430 new_stop = min(self.stop, other.stop)
431 if new_start < new_stop:
432 return Interval(start=new_start, stop=new_stop)
433 raise NoOverlapError(f"No overlap between {self} and {other}.")
435 def dilated_by(self, padding: int) -> Interval:
436 """Return a new interval padded by the given amount on both sides.
438 Parameters
439 ----------
440 padding
441 Number of points to add to each side of the interval.
442 """
443 return Interval(start=self._start - padding, stop=self._stop + padding)
445 def slice_within(self, other: Interval) -> slice:
446 """Return the `slice` that corresponds to the values in this interval
447 when the items of the container being sliced correspond to ``other``.
449 This assumes ``other.contains(self)``.
451 Parameters
452 ----------
453 other
454 Interval whose values correspond to the container being sliced.
455 """
456 if not other.contains(self):
457 raise IndexError(
458 f"Can not calculate a slice of {other} within {self} "
459 "since the given interval does not contain this one."
460 )
461 return slice(self.start - other.start, self.stop - other.start)
463 @classmethod
464 def from_legacy(cls, legacy: Any) -> Interval:
465 """Convert from an `lsst.geom.IntervalI` instance.
467 Parameters
468 ----------
469 legacy
470 Legacy `lsst.geom.IntervalI` instance to convert.
471 """
472 return cls(legacy.begin, legacy.end)
474 def to_legacy(self) -> Any:
475 """Convert to an `lsst.geom.IntervalI` instance."""
476 from lsst.geom import IntervalI
478 return IntervalI(min=self.min, max=self.max)
480 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
481 return (
482 Interval,
483 (
484 self._start,
485 self._stop,
486 ),
487 )
489 @classmethod
490 def __get_pydantic_core_schema__(
491 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
492 ) -> pcs.CoreSchema:
493 from_typed_dict = pcs.chain_schema(
494 [
495 handler(_SerializedInterval),
496 pcs.no_info_plain_validator_function(cls._validate),
497 ]
498 )
499 return pcs.json_or_python_schema(
500 json_schema=from_typed_dict,
501 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
502 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
503 )
505 @classmethod
506 def __get_pydantic_json_schema__(
507 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
508 ) -> JsonSchemaValue:
509 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
511 @classmethod
512 def _validate(cls, data: _SerializedInterval) -> Interval:
513 return cls(**data)
515 def _serialize(self) -> _SerializedInterval:
516 return {"start": self._start, "stop": self._stop}
519class IntervalSliceFactory:
520 """A factory for `Interval` objects using array-slice syntax.
522 Parameters
523 ----------
524 parent
525 Interval that constructed intervals must be contained by, or `None`
526 to allow any bounds.
527 is_local
528 Whether slice bounds are interpreted relative to the start of
529 ``parent`` rather than as absolute coordinates.
531 Notes
532 -----
533 When indexed with a single slice on the `Interval.factory` attribute, this
534 returns an `Interval` with exactly the given bounds::
536 assert Interval.factory[3:6] == Interval(start=3, stop=6)
538 A missing start bound is replaced by ``0``, but a missing stop bound is
539 not allowed.
541 When obtained from the `Interval.absolute` property, indices are absolute
542 coordinate values, but any omitted bounds are replaced with the parent
543 interval's bounds::
545 parent = Interval.factory[3:6]
546 assert Interval.factory[4:5] == parent.absolute[:5]
548 The final interval is also checked to be contained by the parent interval.
550 When obtained from the `Interval.local` property, indices are interpreted
551 as relative to the parent interval, and negative indices are relative to
552 the end (like `~collections.abc.Sequence` indexing)::
554 parent = Interval.factory[3:6]
555 assert Interval.factory[4:5] == parent.local[1:-1]
557 When the stop bound is greater than the size of the parent interval, the
558 returned interval is clipped to be contained by the parent (as in
559 `~collections.abc.Sequence` indexing).
560 """
562 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
563 self._parent = parent
564 self._is_local = is_local
566 def __getitem__(self, s: slice) -> Interval:
567 if s.step is not None and s.step != 1:
568 raise ValueError(f"Slice {s} has non-unit step.")
569 if self._is_local:
570 assert self._parent is not None, "is_local=True requires a parent interval"
571 start, stop, _ = s.indices(self._parent.size)
572 start += self._parent.start
573 stop += self._parent.start
574 else:
575 start = s.start
576 stop = s.stop
577 if start is None:
578 if self._parent is None:
579 start = 0
580 else:
581 start = self._parent.start
582 if stop is None:
583 if self._parent is None: 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true
584 raise IndexError("An Interval cannot have an empty upper bound.")
585 stop = self._parent.stop
586 if self._parent is not None:
587 if start < self._parent.start:
588 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
589 if stop > self._parent.stop:
590 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
591 return Interval(start=start, stop=stop)
594Interval.factory = IntervalSliceFactory()
597class _SerializedBox(TypedDict):
598 y: _SerializedInterval
599 x: _SerializedInterval
602class Box:
603 """An axis-aligned 2-d rectangular region.
605 Parameters
606 ----------
607 y
608 Interval for the y dimension.
609 x
610 Interval for the x dimension.
612 Notes
613 -----
614 `Box` implements the necessary hooks to be included directly in a
615 `pydantic.BaseModel`, even though it is neither a built-in type nor a
616 Pydantic model itself.
617 """
619 def __init__(self, y: Interval, x: Interval) -> None:
620 self._intervals = YX(y, x)
622 __slots__ = ("_intervals",)
624 factory: ClassVar[BoxSliceFactory]
625 """A factory for creating boxes using slice syntax.
627 For example::
629 box = Box.factory[2:5, 3:9]
630 """
632 @classmethod
633 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
634 """Construct a box from its shape and optional start.
636 Parameters
637 ----------
638 shape
639 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
640 start
641 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
642 """
643 if start is None:
644 start = (0,) * len(shape)
645 match shape:
646 case XY(x=x_size, y=y_size):
647 pass
648 case [y_size, x_size]:
649 pass
650 case _:
651 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
652 match start:
653 case XY(x=x_start, y=y_start):
654 pass
655 case [y_start, x_start]:
656 pass
657 case _:
658 raise ValueError(f"Invalid sequence for start: {start!r}.")
659 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
661 @classmethod
662 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
663 """Construct a box from floating-point bounds ensuring that all the
664 are contained in the new box.
666 Parameters
667 ----------
668 x_min
669 Minimum X value.
670 x_max
671 Maximum X value.
672 y_min
673 Minimum Y value.
674 y_max
675 Maximum Y value.
677 Notes
678 -----
679 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
680 pixels whose centers lie within the bounds are included.
681 """
682 return Box.factory[
683 round_half_up(y_min) : round_half_down(y_max) + 1,
684 round_half_up(x_min) : round_half_down(x_max) + 1,
685 ]
687 @property
688 def min(self) -> YX[int]:
689 """The inclusive minimum bounds of the box, ordered ``(y, x)``
690 (`YX` [`int`]).
691 """
692 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
694 @property
695 def max(self) -> YX[int]:
696 """The inclusive maximum bounds of the box, ordered ``(y, x)``
697 (`YX` [`int`]).
698 """
699 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
701 @property
702 def start(self) -> YX[int]:
703 """Tuple holding the inclusive `Interval.start` bvound, ordered
704 ``(y, x)`` (`YX` [`int`]).
706 This is an alias for `min`, typically paired with `stop` for
707 half-exclusive ranges.
708 """
709 return YX(self.y.start, self.x.start)
711 @property
712 def stop(self) -> YX[int]:
713 """Tuple holding the exclusive `Interval.stop` bound, ordered
714 ``(y, x)`` (`YX` [`int`]).
716 The values in this tuple are one greater than those in `max`. It is
717 typically paired with `start` for half-exclusive ranges.
718 """
719 return YX(self.y.stop, self.x.stop)
721 @property
722 def shape(self) -> YX[int]:
723 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
724 (`YX` [`int`]).
725 """
726 return YX(self.y.size, self.x.size)
728 @property
729 def x(self) -> Interval:
730 """The x-dimension interval (`int`)."""
731 return self._intervals[-1]
733 @property
734 def y(self) -> Interval:
735 """The y-dimension interval (`int`)."""
736 return self._intervals[-2]
738 @property
739 def absolute(self) -> BoxSliceFactory:
740 """A factory for constructing a contained `Box` using slice
741 syntax and absolute coordinates.
743 Notes
744 -----
745 Slice bounds that are absent are replaced with the bounds of ``self``.
746 """
747 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
749 @property
750 def local(self) -> BoxSliceFactory:
751 """A factory for constructing a contained `Interval` using a slice
752 relative to the start of this one (`BoxSliceFactory`).
754 Notes
755 -----
756 This factory interprets slices as "local" coordinates, in which ``0``
757 corresponds to ``self.start``. Negative bounds are relative to
758 ``self.stop``, as is usually the case for Python sequences.
759 """
760 return BoxSliceFactory(y=self.y.local, x=self.x.local)
762 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
763 """Return a pair of 2-d arrays of the coordinate values of the box.
765 Parameters
766 ----------
767 n
768 Number of points in each dimension. If a sequence, points are
769 assumed to be ordered ``(x, y)`` unless a `YX` instance is
770 provided.
771 step
772 Set ``n`` such that the distance between points is equal to or
773 just less than this in each dimension. Mutually exclusive with
774 ``n``.
776 Returns
777 -------
778 `XY` [`numpy.ndarray`]
779 A pair of arrays, each of which is 2-d with floating-point values.
781 See Also
782 --------
783 numpy.meshgrid
784 """
785 if n is not None and step is not None:
786 raise TypeError("'n' and 'step' cannot both be provided.")
787 match n:
788 case int():
789 ax = self.x.linspace(n)
790 ay = self.y.linspace(n)
791 case YX(y=ny, x=nx):
792 ax = self.x.linspace(nx)
793 ay = self.y.linspace(ny)
794 case [nx, ny]:
795 ax = self.x.linspace(nx)
796 ay = self.y.linspace(ny)
797 case None:
798 ax = self.x.linspace(step=step)
799 ay = self.y.linspace(step=step)
800 case _:
801 raise ValueError(f"Unexpected values for n ({n})")
802 return XY(*np.meshgrid(ax, ay))
804 def padded(self, padding: int) -> Box:
805 """Return a new box expanded by the given padding on
806 all sides.
808 Parameters
809 ----------
810 padding
811 Number of pixels to expand the box by on every side.
812 """
813 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
815 def __eq__(self, other: object) -> bool:
816 if type(other) is Box:
817 return self._intervals == other._intervals
818 return False
820 def __str__(self) -> str:
821 return f"[y={self.y}, x={self.x}]"
823 def __repr__(self) -> str:
824 return f"Box(y={self.y!r}, x={self.x!r})"
826 @overload
827 def contains(self, other: Box, /) -> bool: ... 827 ↛ exitline 827 didn't return from function 'contains' because
829 @overload
830 def contains(self, *, y: int, x: int) -> bool: ... 830 ↛ exitline 830 didn't return from function 'contains' because
832 @overload
833 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 833 ↛ exitline 833 didn't return from function 'contains' because
835 def contains(
836 self,
837 other: Box | None = None,
838 *,
839 y: int | np.ndarray | None = None,
840 x: int | np.ndarray | None = None,
841 ) -> bool | np.ndarray:
842 """Test whether this box fully contains another or one or more points.
844 Parameters
845 ----------
846 other
847 Another box to compare to. Not compatible with the ``y`` and ``x``
848 arguments.
849 y
850 One or more integer Y coordinates to test for containment.
851 If an array, an array of results will be returned.
852 x
853 One or more integer X coordinates to test for containment.
854 If an array, an array of results will be returned.
856 Returns
857 -------
858 `bool` | `numpy.ndarray`
859 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
860 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
861 array with their broadcasted shape.
863 Notes
864 -----
865 In order to yield the desired behavior for floating-point arguments,
866 points are actually tested against an interval that is 0.5 larger on
867 both sides: this makes positions within the outer boundary of pixels
868 (but beyond the centers of those pixels, which have integer positions)
869 appear "on the image".
870 """
871 if other is not None:
872 if x is not None or y is not None:
873 raise TypeError("Too many arguments to 'Box.contains'.")
874 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
875 elif x is None or y is None:
876 raise TypeError("Not enough arguments to 'Box.contains'.")
877 else:
878 result = np.logical_and(self.x.contains(x), self.y.contains(y))
879 if not result.shape:
880 return bool(result)
881 return result
883 @overload
884 def intersection(self, other: Box) -> Box: ... 884 ↛ exitline 884 didn't return from function 'intersection' because
886 @overload
887 def intersection(self, other: Bounds) -> Bounds: ... 887 ↛ exitline 887 didn't return from function 'intersection' because
889 def intersection(self, other: Bounds) -> Bounds:
890 """Return a bounds object that is contained by both ``self`` and
891 ``other``.
893 When there is no overlap, `NoOverlapError` is raised.
895 Parameters
896 ----------
897 other
898 Bounds to intersect with this one.
899 """
900 from ._concrete_bounds import _intersect_box
902 return _intersect_box(self, other)
904 def dilated_by(self, padding: int) -> Box:
905 """Return a new box padded by the given amount on all sides.
907 Parameters
908 ----------
909 padding
910 Number of pixels to pad the box by on every side.
911 """
912 return Box(*[i.dilated_by(padding) for i in self._intervals])
914 def slice_within(self, other: Box) -> YX[slice]:
915 """Return a `tuple` of `slice` objects that correspond to the
916 positions in this box when the items of the container being sliced
917 correspond to ``other``.
919 This assumes ``other.contains(self)``.
921 Parameters
922 ----------
923 other
924 Box that the sliced container's items correspond to.
925 """
926 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
928 @property
929 def bbox(self) -> Box:
930 """The box itself (`Box`).
932 This is provided for compatibility with the `Bounds` interface.
933 """
934 return self
936 def boundary(self) -> Iterator[YX[int]]:
937 """Iterate over the corners of the box as ``(y, x)`` tuples.
939 Yields
940 ------
941 corner
942 Each corner in turn.
943 """
944 if len(self._intervals) != 2: 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true
945 raise TypeError("Box is not 2-d.")
946 yield YX(self.y.min, self.x.min)
947 yield YX(self.y.min, self.x.max)
948 yield YX(self.y.max, self.x.max)
949 yield YX(self.y.max, self.x.min)
951 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
952 return (Box, self._intervals)
954 @classmethod
955 def from_legacy(cls, legacy: Any) -> Box:
956 """Convert from an `lsst.geom.Box2I` instance.
958 Parameters
959 ----------
960 legacy
961 Legacy `lsst.geom.Box2I` to convert.
962 """
963 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
965 def to_legacy(self) -> Any:
966 """Convert to an `lsst.geom.BoxI` instance."""
967 from lsst.geom import Box2I
969 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
971 @classmethod
972 def __get_pydantic_core_schema__(
973 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
974 ) -> pcs.CoreSchema:
975 from_typed_dict = pcs.chain_schema(
976 [
977 handler(_SerializedBox),
978 pcs.no_info_plain_validator_function(cls._validate),
979 ]
980 )
981 return pcs.json_or_python_schema(
982 json_schema=from_typed_dict,
983 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
984 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
985 )
987 @classmethod
988 def __get_pydantic_json_schema__(
989 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
990 ) -> JsonSchemaValue:
991 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
993 @classmethod
994 def _validate(cls, data: _SerializedBox) -> Box:
995 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
997 def _serialize(self) -> _SerializedBox:
998 return {"y": self.y._serialize(), "x": self.x._serialize()}
1000 def serialize(self) -> Box:
1001 """Return a Pydantic-friendly representation of this object.
1003 This method just returns the `Box` itself, since that already provides
1004 Pydantic serialization hooks. It exists for compatibility with the
1005 `Bounds` protocol.
1006 """
1007 return self
1009 def deserialize(self) -> Box:
1010 """Deserialize a bounds object on the assumption it is a `Box`.
1012 This method just returns the `Box` itself, since that already provides
1013 Pydantic serialization hooks. It exists for compatibility with the
1014 `Bounds` protocol.
1015 """
1016 return self
1019class BoxSliceFactory:
1020 """A factory for `Box` objects using array-slice syntax.
1022 Parameters
1023 ----------
1024 y
1025 Slice factory used for the y axis.
1026 x
1027 Slice factory used for the x axis.
1029 Notes
1030 -----
1031 When `Box.factory` is indexed with a pair of slices, this returns a
1032 `Box` with exactly those bounds::
1034 assert (
1035 Box.factory[3:6, -1:2]
1036 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1037 )
1039 A missing start bound is replaced by ``0``, but a missing stop bound is
1040 not allowed.
1042 When obtained from the `Box.absolute` property, indices are absolute
1043 coordinate values, but any omitted bounds are replaced with the parent
1044 box's bounds::
1046 parent = Box.factory[3:6, -1:2]
1047 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1049 The final box is also checked to be contained by the parent box.
1051 When obtained from the `Box.local` property, indices are interpreted
1052 as relative to the parent box, and negative indices are relative to
1053 the end (like `~collections.abc.Sequence` indexing)::
1055 parent = Box.factory[3:6, -1:2]
1056 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1057 """
1059 def __init__(
1060 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1061 ) -> None:
1062 self._y = y
1063 self._x = x
1065 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1066 match key:
1067 case XY(x=x, y=y):
1068 return Box(y=self._y[y], x=self._x[x])
1069 case (y, x):
1070 return Box(y=self._y[y], x=self._x[x])
1071 case _:
1072 raise TypeError("Expected exactly two slices.")
1075Box.factory = BoxSliceFactory()
1078class Bounds(Protocol):
1079 """A protocol for objects that represent the validity region for a function
1080 defined in 2-d pixel coordinates.
1082 Notes
1083 -----
1084 Most objects natively have a simple 2-d bounding box as their bounds
1085 (typically the boundary of a sensor), and the `Box` class is hence the
1086 most common bounds implementation. But sometimes a large chunk of that
1087 box may be missing due to vignetting or bad amplifiers, and we may want to
1088 transform from one coordinate system to another. The Bounds interface is
1089 intended to handle both of these cases as well.
1090 """
1092 @property
1093 def bbox(self) -> Box: ... 1093 ↛ exitline 1093 didn't return from function 'bbox' because
1095 @overload
1096 def contains(self, *, x: int, y: int) -> bool: ... 1096 ↛ exitline 1096 didn't return from function 'contains' because
1098 @overload
1099 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 1099 ↛ exitline 1099 didn't return from function 'contains' because
1101 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
1102 """Test whether this box fully contains another or one or more points.
1104 Parameters
1105 ----------
1106 x
1107 One or more integer X coordinates to test for containment.
1108 If an array, an array of results will be returned.
1109 y
1110 One or more integer Y coordinates to test for containment.
1111 If an array, an array of results will be returned.
1113 Returns
1114 -------
1115 `bool` | `numpy.ndarray`
1116 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1117 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
1118 shape.
1119 """
1120 ...
1122 def intersection(self, other: Bounds) -> Bounds:
1123 """Compute the intersection of this bounds object with another.
1125 Parameters
1126 ----------
1127 other
1128 Bounds to intersect with this one.
1129 """
1130 ...
1132 def serialize(self) -> SerializableBounds:
1133 """Convert a bounds instance into a serializable object.
1135 Notes
1136 -----
1137 The returned object must support direct nesting with Pydantic models
1138 and have a ``deserialize`` method (taking no arguments) that converts
1139 back to this `Bounds` type. It is common for `serialize` and
1140 ``deserialize`` to just return ``self``, when the bounds object is
1141 natively serializable.
1142 """
1143 ...
1146class BoundsError(ValueError):
1147 """Exception raised when an object is evaluated outside its bounds."""
1150class NoOverlapError(ValueError):
1151 """Exception raised when intervals or bounds do not overlap."""