Coverage for python/lsst/images/_geom.py: 52%
482 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 02:13 -0700
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-30 02:13 -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 "NotContainedError",
25 "SerializableXY",
26 "SerializableYX",
27)
29import math
30from collections.abc import Callable, Iterator, Sequence
31from typing import (
32 TYPE_CHECKING,
33 Annotated,
34 Any,
35 ClassVar,
36 NamedTuple,
37 Protocol,
38 TypedDict,
39 TypeVar,
40 assert_type,
41 final,
42 get_args,
43 overload,
44 runtime_checkable,
45)
47import astropy.units as u
48import numpy as np
49import numpy.typing as npt
50import pydantic
51import pydantic_core.core_schema as pcs
52from pydantic.json_schema import JsonSchemaValue
54import starlink.Ast as Ast
56from .utils import round_half_down, round_half_up
58if TYPE_CHECKING:
59 import astropy.coordinates
61 from ._concrete_bounds import BoundsSerializationModel
62 from ._polygon import Polygon, Region
63 from ._transforms import SkyProjection, Transform
65 try:
66 from lsst.geom import Extent2D as LegacyExtent2D
67 from lsst.geom import Extent2I as LegacyExtent2I
68 from lsst.geom import Point2D as LegacyPoint2D
69 from lsst.geom import Point2I as LegacyPoint2I
70 except ImportError:
71 type LegacyExtent2I = Any # type: ignore[no-redef]
72 type LegacyPoint2I = Any # type: ignore[no-redef]
73 type LegacyExtent2D = Any # type: ignore[no-redef]
74 type LegacyPoint2D = Any # type: ignore[no-redef]
76# This pre-python-3.12 declaration is needed by Sphinx (probably the
77# autodoc-typehints plugin.
78T = TypeVar("T")
80# Interval and Box are defined as regular Python classes rather than
81# dataclasses or Pydantic models because we might want to implement them as
82# compiled-extension types in the future, and we want that to be transparent.
84# In a similar vein, we avoid declaring specific types for multidimensional
85# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
86# shapes) in order to leave room for more fully-featured types to be added
87# upstream of this package in the future.
90class _SerializedYX[T](TypedDict):
91 y: T
92 x: T
95class _SerializedXY[T](TypedDict):
96 x: T
97 y: T
100class YX[T](NamedTuple):
101 """A pair of per-dimension objects, ordered ``(y, x)``.
103 Notes
104 -----
105 `YX` is used for slices, shapes, and other 2-d pairs when the most
106 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
107 arithmetic operations behave as they would on a
108 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
109 concatenates).
111 In Pydantic models `YX` is serialized as a JSON object with ``y`` and
112 ``x`` keys rather than an array, so the dimension order is explicit in
113 the serialized form. Validation also accepts a two-element ``[y, x]``
114 array, the form emitted before the object form was introduced and still
115 present in files that cannot be rewritten. Pydantic only invokes the
116 schema hooks that
117 provide this for bare ``YX`` annotations, however, because it handles
118 parameterized named tuples specially; model fields that need a member
119 type (e.g. ``YX[int]``) must be annotated with `SerializableYX` instead.
121 See Also
122 --------
123 XY
124 """
126 y: T
127 """The y / row object."""
129 x: T
130 """The x / column object."""
132 @property
133 def xy(self) -> XY:
134 """A tuple of the same objects in the opposite order."""
135 return XY(x=self.x, y=self.y)
137 def map[U](self, func: Callable[[T], U]) -> YX[U]:
138 """Apply a function to both objects.
140 Parameters
141 ----------
142 func
143 Callable applied to each of the two objects in turn.
144 """
145 return YX(y=func(self.y), x=func(self.x))
147 def to_legacy_int_extent(self) -> LegacyExtent2I:
148 """Convert to a legacy `lsst.geom.Extent2I` object."""
149 from lsst.geom import Extent2I as LegacyExtent2I
151 return LegacyExtent2I(self.x, self.y)
153 def to_legacy_int_point(self) -> LegacyPoint2I:
154 """Convert to a legacy `lsst.geom.Point2I` object."""
155 from lsst.geom import Point2I as LegacyPoint2I
157 return LegacyPoint2I(self.x, self.y)
159 def to_legacy_float_extent(self) -> LegacyExtent2D:
160 """Convert to a legacy `lsst.geom.Extent2D` object."""
161 from lsst.geom import Extent2D as LegacyExtent2D
163 return LegacyExtent2D(self.x, self.y)
165 def to_legacy_float_point(self) -> LegacyPoint2D:
166 """Convert to a legacy `lsst.geom.Point2D` object."""
167 from lsst.geom import Point2D as LegacyPoint2D
169 return LegacyPoint2D(self.x, self.y)
171 @classmethod
172 def __get_pydantic_core_schema__(
173 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
174 ) -> pcs.CoreSchema:
175 item_type = args[0] if (args := get_args(source_type)) else Any
176 typed_dict = handler(_SerializedYX[item_type]) # type: ignore[valid-type]
177 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict)
178 # Files written before the object form was introduced (including the
179 # DP2 cell coadds, which cannot be rewritten) store pairs as ``[y, x]``
180 # arrays, so validation must accept that form for as long as those
181 # files remain readable, even if a future schema version stops
182 # documenting it. This is a strict list schema so that a stray `XY`
183 # (a tuple) assigned to a `YX` field fails to validate instead of
184 # being silently transposed.
185 from_list = pcs.no_info_after_validator_function(
186 cls._from_list,
187 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True),
188 )
189 validation = pcs.union_schema([from_typed_dict, from_list])
190 return pcs.json_or_python_schema(
191 json_schema=validation,
192 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]),
193 serialization=pcs.plain_serializer_function_ser_schema(
194 cls._serialize, info_arg=False, return_schema=typed_dict
195 ),
196 )
198 @classmethod
199 def _validate(cls, data: _SerializedYX[T]) -> YX[T]:
200 return cls(**data)
202 @classmethod
203 def _from_list(cls, data: list[T]) -> YX[T]:
204 return cls(*data)
206 def _serialize(self) -> _SerializedYX[T]:
207 return {"y": self.y, "x": self.x}
210class XY[T](NamedTuple):
211 """A pair of per-dimension objects, ordered ``(x, y)``.
213 Notes
214 -----
215 `XY` is used for points and other 2-d pairs when the most natural ordering
216 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
217 behave as they would on a `collections.abc.Sequence`, not a mathematical
218 vector (e.g. adding concatenates).
220 In Pydantic models `XY` is serialized as a JSON object with ``x`` and
221 ``y`` keys rather than an array, so the dimension order is explicit in
222 the serialized form. Validation also accepts a two-element ``[x, y]``
223 array, the form emitted before the object form was introduced and still
224 present in files that cannot be rewritten. Pydantic only invokes the
225 schema hooks that
226 provide this for bare ``XY`` annotations, however, because it handles
227 parameterized named tuples specially; model fields that need a member
228 type (e.g. ``XY[float]``) must be annotated with `SerializableXY` instead.
230 See Also
231 --------
232 YX
233 """
235 x: T
236 """The x / column object."""
238 y: T
239 """The y / row object."""
241 @property
242 def yx(self) -> YX:
243 """A tuple of the same objects in the opposite order."""
244 return YX(y=self.y, x=self.x)
246 def map[U](self, func: Callable[[T], U]) -> XY[U]:
247 """Apply a function to both objects.
249 Parameters
250 ----------
251 func
252 Callable applied to each of the two objects in turn.
253 """
254 return XY(x=func(self.x), y=func(self.y))
256 def to_legacy_int_extent(self) -> LegacyExtent2I:
257 """Convert to a legacy `lsst.geom.Extent2I` object."""
258 from lsst.geom import Extent2I as LegacyExtent2I
260 return LegacyExtent2I(self.x, self.y)
262 def to_legacy_int_point(self) -> LegacyPoint2I:
263 """Convert to a legacy `lsst.geom.Point2I` object."""
264 from lsst.geom import Point2I as LegacyPoint2I
266 return LegacyPoint2I(self.x, self.y)
268 def to_legacy_float_extent(self) -> LegacyExtent2D:
269 """Convert to a legacy `lsst.geom.Extent2D` object."""
270 from lsst.geom import Extent2D as LegacyExtent2D
272 return LegacyExtent2D(self.x, self.y)
274 def to_legacy_float_point(self) -> LegacyPoint2D:
275 """Convert to a legacy `lsst.geom.Point2D` object."""
276 from lsst.geom import Point2D as LegacyPoint2D
278 return LegacyPoint2D(self.x, self.y)
280 @classmethod
281 def __get_pydantic_core_schema__(
282 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
283 ) -> pcs.CoreSchema:
284 item_type = args[0] if (args := get_args(source_type)) else Any
285 typed_dict = handler(_SerializedXY[item_type]) # type: ignore[valid-type]
286 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict)
287 # Files written before the object form was introduced store pairs as
288 # ``[x, y]`` arrays, so validation must accept that form for as long
289 # as those files remain readable, even if a future schema version
290 # stops documenting it. This is a strict list schema so that a stray
291 # `YX` (a tuple) assigned to an `XY` field fails to validate instead
292 # of being silently transposed.
293 from_list = pcs.no_info_after_validator_function(
294 cls._from_list,
295 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True),
296 )
297 validation = pcs.union_schema([from_typed_dict, from_list])
298 return pcs.json_or_python_schema(
299 json_schema=validation,
300 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]),
301 serialization=pcs.plain_serializer_function_ser_schema(
302 cls._serialize, info_arg=False, return_schema=typed_dict
303 ),
304 )
306 @classmethod
307 def _validate(cls, data: _SerializedXY[T]) -> XY[T]:
308 return cls(**data)
310 @classmethod
311 def _from_list(cls, data: list[T]) -> XY[T]:
312 return cls(*data)
314 def _serialize(self) -> _SerializedXY[T]:
315 return {"x": self.x, "y": self.y}
318SerializableXY = Annotated[XY[T], pydantic.GetPydanticSchema(XY.__get_pydantic_core_schema__)]
319"""An `XY` annotation for Pydantic model fields that serializes as a JSON
320object with ``x`` and ``y`` keys (`XY` alone serializes as an array when
321parameterized, because Pydantic handles named tuples specially).
322"""
324SerializableYX = Annotated[YX[T], pydantic.GetPydanticSchema(YX.__get_pydantic_core_schema__)]
325"""A `YX` annotation for Pydantic model fields that serializes as a JSON
326object with ``y`` and ``x`` keys (`YX` alone serializes as an array when
327parameterized, because Pydantic handles named tuples specially).
328"""
331class _SerializedInterval(TypedDict):
332 start: int
333 stop: int
336@final
337class Interval:
338 """A 1-d integer interval with positive size.
340 Intervals should generally be constructed by a classmethod factory (e.g.
341 `hull` or `from_size`) or the `factory` slicing proxy::
343 interval = Interval.factory[a:b]
345 This can be more verbose than invoking __init__ directly, but since it
346 uses Python's syntax for half-inclusive ranges it's easier for readers to
347 immediately see how the endpoints are interpreted.
349 Parameters
350 ----------
351 start
352 Inclusive minimum point in the interval.
353 stop
354 One past the maximum point in the interval.
356 Notes
357 -----
358 Adding or subtracting an `int` from an interval returns a shifted interval.
360 `Interval` implements the necessary hooks to be included directly in a
361 `pydantic.BaseModel`, even though it is neither a built-in type nor a
362 Pydantic model itself.
363 """
365 def __init__(self, start: int, stop: int) -> None:
366 # Coerce to be defensive against numpy int scalars.
367 self._start = int(start)
368 self._stop = int(stop)
369 if not (self._stop > self._start):
370 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
372 __slots__ = ("_start", "_stop")
374 factory: ClassVar[IntervalSliceFactory]
375 """A factory for creating intervals using slice syntax.
377 For example::
379 interval = Interval.factory[2:5]
380 """
382 @classmethod
383 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
384 """Construct an interval that includes all of the given points and/or
385 intervals.
387 Parameters
388 ----------
389 first
390 First point or interval to include in the hull.
391 *args
392 Additional points and/or intervals to include in the hull.
393 """
394 if type(first) is Interval:
395 rmin = first.min
396 rmax = first.max
397 else:
398 rmin = rmax = first
399 for arg in args:
400 if type(arg) is Interval:
401 rmin = min(rmin, arg.min)
402 rmax = max(rmax, arg.max)
403 else:
404 rmin = min(rmin, arg)
405 rmax = max(rmax, arg)
406 return Interval(start=rmin, stop=rmax + 1)
408 @classmethod
409 def from_size(cls, size: int, start: int = 0) -> Interval:
410 """Construct an interval from its size and optional start.
412 Parameters
413 ----------
414 size
415 Number of points in the interval.
416 start
417 Inclusive minimum point in the interval.
418 """
419 return cls(start=start, stop=start + size)
421 @property
422 def min(self) -> int:
423 """Inclusive minimum point in the interval (`int`)."""
424 return self.start
426 @property
427 def max(self) -> int:
428 """Inclusive maximum point in the interval (`int`)."""
429 return self.stop - 1
431 @property
432 def start(self) -> int:
433 """Inclusive minimum point in the interval (`int`)."""
434 return self._start
436 @property
437 def stop(self) -> int:
438 """One past the maximum point in the interval (`int`)."""
439 return self._stop
441 @property
442 def size(self) -> int:
443 """Size of the interval (`int`)."""
444 return self.stop - self.start
446 @property
447 def range(self) -> __builtins__.range:
448 """An iterable over all values in the interval
449 (`__builtins__.range`).
450 """
451 return range(self.start, self.stop)
453 @property
454 def arange(self) -> np.ndarray:
455 """An array of all the values in the interval (`numpy.ndarray`).
457 Array values are integers.
458 """
459 return np.arange(self.start, self.stop)
461 @property
462 def absolute(self) -> IntervalSliceFactory:
463 """A factory for constructing a contained `Interval` using slice
464 syntax and absolute coordinates.
466 Notes
467 -----
468 Slice bounds that are absent are replaced with the bounds of ``self``.
469 """
470 return IntervalSliceFactory(self, is_local=False)
472 @property
473 def local(self) -> IntervalSliceFactory:
474 """A factory for constructing a contained `Interval` using a slice
475 relative to the start of this one (`IntervalSliceFactory`).
477 Notes
478 -----
479 This factory interprets slices as "local" coordinates, in which ``0``
480 corresponds to ``self.start``. Negative bounds are relative to
481 ``self.stop``, as is usually the case for Python sequences.
482 """
483 return IntervalSliceFactory(self, is_local=True)
485 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
486 """Return an array of values that spans the interval.
488 Parameters
489 ----------
490 n
491 How many values to return. The default (if ``step`` is also not
492 provided) is the size of the interval, i.e. equivalent to the
493 `arange` property (but converted to `float`).
494 step
495 Set ``n`` such that the distance between points is equal to or
496 just less than this. Mutually exclusive with ``n``.
498 Returns
499 -------
500 numpy.ndarray
501 Array of `float` values.
503 See Also
504 --------
505 numpy.linspace
506 """
507 if n is None:
508 if step is None:
509 return self.arange.astype(np.float64)
510 n = math.ceil(self.size / step)
511 elif step is not None:
512 raise TypeError("'n' and 'step' cannot both be provided.")
513 return np.linspace(self.min, self.max, n, dtype=np.float64)
515 @property
516 def center(self) -> float:
517 """The center of the interval (`float`)."""
518 return 0.5 * (self.min + self.max)
520 def padded(self, padding: int) -> Interval:
521 """Return a new interval expanded by the given padding on
522 either side.
524 Parameters
525 ----------
526 padding
527 Number of points to add to each side of the interval.
528 """
529 return Interval(self.start - padding, self.stop + padding)
531 def __repr__(self) -> str:
532 return f"Interval(start={self.start}, stop={self.stop})"
534 def __str__(self) -> str:
535 return f"{self.start}:{self.stop}"
537 def __eq__(self, other: object) -> bool:
538 if type(other) is Interval:
539 return self._start == other._start and self._stop == other._stop
540 return False
542 def __add__(self, other: int) -> Interval:
543 return Interval(start=self.start + other, stop=self.stop + other)
545 def __sub__(self, other: int) -> Interval:
546 return Interval(start=self.start - other, stop=self.stop - other)
548 def __contains__(self, x: int) -> bool:
549 return x >= self.start and x < self.stop
551 @overload
552 def contains(self, other: Interval | int | float) -> bool: ...
554 @overload
555 def contains(self, other: npt.ArrayLike) -> np.ndarray: ...
557 def contains(self, other: Interval | int | float | npt.ArrayLike) -> bool | np.ndarray:
558 """Test whether this interval fully contains another or one or more
559 points.
561 Parameters
562 ----------
563 other
564 Another interval to compare to, or one or more position values as
565 a scalar or any array-like.
567 Returns
568 -------
569 `bool` | `numpy.ndarray`
570 If a single interval or value was passed, a single `bool`. If an
571 array-like was passed, an array with the broadcasted shape.
573 Notes
574 -----
575 In order to yield the desired behavior for floating-point arguments,
576 points are actually tested against an interval that is 0.5 larger on
577 both sides: this makes positions within the outer boundary of pixels
578 (but beyond the centers of those pixels, which have integer positions)
579 appear "on the image".
580 """
581 if isinstance(other, Interval):
582 return self.start <= other.start and self.stop >= other.stop
583 else:
584 other = np.asarray(other)
585 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
586 if not result.shape:
587 return bool(result)
588 return result
590 def intersection(self, other: Interval) -> Interval:
591 """Return an interval that is contained by both ``self`` and ``other``.
593 When there is no overlap between the intervals, `NoOverlapError` is
594 raised.
596 Parameters
597 ----------
598 other
599 Interval to intersect with this one.
600 """
601 new_start = max(self.start, other.start)
602 new_stop = min(self.stop, other.stop)
603 if new_start < new_stop:
604 return Interval(start=new_start, stop=new_stop)
605 raise NoOverlapError(f"No overlap between {self} and {other}.")
607 def dilated_by(self, padding: int) -> Interval:
608 """Return a new interval padded by the given amount on both sides.
610 Parameters
611 ----------
612 padding
613 Number of points to add to each side of the interval.
614 """
615 return Interval(start=self._start - padding, stop=self._stop + padding)
617 def slice_within(self, other: Interval) -> slice:
618 """Return the `slice` that corresponds to the values in this interval
619 when the items of the container being sliced correspond to ``other``.
621 This assumes ``other.contains(self)``.
623 Parameters
624 ----------
625 other
626 Interval whose values correspond to the container being sliced.
627 """
628 if not other.contains(self):
629 raise IndexError(
630 f"Can not calculate a slice of {other} within {self} "
631 "since the given interval does not contain this one."
632 )
633 return slice(self.start - other.start, self.stop - other.start)
635 @classmethod
636 def from_legacy(cls, legacy: Any) -> Interval:
637 """Convert from an `lsst.geom.IntervalI` instance.
639 Parameters
640 ----------
641 legacy
642 Legacy `lsst.geom.IntervalI` instance to convert.
643 """
644 return cls(legacy.begin, legacy.end)
646 def to_legacy(self) -> Any:
647 """Convert to an `lsst.geom.IntervalI` instance."""
648 from lsst.geom import IntervalI
650 return IntervalI(min=self.min, max=self.max)
652 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
653 return (
654 Interval,
655 (
656 self._start,
657 self._stop,
658 ),
659 )
661 @classmethod
662 def __get_pydantic_core_schema__(
663 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
664 ) -> pcs.CoreSchema:
665 from_typed_dict = pcs.chain_schema(
666 [
667 handler(_SerializedInterval),
668 pcs.no_info_plain_validator_function(cls._validate),
669 ]
670 )
671 return pcs.json_or_python_schema(
672 json_schema=from_typed_dict,
673 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
674 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
675 )
677 @classmethod
678 def __get_pydantic_json_schema__(
679 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
680 ) -> JsonSchemaValue:
681 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
683 @classmethod
684 def _validate(cls, data: _SerializedInterval) -> Interval:
685 return cls(**data)
687 def _serialize(self) -> _SerializedInterval:
688 return {"start": self._start, "stop": self._stop}
691class IntervalSliceFactory:
692 """A factory for `Interval` objects using array-slice syntax.
694 Parameters
695 ----------
696 parent
697 Interval that constructed intervals must be contained by, or `None`
698 to allow any bounds.
699 is_local
700 Whether slice bounds are interpreted relative to the start of
701 ``parent`` rather than as absolute coordinates.
703 Notes
704 -----
705 When indexed with a single slice on the `Interval.factory` attribute, this
706 returns an `Interval` with exactly the given bounds::
708 assert Interval.factory[3:6] == Interval(start=3, stop=6)
710 A missing start bound is replaced by ``0``, but a missing stop bound is
711 not allowed.
713 When obtained from the `Interval.absolute` property, indices are absolute
714 coordinate values, but any omitted bounds are replaced with the parent
715 interval's bounds::
717 parent = Interval.factory[3:6]
718 assert Interval.factory[4:5] == parent.absolute[:5]
720 The final interval is also checked to be contained by the parent interval.
722 When obtained from the `Interval.local` property, indices are interpreted
723 as relative to the parent interval, and negative indices are relative to
724 the end (like `~collections.abc.Sequence` indexing)::
726 parent = Interval.factory[3:6]
727 assert Interval.factory[4:5] == parent.local[1:-1]
729 When the stop bound is greater than the size of the parent interval, the
730 returned interval is clipped to be contained by the parent (as in
731 `~collections.abc.Sequence` indexing).
732 """
734 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
735 self._parent = parent
736 self._is_local = is_local
738 def __getitem__(self, s: slice) -> Interval:
739 if s.step is not None and s.step != 1:
740 raise ValueError(f"Slice {s} has non-unit step.")
741 if self._is_local:
742 assert self._parent is not None, "is_local=True requires a parent interval"
743 start, stop, _ = s.indices(self._parent.size)
744 start += self._parent.start
745 stop += self._parent.start
746 else:
747 start = s.start
748 stop = s.stop
749 if start is None:
750 if self._parent is None:
751 start = 0
752 else:
753 start = self._parent.start
754 if stop is None:
755 if self._parent is None: 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true
756 raise IndexError("An Interval cannot have an empty upper bound.")
757 stop = self._parent.stop
758 if self._parent is not None:
759 if start < self._parent.start:
760 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
761 if stop > self._parent.stop:
762 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
763 return Interval(start=start, stop=stop)
766Interval.factory = IntervalSliceFactory()
769class _SerializedBox(TypedDict):
770 y: _SerializedInterval
771 x: _SerializedInterval
774class Box:
775 """An axis-aligned 2-d rectangular region.
777 Boxes should generally be constructed by a classmethod factory (e.g.
778 `from_shape`, `from_float_bounds`, `from_sky_circle`) or the `factory`
779 slicing proxy::
781 box = Box.factory[y_a:y_b, x_a:x_b]
783 unless the ``y`` and ``x`` intervals are already available.
785 Parameters
786 ----------
787 y
788 Interval for the y dimension.
789 x
790 Interval for the x dimension.
792 Raises
793 ------
794 TypeError
795 Raised if ``y`` or ``x`` is not an `Interval`.
797 Notes
798 -----
799 `Box` implements the necessary hooks to be included directly in a
800 `pydantic.BaseModel`, even though it is neither a built-in type nor a
801 Pydantic model itself.
802 """
804 def __init__(self, y: Interval, x: Interval) -> None:
805 match (y, x):
806 case (Interval(), Interval()):
807 self._intervals = YX(y, x)
808 case _:
809 raise TypeError(
810 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). "
811 "See the Box.factory slice syntax and the from_* class methods for other ways "
812 "to construct a Box."
813 )
815 __slots__ = ("_intervals",)
817 factory: ClassVar[BoxSliceFactory]
818 """A factory for creating boxes using slice syntax.
820 For example::
822 box = Box.factory[2:5, 3:9]
823 """
825 @classmethod
826 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
827 """Construct a box from its shape and optional start.
829 Parameters
830 ----------
831 shape
832 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
833 start
834 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
835 """
836 if start is None:
837 start = (0,) * len(shape)
838 match shape:
839 case XY(x=x_size, y=y_size):
840 pass
841 case [y_size, x_size]:
842 pass
843 case _:
844 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
845 match start:
846 case XY(x=x_start, y=y_start):
847 pass
848 case [y_start, x_start]:
849 pass
850 case _:
851 raise ValueError(f"Invalid sequence for start: {start!r}.")
852 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
854 @classmethod
855 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
856 """Construct a box from floating-point bounds ensuring that all the
857 are contained in the new box.
859 Parameters
860 ----------
861 x_min
862 Minimum X value.
863 x_max
864 Maximum X value.
865 y_min
866 Minimum Y value.
867 y_max
868 Maximum Y value.
870 Notes
871 -----
872 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
873 pixels whose centers lie within the bounds are included.
874 """
875 return Box.factory[
876 round_half_up(y_min) : round_half_down(y_max) + 1,
877 round_half_up(x_min) : round_half_down(x_max) + 1,
878 ]
880 @classmethod
881 def from_sky_circle(
882 cls,
883 sky_projection: SkyProjection,
884 center: astropy.coordinates.SkyCoord,
885 radius: astropy.coordinates.Angle,
886 ) -> Box:
887 """Calculate a bounding box from a sky projection and a circular
888 sky region.
890 Parameters
891 ----------
892 sky_projection
893 The sky projection mapping pixels to sky.
894 center
895 The center of the circle, as a scalar
896 `astropy.coordinates.SkyCoord` in any frame.
897 radius
898 Radius of the circle, as a scalar `astropy.coordinates.Angle`.
900 Returns
901 -------
902 Box
903 Bounding box enclosing the circle based on the sky projection.
905 Raises
906 ------
907 ValueError
908 Raised if ``center`` or ``radius`` is not scalar, or if this
909 image has no sky projection.
910 """
911 if not center.isscalar:
912 raise ValueError("The center of the sky circle must be a scalar SkyCoord.")
913 if not radius.isscalar:
914 raise ValueError("The radius of the sky circle must be a scalar Angle.")
915 center = center.transform_to("icrs")
917 # Use pyast directly for the region handling.
918 sky_region = Ast.Circle(
919 Ast.SkyFrame("System=ICRS"),
920 1,
921 [center.ra.rad, center.dec.rad],
922 [radius.to_value(u.rad)],
923 )
925 # If it is not already a pyast mapping
926 # (e.g., it is implemented with astshim), convert it by round-tripping
927 # the AST textual serialization through a pyast Channel.
928 sky_to_pixel: Any = sky_projection.sky_to_pixel_transform._ast_mapping
929 if not isinstance(sky_to_pixel, Ast.Mapping): 929 ↛ 935line 929 didn't jump to line 935 because the condition on line 929 was always true
930 # Comments must be disabled for pyast to be able to parse the
931 # astshim serialization.
932 sky_to_pixel = Ast.Channel(sky_to_pixel.show(False).splitlines()).read()
934 # Calculate the Box around the region.
935 pixel_region = sky_region.mapregion(sky_to_pixel, Ast.Frame(2))
936 lbnd, ubnd = pixel_region.getregionbounds()
937 return Box.from_float_bounds(
938 x_min=float(lbnd[0]),
939 x_max=float(ubnd[0]),
940 y_min=float(lbnd[1]),
941 y_max=float(ubnd[1]),
942 )
944 @property
945 def min(self) -> YX[int]:
946 """The inclusive minimum bounds of the box, ordered ``(y, x)``
947 (`YX` [`int`]).
948 """
949 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
951 @property
952 def max(self) -> YX[int]:
953 """The inclusive maximum bounds of the box, ordered ``(y, x)``
954 (`YX` [`int`]).
955 """
956 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
958 @property
959 def start(self) -> YX[int]:
960 """Tuple holding the inclusive `Interval.start` bvound, ordered
961 ``(y, x)`` (`YX` [`int`]).
963 This is an alias for `min`, typically paired with `stop` for
964 half-exclusive ranges.
965 """
966 return YX(self.y.start, self.x.start)
968 @property
969 def stop(self) -> YX[int]:
970 """Tuple holding the exclusive `Interval.stop` bound, ordered
971 ``(y, x)`` (`YX` [`int`]).
973 The values in this tuple are one greater than those in `max`. It is
974 typically paired with `start` for half-exclusive ranges.
975 """
976 return YX(self.y.stop, self.x.stop)
978 @property
979 def shape(self) -> YX[int]:
980 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
981 (`YX` [`int`]).
982 """
983 return YX(self.y.size, self.x.size)
985 @property
986 def x(self) -> Interval:
987 """The x-dimension interval (`int`)."""
988 return self._intervals[-1]
990 @property
991 def y(self) -> Interval:
992 """The y-dimension interval (`int`)."""
993 return self._intervals[-2]
995 @property
996 def center(self) -> XY[float]:
997 """The center of the box (`XY` [`float`])."""
998 return XY(x=self.x.center, y=self.y.center)
1000 @property
1001 def area(self) -> int:
1002 """The number of pixels in the box (`int`)."""
1003 return self.x.size * self.y.size
1005 @property
1006 def absolute(self) -> BoxSliceFactory:
1007 """A factory for constructing a contained `Box` using slice
1008 syntax and absolute coordinates.
1010 Notes
1011 -----
1012 Slice bounds that are absent are replaced with the bounds of ``self``.
1013 """
1014 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
1016 @property
1017 def local(self) -> BoxSliceFactory:
1018 """A factory for constructing a contained `Interval` using a slice
1019 relative to the start of this one (`BoxSliceFactory`).
1021 Notes
1022 -----
1023 This factory interprets slices as "local" coordinates, in which ``0``
1024 corresponds to ``self.start``. Negative bounds are relative to
1025 ``self.stop``, as is usually the case for Python sequences.
1026 """
1027 return BoxSliceFactory(y=self.y.local, x=self.x.local)
1029 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
1030 """Return a pair of 2-d arrays of the coordinate values of the box.
1032 Parameters
1033 ----------
1034 n
1035 Number of points in each dimension. If a sequence, points are
1036 assumed to be ordered ``(x, y)`` unless a `YX` instance is
1037 provided.
1038 step
1039 Set ``n`` such that the distance between points is equal to or
1040 just less than this in each dimension. Mutually exclusive with
1041 ``n``.
1043 Returns
1044 -------
1045 `XY` [`numpy.ndarray`]
1046 A pair of arrays, each of which is 2-d with floating-point values.
1048 See Also
1049 --------
1050 numpy.meshgrid
1051 """
1052 if n is not None and step is not None:
1053 raise TypeError("'n' and 'step' cannot both be provided.")
1054 match n:
1055 case int():
1056 ax = self.x.linspace(n)
1057 ay = self.y.linspace(n)
1058 case YX(y=ny, x=nx):
1059 ax = self.x.linspace(nx)
1060 ay = self.y.linspace(ny)
1061 case [nx, ny]:
1062 ax = self.x.linspace(nx)
1063 ay = self.y.linspace(ny)
1064 case None:
1065 ax = self.x.linspace(step=step)
1066 ay = self.y.linspace(step=step)
1067 case _:
1068 raise ValueError(f"Unexpected values for n ({n})")
1069 return XY(*np.meshgrid(ax, ay))
1071 def padded(self, padding: int) -> Box:
1072 """Return a new box expanded by the given padding on
1073 all sides.
1075 Parameters
1076 ----------
1077 padding
1078 Number of pixels to expand the box by on every side.
1079 """
1080 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
1082 def __eq__(self, other: object) -> bool:
1083 if type(other) is Box:
1084 return self._intervals == other._intervals
1085 return False
1087 def __repr__(self) -> str:
1088 return f"Box(y={self.y!r}, x={self.x!r})"
1090 def __str__(self) -> str:
1091 return f"[y={self.y}, x={self.x}]"
1093 @overload
1094 def contains(self, other: Box, /) -> bool: ...
1096 @overload
1097 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1099 @overload
1100 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1102 @overload
1103 def contains(self, /, *, y: int | float, x: int | float) -> bool: ...
1105 @overload
1106 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ...
1108 def contains(
1109 self,
1110 other: Box | XY[Any] | YX[Any] | None = None,
1111 /,
1112 *,
1113 y: Any = None,
1114 x: Any = None,
1115 ) -> bool | np.ndarray:
1116 """Test whether this box fully contains another or one or more points.
1118 Parameters
1119 ----------
1120 other
1121 Another box, or an `XY` or `YX` coordinate pair, to compare to.
1122 Mutually exclusive with ``x`` and ``y``.
1123 y
1124 One or more Y coordinates to test for containment, as a scalar or
1125 any array-like. Results are broadcast against ``x``.
1126 Mutually exclusive with ``other``.
1127 x
1128 One or more X coordinates to test for containment, as a scalar or
1129 any array-like. Results are broadcast against ``y``.
1130 Mutually exclusive with ``other``.
1132 Returns
1133 -------
1134 `bool` | `numpy.ndarray`
1135 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
1136 single `bool` value. If ``x`` and ``y`` are array-like, a boolean
1137 array with their broadcasted shape.
1139 Notes
1140 -----
1141 In order to yield the desired behavior for floating-point arguments,
1142 points are actually tested against an interval that is 0.5 larger on
1143 both sides: this makes positions within the outer boundary of pixels
1144 (but beyond the centers of those pixels, which have integer positions)
1145 appear "on the image".
1146 """
1147 match other:
1148 case None:
1149 if x is None or y is None:
1150 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.")
1151 case Box():
1152 if x is not None or y is not None:
1153 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1154 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
1155 case XY() | YX(): 1155 ↛ 1159line 1155 didn't jump to line 1159 because the pattern on line 1155 always matched
1156 if x is not None or y is not None:
1157 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1158 x, y = other.x, other.y
1159 case _:
1160 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
1161 result = np.logical_and(self.x.contains(x), self.y.contains(y))
1162 if not result.shape:
1163 return bool(result)
1164 return result
1166 @overload
1167 def intersection(self, other: Box) -> Box: ...
1169 @overload
1170 def intersection(self, other: Region) -> Region | Box: ...
1172 @overload
1173 def intersection(self, other: Bounds) -> Bounds: ...
1175 def intersection(self, other: Bounds) -> Bounds:
1176 """Return a bounds object that is contained by both ``self`` and
1177 ``other``.
1179 When there is no overlap, `NoOverlapError` is raised.
1181 Parameters
1182 ----------
1183 other
1184 Bounds to intersect with this one.
1185 """
1186 from ._concrete_bounds import _intersect_box
1188 return _intersect_box(self, other)
1190 def dilated_by(self, padding: int) -> Box:
1191 """Return a new box padded by the given amount on all sides.
1193 Parameters
1194 ----------
1195 padding
1196 Number of pixels to pad the box by on every side.
1197 """
1198 return Box(*[i.dilated_by(padding) for i in self._intervals])
1200 def slice_within(self, other: Box) -> YX[slice]:
1201 """Return a `tuple` of `slice` objects that correspond to the
1202 positions in this box when the items of the container being sliced
1203 correspond to ``other``.
1205 This assumes ``other.contains(self)``.
1207 Parameters
1208 ----------
1209 other
1210 Box that the sliced container's items correspond to.
1211 """
1212 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
1214 @property
1215 def bbox(self) -> Box:
1216 """The box itself (`Box`).
1218 This is provided for compatibility with the `Bounds` interface.
1219 """
1220 return self
1222 def boundary(self) -> Iterator[YX[int]]:
1223 """Iterate over the corners of the box as ``(y, x)`` tuples.
1225 Yields
1226 ------
1227 corner
1228 Each corner in turn.
1229 """
1230 if len(self._intervals) != 2: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true
1231 raise TypeError("Box is not 2-d.")
1232 yield YX(self.y.min, self.x.min)
1233 yield YX(self.y.min, self.x.max)
1234 yield YX(self.y.max, self.x.max)
1235 yield YX(self.y.max, self.x.min)
1237 def to_polygon(self) -> Polygon:
1238 """Convert the box to a polygon with floating-point vertices.
1240 Notes
1241 -----
1242 Because the integer min and max coordinates of a box are
1243 interpreted as pixel centers, these are expanded by 0.5 on all sides
1244 before using them to form the polygon vertices.
1245 """
1246 from ._polygon import Polygon
1248 return Polygon.from_box(self)
1250 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1251 """Apply a coordinate transform to the box, returning a polygon.
1253 Parameters
1254 ----------
1255 transform
1256 Coordinate transform to apply (in the forward direction).
1258 Notes
1259 -----
1260 This transforms the polygon representation of the box (see
1261 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1262 full pixels before transforming them.
1263 """
1264 return self.to_polygon().transform(transform)
1266 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1267 return (Box, self._intervals)
1269 @classmethod
1270 def from_legacy(cls, legacy: Any) -> Box:
1271 """Convert from an `lsst.geom.Box2I` instance.
1273 Parameters
1274 ----------
1275 legacy
1276 Legacy `lsst.geom.Box2I` to convert.
1277 """
1278 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1280 def to_legacy(self) -> Any:
1281 """Convert to an `lsst.geom.BoxI` instance."""
1282 from lsst.geom import Box2I
1284 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1286 @classmethod
1287 def __get_pydantic_core_schema__(
1288 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1289 ) -> pcs.CoreSchema:
1290 from_typed_dict = pcs.chain_schema(
1291 [
1292 handler(_SerializedBox),
1293 pcs.no_info_plain_validator_function(cls._validate),
1294 ]
1295 )
1296 return pcs.json_or_python_schema(
1297 json_schema=from_typed_dict,
1298 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1299 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1300 )
1302 @classmethod
1303 def __get_pydantic_json_schema__(
1304 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1305 ) -> JsonSchemaValue:
1306 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1308 @classmethod
1309 def _validate(cls, data: _SerializedBox) -> Box:
1310 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1312 def _serialize(self) -> _SerializedBox:
1313 return {"y": self.y._serialize(), "x": self.x._serialize()}
1315 def serialize(self) -> Box:
1316 """Return a Pydantic-friendly representation of this object.
1318 This method just returns the `Box` itself, since that already provides
1319 Pydantic serialization hooks. It exists for compatibility with the
1320 `Bounds` protocol.
1321 """
1322 return self
1324 def deserialize(self) -> Box:
1325 """Deserialize a bounds object on the assumption it is a `Box`.
1327 This method just returns the `Box` itself, since that already provides
1328 Pydantic serialization hooks. It exists for compatibility with the
1329 `Bounds` protocol.
1330 """
1331 return self
1334class BoxSliceFactory:
1335 """A factory for `Box` objects using array-slice syntax.
1337 Parameters
1338 ----------
1339 y
1340 Slice factory used for the y axis.
1341 x
1342 Slice factory used for the x axis.
1344 Notes
1345 -----
1346 When `Box.factory` is indexed with a pair of slices, this returns a
1347 `Box` with exactly those bounds::
1349 assert (
1350 Box.factory[3:6, -1:2]
1351 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1352 )
1354 A missing start bound is replaced by ``0``, but a missing stop bound is
1355 not allowed.
1357 When obtained from the `Box.absolute` property, indices are absolute
1358 coordinate values, but any omitted bounds are replaced with the parent
1359 box's bounds::
1361 parent = Box.factory[3:6, -1:2]
1362 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1364 The final box is also checked to be contained by the parent box.
1366 When obtained from the `Box.local` property, indices are interpreted
1367 as relative to the parent box, and negative indices are relative to
1368 the end (like `~collections.abc.Sequence` indexing)::
1370 parent = Box.factory[3:6, -1:2]
1371 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1372 """
1374 def __init__(
1375 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1376 ) -> None:
1377 self._y = y
1378 self._x = x
1380 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1381 match key:
1382 case XY(x=x, y=y):
1383 return Box(y=self._y[y], x=self._x[x])
1384 case (y, x):
1385 return Box(y=self._y[y], x=self._x[x])
1386 case _:
1387 raise TypeError("Expected exactly two slices.")
1390Box.factory = BoxSliceFactory()
1393# The @runtime_checkable decorator makes this work as a butler storage class
1394@runtime_checkable
1395class Bounds(Protocol):
1396 """A protocol for objects that represent the validity region for a function
1397 defined in 2-d pixel coordinates.
1399 Notes
1400 -----
1401 Most objects natively have a simple 2-d bounding box as their bounds
1402 (typically the boundary of a sensor), and the `Box` class is hence the
1403 most common bounds implementation. But sometimes a large chunk of that
1404 box may be missing due to vignetting or bad amplifiers, and we may want to
1405 transform from one coordinate system to another. The Bounds interface is
1406 intended to handle both of these cases as well.
1407 """
1409 @property
1410 def bbox(self) -> Box: ...
1412 @overload
1413 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1415 @overload
1416 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1418 @overload
1419 def contains(self, /, *, x: int | float, y: int | float) -> bool: ...
1421 @overload
1422 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ...
1424 def contains(
1425 self,
1426 point: XY[Any] | YX[Any] | None = None,
1427 /,
1428 *,
1429 x: int | float | npt.ArrayLike | None = None,
1430 y: int | float | npt.ArrayLike | None = None,
1431 ) -> bool | np.ndarray:
1432 """Test whether one or more points fall within these bounds.
1434 Parameters
1435 ----------
1436 point
1437 An `XY` or `YX` coordinate pair to test for containment.
1438 Mutually exclusive with ``x`` and ``y``.
1439 x
1440 One or more X coordinates to test for containment, as a scalar or
1441 any array-like. Results are broadcast against ``y``.
1442 Mutually exclusive with ``point``.
1443 y
1444 One or more Y coordinates to test for containment, as a scalar or
1445 any array-like. Results are broadcast against ``x``.
1446 Mutually exclusive with ``point``.
1448 Returns
1449 -------
1450 `bool` | `numpy.ndarray`
1451 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1452 ``x`` and ``y`` are array-like, a boolean array with their
1453 broadcasted shape.
1454 """
1455 ...
1457 def intersection(self, other: Bounds) -> Bounds:
1458 """Compute the intersection of this bounds object with another.
1460 Parameters
1461 ----------
1462 other
1463 Bounds to intersect with this one.
1464 """
1465 ...
1467 def serialize(self) -> BoundsSerializationModel:
1468 """Convert a bounds instance into a serializable object.
1470 Notes
1471 -----
1472 The returned object must support direct nesting with Pydantic models
1473 and have a ``deserialize`` method (taking no arguments) that converts
1474 back to this `Bounds` type. It is common for `serialize` and
1475 ``deserialize`` to just return ``self``, when the bounds object is
1476 natively serializable.
1477 """
1478 ...
1481class BoundsError(ValueError):
1482 """Exception raised when an object is evaluated outside its bounds."""
1485class NoOverlapError(ValueError):
1486 """Exception raised when intervals or bounds do not overlap."""
1489class NotContainedError(RuntimeError):
1490 """Exception raised when intervals or bounds are not fully contained by
1491 another.
1492 """
1495if TYPE_CHECKING:
1497 def _test_types() -> None:
1498 interval = Interval(0, 10)
1499 box = Box(y=Interval(0, 10), x=Interval(0, 10))
1500 arr = np.zeros(3)
1502 # Interval.contains: scalar → bool, array-like → np.ndarray
1503 assert_type(interval.contains(5), bool)
1504 assert_type(interval.contains(arr), np.ndarray)
1506 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray
1507 assert_type(box.contains(box), bool)
1508 assert_type(box.contains(y=3, x=4), bool)
1509 assert_type(box.contains(y=3.0, x=4.0), bool)
1510 assert_type(box.contains(y=arr, x=arr), np.ndarray)
1511 assert_type(box.contains(XY(3, 4)), bool)
1512 assert_type(box.contains(YX(4, 3)), bool)
1513 assert_type(box.contains(XY(arr, arr)), np.ndarray)
1514 assert_type(box.contains(YX(arr, arr)), np.ndarray)
1516 # Bounds.contains (Protocol): XY/YX, scalar, array-like
1517 bounds: Bounds = box
1518 assert_type(bounds.contains(x=1, y=1), bool)
1519 assert_type(bounds.contains(x=1.0, y=1.0), bool)
1520 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
1521 assert_type(bounds.contains(XY(1, 1)), bool)
1522 assert_type(bounds.contains(YX(1, 1)), bool)
1523 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
1524 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)