Coverage for python/lsst/images/_geom.py: 52%
478 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 09:23 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-23 09:23 +0000
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14__all__ = (
15 "XY",
16 "YX",
17 "Bounds",
18 "BoundsError",
19 "Box",
20 "BoxSliceFactory",
21 "Interval",
22 "IntervalSliceFactory",
23 "NoOverlapError",
24 "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)
46import astropy.units as u
47import numpy as np
48import numpy.typing as npt
49import pydantic
50import pydantic_core.core_schema as pcs
51from pydantic.json_schema import JsonSchemaValue
53import starlink.Ast as Ast
55from .utils import round_half_down, round_half_up
57if TYPE_CHECKING:
58 import astropy.coordinates
60 from ._concrete_bounds import BoundsSerializationModel
61 from ._polygon import Polygon, Region
62 from ._transforms import SkyProjection, Transform
64 try:
65 from lsst.geom import Extent2D as LegacyExtent2D
66 from lsst.geom import Extent2I as LegacyExtent2I
67 from lsst.geom import Point2D as LegacyPoint2D
68 from lsst.geom import Point2I as LegacyPoint2I
69 except ImportError:
70 type LegacyExtent2I = Any # type: ignore[no-redef]
71 type LegacyPoint2I = Any # type: ignore[no-redef]
72 type LegacyExtent2D = Any # type: ignore[no-redef]
73 type LegacyPoint2D = Any # type: ignore[no-redef]
75# This pre-python-3.12 declaration is needed by Sphinx (probably the
76# autodoc-typehints plugin.
77T = TypeVar("T")
79# Interval and Box are defined as regular Python classes rather than
80# dataclasses or Pydantic models because we might want to implement them as
81# compiled-extension types in the future, and we want that to be transparent.
83# In a similar vein, we avoid declaring specific types for multidimensional
84# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
85# shapes) in order to leave room for more fully-featured types to be added
86# upstream of this package in the future.
89class _SerializedYX[T](TypedDict):
90 y: T
91 x: T
94class _SerializedXY[T](TypedDict):
95 x: T
96 y: T
99class YX[T](NamedTuple):
100 """A pair of per-dimension objects, ordered ``(y, x)``.
102 Notes
103 -----
104 `YX` is used for slices, shapes, and other 2-d pairs when the most
105 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
106 arithmetic operations behave as they would on a
107 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
108 concatenates).
110 In Pydantic models `YX` is serialized as a JSON object with ``y`` and
111 ``x`` keys rather than an array, so the dimension order is explicit in
112 the serialized form. Validation also accepts a two-element ``[y, x]``
113 array, the form emitted before the object form was introduced and still
114 present in files that cannot be rewritten. Pydantic only invokes the
115 schema hooks that
116 provide this for bare ``YX`` annotations, however, because it handles
117 parameterized named tuples specially; model fields that need a member
118 type (e.g. ``YX[int]``) must be annotated with `SerializableYX` instead.
120 See Also
121 --------
122 XY
123 """
125 y: T
126 """The y / row object."""
128 x: T
129 """The x / column object."""
131 @property
132 def xy(self) -> XY:
133 """A tuple of the same objects in the opposite order."""
134 return XY(x=self.x, y=self.y)
136 def map[U](self, func: Callable[[T], U]) -> YX[U]:
137 """Apply a function to both objects.
139 Parameters
140 ----------
141 func
142 Callable applied to each of the two objects in turn.
143 """
144 return YX(y=func(self.y), x=func(self.x))
146 def to_legacy_int_extent(self) -> LegacyExtent2I:
147 """Convert to a legacy `lsst.geom.Extent2I` object."""
148 from lsst.geom import Extent2I as LegacyExtent2I
150 return LegacyExtent2I(self.x, self.y)
152 def to_legacy_int_point(self) -> LegacyPoint2I:
153 """Convert to a legacy `lsst.geom.Point2I` object."""
154 from lsst.geom import Point2I as LegacyPoint2I
156 return LegacyPoint2I(self.x, self.y)
158 def to_legacy_float_extent(self) -> LegacyExtent2D:
159 """Convert to a legacy `lsst.geom.Extent2D` object."""
160 from lsst.geom import Extent2D as LegacyExtent2D
162 return LegacyExtent2D(self.x, self.y)
164 def to_legacy_float_point(self) -> LegacyPoint2D:
165 """Convert to a legacy `lsst.geom.Point2D` object."""
166 from lsst.geom import Point2D as LegacyPoint2D
168 return LegacyPoint2D(self.x, self.y)
170 @classmethod
171 def __get_pydantic_core_schema__(
172 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
173 ) -> pcs.CoreSchema:
174 item_type = args[0] if (args := get_args(source_type)) else Any
175 typed_dict = handler(_SerializedYX[item_type]) # type: ignore[valid-type]
176 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict)
177 # Files written before the object form was introduced (including the
178 # DP2 cell coadds, which cannot be rewritten) store pairs as ``[y, x]``
179 # arrays, so validation must accept that form for as long as those
180 # files remain readable, even if a future schema version stops
181 # documenting it. This is a strict list schema so that a stray `XY`
182 # (a tuple) assigned to a `YX` field fails to validate instead of
183 # being silently transposed.
184 from_list = pcs.no_info_after_validator_function(
185 cls._from_list,
186 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True),
187 )
188 validation = pcs.union_schema([from_typed_dict, from_list])
189 return pcs.json_or_python_schema(
190 json_schema=validation,
191 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]),
192 serialization=pcs.plain_serializer_function_ser_schema(
193 cls._serialize, info_arg=False, return_schema=typed_dict
194 ),
195 )
197 @classmethod
198 def _validate(cls, data: _SerializedYX[T]) -> YX[T]:
199 return cls(**data)
201 @classmethod
202 def _from_list(cls, data: list[T]) -> YX[T]:
203 return cls(*data)
205 def _serialize(self) -> _SerializedYX[T]:
206 return {"y": self.y, "x": self.x}
209class XY[T](NamedTuple):
210 """A pair of per-dimension objects, ordered ``(x, y)``.
212 Notes
213 -----
214 `XY` is used for points and other 2-d pairs when the most natural ordering
215 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
216 behave as they would on a `collections.abc.Sequence`, not a mathematical
217 vector (e.g. adding concatenates).
219 In Pydantic models `XY` is serialized as a JSON object with ``x`` and
220 ``y`` keys rather than an array, so the dimension order is explicit in
221 the serialized form. Validation also accepts a two-element ``[x, y]``
222 array, the form emitted before the object form was introduced and still
223 present in files that cannot be rewritten. Pydantic only invokes the
224 schema hooks that
225 provide this for bare ``XY`` annotations, however, because it handles
226 parameterized named tuples specially; model fields that need a member
227 type (e.g. ``XY[float]``) must be annotated with `SerializableXY` instead.
229 See Also
230 --------
231 YX
232 """
234 x: T
235 """The x / column object."""
237 y: T
238 """The y / row object."""
240 @property
241 def yx(self) -> YX:
242 """A tuple of the same objects in the opposite order."""
243 return YX(y=self.y, x=self.x)
245 def map[U](self, func: Callable[[T], U]) -> XY[U]:
246 """Apply a function to both objects.
248 Parameters
249 ----------
250 func
251 Callable applied to each of the two objects in turn.
252 """
253 return XY(x=func(self.x), y=func(self.y))
255 def to_legacy_int_extent(self) -> LegacyExtent2I:
256 """Convert to a legacy `lsst.geom.Extent2I` object."""
257 from lsst.geom import Extent2I as LegacyExtent2I
259 return LegacyExtent2I(self.x, self.y)
261 def to_legacy_int_point(self) -> LegacyPoint2I:
262 """Convert to a legacy `lsst.geom.Point2I` object."""
263 from lsst.geom import Point2I as LegacyPoint2I
265 return LegacyPoint2I(self.x, self.y)
267 def to_legacy_float_extent(self) -> LegacyExtent2D:
268 """Convert to a legacy `lsst.geom.Extent2D` object."""
269 from lsst.geom import Extent2D as LegacyExtent2D
271 return LegacyExtent2D(self.x, self.y)
273 def to_legacy_float_point(self) -> LegacyPoint2D:
274 """Convert to a legacy `lsst.geom.Point2D` object."""
275 from lsst.geom import Point2D as LegacyPoint2D
277 return LegacyPoint2D(self.x, self.y)
279 @classmethod
280 def __get_pydantic_core_schema__(
281 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
282 ) -> pcs.CoreSchema:
283 item_type = args[0] if (args := get_args(source_type)) else Any
284 typed_dict = handler(_SerializedXY[item_type]) # type: ignore[valid-type]
285 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict)
286 # Files written before the object form was introduced store pairs as
287 # ``[x, y]`` arrays, so validation must accept that form for as long
288 # as those files remain readable, even if a future schema version
289 # stops documenting it. This is a strict list schema so that a stray
290 # `YX` (a tuple) assigned to an `XY` field fails to validate instead
291 # of being silently transposed.
292 from_list = pcs.no_info_after_validator_function(
293 cls._from_list,
294 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True),
295 )
296 validation = pcs.union_schema([from_typed_dict, from_list])
297 return pcs.json_or_python_schema(
298 json_schema=validation,
299 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]),
300 serialization=pcs.plain_serializer_function_ser_schema(
301 cls._serialize, info_arg=False, return_schema=typed_dict
302 ),
303 )
305 @classmethod
306 def _validate(cls, data: _SerializedXY[T]) -> XY[T]:
307 return cls(**data)
309 @classmethod
310 def _from_list(cls, data: list[T]) -> XY[T]:
311 return cls(*data)
313 def _serialize(self) -> _SerializedXY[T]:
314 return {"x": self.x, "y": self.y}
317SerializableXY = Annotated[XY[T], pydantic.GetPydanticSchema(XY.__get_pydantic_core_schema__)]
318"""An `XY` annotation for Pydantic model fields that serializes as a JSON
319object with ``x`` and ``y`` keys (`XY` alone serializes as an array when
320parameterized, because Pydantic handles named tuples specially).
321"""
323SerializableYX = Annotated[YX[T], pydantic.GetPydanticSchema(YX.__get_pydantic_core_schema__)]
324"""A `YX` annotation for Pydantic model fields that serializes as a JSON
325object with ``y`` and ``x`` keys (`YX` alone serializes as an array when
326parameterized, because Pydantic handles named tuples specially).
327"""
330class _SerializedInterval(TypedDict):
331 start: int
332 stop: int
335@final
336class Interval:
337 """A 1-d integer interval with positive size.
339 Intervals should generally be constructed by a classmethod factory (e.g.
340 `hull` or `from_size`) or the `factory` slicing proxy::
342 interval = Interval.factory[a:b]
344 This can be more verbose than invoking __init__ directly, but since it
345 uses Python's syntax for half-inclusive ranges it's easier for readers to
346 immediately see how the endpoints are interpreted.
348 Parameters
349 ----------
350 start
351 Inclusive minimum point in the interval.
352 stop
353 One past the maximum point in the interval.
355 Notes
356 -----
357 Adding or subtracting an `int` from an interval returns a shifted interval.
359 `Interval` implements the necessary hooks to be included directly in a
360 `pydantic.BaseModel`, even though it is neither a built-in type nor a
361 Pydantic model itself.
362 """
364 def __init__(self, start: int, stop: int) -> None:
365 # Coerce to be defensive against numpy int scalars.
366 self._start = int(start)
367 self._stop = int(stop)
368 if not (self._stop > self._start):
369 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
371 __slots__ = ("_start", "_stop")
373 factory: ClassVar[IntervalSliceFactory]
374 """A factory for creating intervals using slice syntax.
376 For example::
378 interval = Interval.factory[2:5]
379 """
381 @classmethod
382 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
383 """Construct an interval that includes all of the given points and/or
384 intervals.
386 Parameters
387 ----------
388 first
389 First point or interval to include in the hull.
390 *args
391 Additional points and/or intervals to include in the hull.
392 """
393 if type(first) is Interval:
394 rmin = first.min
395 rmax = first.max
396 else:
397 rmin = rmax = first
398 for arg in args:
399 if type(arg) is Interval:
400 rmin = min(rmin, arg.min)
401 rmax = max(rmax, arg.max)
402 else:
403 rmin = min(rmin, arg)
404 rmax = max(rmax, arg)
405 return Interval(start=rmin, stop=rmax + 1)
407 @classmethod
408 def from_size(cls, size: int, start: int = 0) -> Interval:
409 """Construct an interval from its size and optional start.
411 Parameters
412 ----------
413 size
414 Number of points in the interval.
415 start
416 Inclusive minimum point in the interval.
417 """
418 return cls(start=start, stop=start + size)
420 @property
421 def min(self) -> int:
422 """Inclusive minimum point in the interval (`int`)."""
423 return self.start
425 @property
426 def max(self) -> int:
427 """Inclusive maximum point in the interval (`int`)."""
428 return self.stop - 1
430 @property
431 def start(self) -> int:
432 """Inclusive minimum point in the interval (`int`)."""
433 return self._start
435 @property
436 def stop(self) -> int:
437 """One past the maximum point in the interval (`int`)."""
438 return self._stop
440 @property
441 def size(self) -> int:
442 """Size of the interval (`int`)."""
443 return self.stop - self.start
445 @property
446 def range(self) -> __builtins__.range:
447 """An iterable over all values in the interval
448 (`__builtins__.range`).
449 """
450 return range(self.start, self.stop)
452 @property
453 def arange(self) -> np.ndarray:
454 """An array of all the values in the interval (`numpy.ndarray`).
456 Array values are integers.
457 """
458 return np.arange(self.start, self.stop)
460 @property
461 def absolute(self) -> IntervalSliceFactory:
462 """A factory for constructing a contained `Interval` using slice
463 syntax and absolute coordinates.
465 Notes
466 -----
467 Slice bounds that are absent are replaced with the bounds of ``self``.
468 """
469 return IntervalSliceFactory(self, is_local=False)
471 @property
472 def local(self) -> IntervalSliceFactory:
473 """A factory for constructing a contained `Interval` using a slice
474 relative to the start of this one (`IntervalSliceFactory`).
476 Notes
477 -----
478 This factory interprets slices as "local" coordinates, in which ``0``
479 corresponds to ``self.start``. Negative bounds are relative to
480 ``self.stop``, as is usually the case for Python sequences.
481 """
482 return IntervalSliceFactory(self, is_local=True)
484 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
485 """Return an array of values that spans the interval.
487 Parameters
488 ----------
489 n
490 How many values to return. The default (if ``step`` is also not
491 provided) is the size of the interval, i.e. equivalent to the
492 `arange` property (but converted to `float`).
493 step
494 Set ``n`` such that the distance between points is equal to or
495 just less than this. Mutually exclusive with ``n``.
497 Returns
498 -------
499 numpy.ndarray
500 Array of `float` values.
502 See Also
503 --------
504 numpy.linspace
505 """
506 if n is None:
507 if step is None:
508 return self.arange.astype(np.float64)
509 n = math.ceil(self.size / step)
510 elif step is not None:
511 raise TypeError("'n' and 'step' cannot both be provided.")
512 return np.linspace(self.min, self.max, n, dtype=np.float64)
514 @property
515 def center(self) -> float:
516 """The center of the interval (`float`)."""
517 return 0.5 * (self.min + self.max)
519 def padded(self, padding: int) -> Interval:
520 """Return a new interval expanded by the given padding on
521 either side.
523 Parameters
524 ----------
525 padding
526 Number of points to add to each side of the interval.
527 """
528 return Interval(self.start - padding, self.stop + padding)
530 def __str__(self) -> str:
531 return f"{self.start}:{self.stop}"
533 def __repr__(self) -> str:
534 return f"Interval(start={self.start}, stop={self.stop})"
536 def __eq__(self, other: object) -> bool:
537 if type(other) is Interval:
538 return self._start == other._start and self._stop == other._stop
539 return False
541 def __add__(self, other: int) -> Interval:
542 return Interval(start=self.start + other, stop=self.stop + other)
544 def __sub__(self, other: int) -> Interval:
545 return Interval(start=self.start - other, stop=self.stop - other)
547 def __contains__(self, x: int) -> bool:
548 return x >= self.start and x < self.stop
550 @overload
551 def contains(self, other: Interval | int | float) -> bool: ...
553 @overload
554 def contains(self, other: npt.ArrayLike) -> np.ndarray: ...
556 def contains(self, other: Interval | int | float | npt.ArrayLike) -> bool | np.ndarray:
557 """Test whether this interval fully contains another or one or more
558 points.
560 Parameters
561 ----------
562 other
563 Another interval to compare to, or one or more position values as
564 a scalar or any array-like.
566 Returns
567 -------
568 `bool` | `numpy.ndarray`
569 If a single interval or value was passed, a single `bool`. If an
570 array-like was passed, an array with the broadcasted shape.
572 Notes
573 -----
574 In order to yield the desired behavior for floating-point arguments,
575 points are actually tested against an interval that is 0.5 larger on
576 both sides: this makes positions within the outer boundary of pixels
577 (but beyond the centers of those pixels, which have integer positions)
578 appear "on the image".
579 """
580 if isinstance(other, Interval):
581 return self.start <= other.start and self.stop >= other.stop
582 else:
583 other = np.asarray(other)
584 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
585 if not result.shape:
586 return bool(result)
587 return result
589 def intersection(self, other: Interval) -> Interval:
590 """Return an interval that is contained by both ``self`` and ``other``.
592 When there is no overlap between the intervals, `NoOverlapError` is
593 raised.
595 Parameters
596 ----------
597 other
598 Interval to intersect with this one.
599 """
600 new_start = max(self.start, other.start)
601 new_stop = min(self.stop, other.stop)
602 if new_start < new_stop:
603 return Interval(start=new_start, stop=new_stop)
604 raise NoOverlapError(f"No overlap between {self} and {other}.")
606 def dilated_by(self, padding: int) -> Interval:
607 """Return a new interval padded by the given amount on both sides.
609 Parameters
610 ----------
611 padding
612 Number of points to add to each side of the interval.
613 """
614 return Interval(start=self._start - padding, stop=self._stop + padding)
616 def slice_within(self, other: Interval) -> slice:
617 """Return the `slice` that corresponds to the values in this interval
618 when the items of the container being sliced correspond to ``other``.
620 This assumes ``other.contains(self)``.
622 Parameters
623 ----------
624 other
625 Interval whose values correspond to the container being sliced.
626 """
627 if not other.contains(self):
628 raise IndexError(
629 f"Can not calculate a slice of {other} within {self} "
630 "since the given interval does not contain this one."
631 )
632 return slice(self.start - other.start, self.stop - other.start)
634 @classmethod
635 def from_legacy(cls, legacy: Any) -> Interval:
636 """Convert from an `lsst.geom.IntervalI` instance.
638 Parameters
639 ----------
640 legacy
641 Legacy `lsst.geom.IntervalI` instance to convert.
642 """
643 return cls(legacy.begin, legacy.end)
645 def to_legacy(self) -> Any:
646 """Convert to an `lsst.geom.IntervalI` instance."""
647 from lsst.geom import IntervalI
649 return IntervalI(min=self.min, max=self.max)
651 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
652 return (
653 Interval,
654 (
655 self._start,
656 self._stop,
657 ),
658 )
660 @classmethod
661 def __get_pydantic_core_schema__(
662 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
663 ) -> pcs.CoreSchema:
664 from_typed_dict = pcs.chain_schema(
665 [
666 handler(_SerializedInterval),
667 pcs.no_info_plain_validator_function(cls._validate),
668 ]
669 )
670 return pcs.json_or_python_schema(
671 json_schema=from_typed_dict,
672 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
673 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
674 )
676 @classmethod
677 def __get_pydantic_json_schema__(
678 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
679 ) -> JsonSchemaValue:
680 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
682 @classmethod
683 def _validate(cls, data: _SerializedInterval) -> Interval:
684 return cls(**data)
686 def _serialize(self) -> _SerializedInterval:
687 return {"start": self._start, "stop": self._stop}
690class IntervalSliceFactory:
691 """A factory for `Interval` objects using array-slice syntax.
693 Parameters
694 ----------
695 parent
696 Interval that constructed intervals must be contained by, or `None`
697 to allow any bounds.
698 is_local
699 Whether slice bounds are interpreted relative to the start of
700 ``parent`` rather than as absolute coordinates.
702 Notes
703 -----
704 When indexed with a single slice on the `Interval.factory` attribute, this
705 returns an `Interval` with exactly the given bounds::
707 assert Interval.factory[3:6] == Interval(start=3, stop=6)
709 A missing start bound is replaced by ``0``, but a missing stop bound is
710 not allowed.
712 When obtained from the `Interval.absolute` property, indices are absolute
713 coordinate values, but any omitted bounds are replaced with the parent
714 interval's bounds::
716 parent = Interval.factory[3:6]
717 assert Interval.factory[4:5] == parent.absolute[:5]
719 The final interval is also checked to be contained by the parent interval.
721 When obtained from the `Interval.local` property, indices are interpreted
722 as relative to the parent interval, and negative indices are relative to
723 the end (like `~collections.abc.Sequence` indexing)::
725 parent = Interval.factory[3:6]
726 assert Interval.factory[4:5] == parent.local[1:-1]
728 When the stop bound is greater than the size of the parent interval, the
729 returned interval is clipped to be contained by the parent (as in
730 `~collections.abc.Sequence` indexing).
731 """
733 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
734 self._parent = parent
735 self._is_local = is_local
737 def __getitem__(self, s: slice) -> Interval:
738 if s.step is not None and s.step != 1:
739 raise ValueError(f"Slice {s} has non-unit step.")
740 if self._is_local:
741 assert self._parent is not None, "is_local=True requires a parent interval"
742 start, stop, _ = s.indices(self._parent.size)
743 start += self._parent.start
744 stop += self._parent.start
745 else:
746 start = s.start
747 stop = s.stop
748 if start is None:
749 if self._parent is None:
750 start = 0
751 else:
752 start = self._parent.start
753 if stop is None:
754 if self._parent is None: 754 ↛ 755line 754 didn't jump to line 755 because the condition on line 754 was never true
755 raise IndexError("An Interval cannot have an empty upper bound.")
756 stop = self._parent.stop
757 if self._parent is not None:
758 if start < self._parent.start:
759 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
760 if stop > self._parent.stop:
761 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
762 return Interval(start=start, stop=stop)
765Interval.factory = IntervalSliceFactory()
768class _SerializedBox(TypedDict):
769 y: _SerializedInterval
770 x: _SerializedInterval
773class Box:
774 """An axis-aligned 2-d rectangular region.
776 Boxes should generally be constructed by a classmethod factory (e.g.
777 `from_shape`, `from_float_bounds`, `from_sky_circle`) or the `factory`
778 slicing proxy::
780 box = Box.factory[y_a:y_b, x_a:x_b]
782 unless the ``y`` and ``x`` intervals are already available.
784 Parameters
785 ----------
786 y
787 Interval for the y dimension.
788 x
789 Interval for the x dimension.
791 Raises
792 ------
793 TypeError
794 Raised if ``y`` or ``x`` is not an `Interval`.
796 Notes
797 -----
798 `Box` implements the necessary hooks to be included directly in a
799 `pydantic.BaseModel`, even though it is neither a built-in type nor a
800 Pydantic model itself.
801 """
803 def __init__(self, y: Interval, x: Interval) -> None:
804 match (y, x):
805 case (Interval(), Interval()):
806 self._intervals = YX(y, x)
807 case _:
808 raise TypeError(
809 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). "
810 "See the Box.factory slice syntax and the from_* class methods for other ways "
811 "to construct a Box."
812 )
814 __slots__ = ("_intervals",)
816 factory: ClassVar[BoxSliceFactory]
817 """A factory for creating boxes using slice syntax.
819 For example::
821 box = Box.factory[2:5, 3:9]
822 """
824 @classmethod
825 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
826 """Construct a box from its shape and optional start.
828 Parameters
829 ----------
830 shape
831 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
832 start
833 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
834 """
835 if start is None:
836 start = (0,) * len(shape)
837 match shape:
838 case XY(x=x_size, y=y_size):
839 pass
840 case [y_size, x_size]:
841 pass
842 case _:
843 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
844 match start:
845 case XY(x=x_start, y=y_start):
846 pass
847 case [y_start, x_start]:
848 pass
849 case _:
850 raise ValueError(f"Invalid sequence for start: {start!r}.")
851 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
853 @classmethod
854 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
855 """Construct a box from floating-point bounds ensuring that all the
856 are contained in the new box.
858 Parameters
859 ----------
860 x_min
861 Minimum X value.
862 x_max
863 Maximum X value.
864 y_min
865 Minimum Y value.
866 y_max
867 Maximum Y value.
869 Notes
870 -----
871 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
872 pixels whose centers lie within the bounds are included.
873 """
874 return Box.factory[
875 round_half_up(y_min) : round_half_down(y_max) + 1,
876 round_half_up(x_min) : round_half_down(x_max) + 1,
877 ]
879 @classmethod
880 def from_sky_circle(
881 cls,
882 sky_projection: SkyProjection,
883 center: astropy.coordinates.SkyCoord,
884 radius: astropy.coordinates.Angle,
885 ) -> Box:
886 """Calculate a bounding box from a sky projection and a circular
887 sky region.
889 Parameters
890 ----------
891 sky_projection
892 The sky projection mapping pixels to sky.
893 center
894 The center of the circle, as a scalar
895 `astropy.coordinates.SkyCoord` in any frame.
896 radius
897 Radius of the circle, as a scalar `astropy.coordinates.Angle`.
899 Returns
900 -------
901 Box
902 Bounding box enclosing the circle based on the sky projection.
904 Raises
905 ------
906 ValueError
907 Raised if ``center`` or ``radius`` is not scalar, or if this
908 image has no sky projection.
909 """
910 if not center.isscalar:
911 raise ValueError("The center of the sky circle must be a scalar SkyCoord.")
912 if not radius.isscalar:
913 raise ValueError("The radius of the sky circle must be a scalar Angle.")
914 center = center.transform_to("icrs")
916 # Use pyast directly for the region handling.
917 sky_region = Ast.Circle(
918 Ast.SkyFrame("System=ICRS"),
919 1,
920 [center.ra.rad, center.dec.rad],
921 [radius.to_value(u.rad)],
922 )
924 # If it is not already a pyast mapping
925 # (e.g., it is implemented with astshim), convert it by round-tripping
926 # the AST textual serialization through a pyast Channel.
927 sky_to_pixel: Any = sky_projection.sky_to_pixel_transform._ast_mapping
928 if not isinstance(sky_to_pixel, Ast.Mapping): 928 ↛ 934line 928 didn't jump to line 934 because the condition on line 928 was always true
929 # Comments must be disabled for pyast to be able to parse the
930 # astshim serialization.
931 sky_to_pixel = Ast.Channel(sky_to_pixel.show(False).splitlines()).read()
933 # Calculate the Box around the region.
934 pixel_region = sky_region.mapregion(sky_to_pixel, Ast.Frame(2))
935 lbnd, ubnd = pixel_region.getregionbounds()
936 return Box.from_float_bounds(
937 x_min=float(lbnd[0]),
938 x_max=float(ubnd[0]),
939 y_min=float(lbnd[1]),
940 y_max=float(ubnd[1]),
941 )
943 @property
944 def min(self) -> YX[int]:
945 """The inclusive minimum bounds of the box, ordered ``(y, x)``
946 (`YX` [`int`]).
947 """
948 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
950 @property
951 def max(self) -> YX[int]:
952 """The inclusive maximum bounds of the box, ordered ``(y, x)``
953 (`YX` [`int`]).
954 """
955 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
957 @property
958 def start(self) -> YX[int]:
959 """Tuple holding the inclusive `Interval.start` bvound, ordered
960 ``(y, x)`` (`YX` [`int`]).
962 This is an alias for `min`, typically paired with `stop` for
963 half-exclusive ranges.
964 """
965 return YX(self.y.start, self.x.start)
967 @property
968 def stop(self) -> YX[int]:
969 """Tuple holding the exclusive `Interval.stop` bound, ordered
970 ``(y, x)`` (`YX` [`int`]).
972 The values in this tuple are one greater than those in `max`. It is
973 typically paired with `start` for half-exclusive ranges.
974 """
975 return YX(self.y.stop, self.x.stop)
977 @property
978 def shape(self) -> YX[int]:
979 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
980 (`YX` [`int`]).
981 """
982 return YX(self.y.size, self.x.size)
984 @property
985 def x(self) -> Interval:
986 """The x-dimension interval (`int`)."""
987 return self._intervals[-1]
989 @property
990 def y(self) -> Interval:
991 """The y-dimension interval (`int`)."""
992 return self._intervals[-2]
994 @property
995 def area(self) -> int:
996 """The number of pixels in the box (`int`)."""
997 return self.x.size * self.y.size
999 @property
1000 def absolute(self) -> BoxSliceFactory:
1001 """A factory for constructing a contained `Box` using slice
1002 syntax and absolute coordinates.
1004 Notes
1005 -----
1006 Slice bounds that are absent are replaced with the bounds of ``self``.
1007 """
1008 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
1010 @property
1011 def local(self) -> BoxSliceFactory:
1012 """A factory for constructing a contained `Interval` using a slice
1013 relative to the start of this one (`BoxSliceFactory`).
1015 Notes
1016 -----
1017 This factory interprets slices as "local" coordinates, in which ``0``
1018 corresponds to ``self.start``. Negative bounds are relative to
1019 ``self.stop``, as is usually the case for Python sequences.
1020 """
1021 return BoxSliceFactory(y=self.y.local, x=self.x.local)
1023 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
1024 """Return a pair of 2-d arrays of the coordinate values of the box.
1026 Parameters
1027 ----------
1028 n
1029 Number of points in each dimension. If a sequence, points are
1030 assumed to be ordered ``(x, y)`` unless a `YX` instance is
1031 provided.
1032 step
1033 Set ``n`` such that the distance between points is equal to or
1034 just less than this in each dimension. Mutually exclusive with
1035 ``n``.
1037 Returns
1038 -------
1039 `XY` [`numpy.ndarray`]
1040 A pair of arrays, each of which is 2-d with floating-point values.
1042 See Also
1043 --------
1044 numpy.meshgrid
1045 """
1046 if n is not None and step is not None:
1047 raise TypeError("'n' and 'step' cannot both be provided.")
1048 match n:
1049 case int():
1050 ax = self.x.linspace(n)
1051 ay = self.y.linspace(n)
1052 case YX(y=ny, x=nx):
1053 ax = self.x.linspace(nx)
1054 ay = self.y.linspace(ny)
1055 case [nx, ny]:
1056 ax = self.x.linspace(nx)
1057 ay = self.y.linspace(ny)
1058 case None:
1059 ax = self.x.linspace(step=step)
1060 ay = self.y.linspace(step=step)
1061 case _:
1062 raise ValueError(f"Unexpected values for n ({n})")
1063 return XY(*np.meshgrid(ax, ay))
1065 def padded(self, padding: int) -> Box:
1066 """Return a new box expanded by the given padding on
1067 all sides.
1069 Parameters
1070 ----------
1071 padding
1072 Number of pixels to expand the box by on every side.
1073 """
1074 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
1076 def __eq__(self, other: object) -> bool:
1077 if type(other) is Box:
1078 return self._intervals == other._intervals
1079 return False
1081 def __str__(self) -> str:
1082 return f"[y={self.y}, x={self.x}]"
1084 def __repr__(self) -> str:
1085 return f"Box(y={self.y!r}, x={self.x!r})"
1087 @overload
1088 def contains(self, other: Box, /) -> bool: ...
1090 @overload
1091 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1093 @overload
1094 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1096 @overload
1097 def contains(self, /, *, y: int | float, x: int | float) -> bool: ...
1099 @overload
1100 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ...
1102 def contains(
1103 self,
1104 other: Box | XY[Any] | YX[Any] | None = None,
1105 /,
1106 *,
1107 y: Any = None,
1108 x: Any = None,
1109 ) -> bool | np.ndarray:
1110 """Test whether this box fully contains another or one or more points.
1112 Parameters
1113 ----------
1114 other
1115 Another box, or an `XY` or `YX` coordinate pair, to compare to.
1116 Mutually exclusive with ``x`` and ``y``.
1117 y
1118 One or more Y coordinates to test for containment, as a scalar or
1119 any array-like. Results are broadcast against ``x``.
1120 Mutually exclusive with ``other``.
1121 x
1122 One or more X coordinates to test for containment, as a scalar or
1123 any array-like. Results are broadcast against ``y``.
1124 Mutually exclusive with ``other``.
1126 Returns
1127 -------
1128 `bool` | `numpy.ndarray`
1129 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
1130 single `bool` value. If ``x`` and ``y`` are array-like, a boolean
1131 array with their broadcasted shape.
1133 Notes
1134 -----
1135 In order to yield the desired behavior for floating-point arguments,
1136 points are actually tested against an interval that is 0.5 larger on
1137 both sides: this makes positions within the outer boundary of pixels
1138 (but beyond the centers of those pixels, which have integer positions)
1139 appear "on the image".
1140 """
1141 match other:
1142 case None:
1143 if x is None or y is None:
1144 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.")
1145 case Box():
1146 if x is not None or y is not None:
1147 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1148 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
1149 case XY() | YX(): 1149 ↛ 1153line 1149 didn't jump to line 1153 because the pattern on line 1149 always matched
1150 if x is not None or y is not None:
1151 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1152 x, y = other.x, other.y
1153 case _:
1154 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
1155 result = np.logical_and(self.x.contains(x), self.y.contains(y))
1156 if not result.shape:
1157 return bool(result)
1158 return result
1160 @overload
1161 def intersection(self, other: Box) -> Box: ...
1163 @overload
1164 def intersection(self, other: Region) -> Region | Box: ...
1166 @overload
1167 def intersection(self, other: Bounds) -> Bounds: ...
1169 def intersection(self, other: Bounds) -> Bounds:
1170 """Return a bounds object that is contained by both ``self`` and
1171 ``other``.
1173 When there is no overlap, `NoOverlapError` is raised.
1175 Parameters
1176 ----------
1177 other
1178 Bounds to intersect with this one.
1179 """
1180 from ._concrete_bounds import _intersect_box
1182 return _intersect_box(self, other)
1184 def dilated_by(self, padding: int) -> Box:
1185 """Return a new box padded by the given amount on all sides.
1187 Parameters
1188 ----------
1189 padding
1190 Number of pixels to pad the box by on every side.
1191 """
1192 return Box(*[i.dilated_by(padding) for i in self._intervals])
1194 def slice_within(self, other: Box) -> YX[slice]:
1195 """Return a `tuple` of `slice` objects that correspond to the
1196 positions in this box when the items of the container being sliced
1197 correspond to ``other``.
1199 This assumes ``other.contains(self)``.
1201 Parameters
1202 ----------
1203 other
1204 Box that the sliced container's items correspond to.
1205 """
1206 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
1208 @property
1209 def bbox(self) -> Box:
1210 """The box itself (`Box`).
1212 This is provided for compatibility with the `Bounds` interface.
1213 """
1214 return self
1216 def boundary(self) -> Iterator[YX[int]]:
1217 """Iterate over the corners of the box as ``(y, x)`` tuples.
1219 Yields
1220 ------
1221 corner
1222 Each corner in turn.
1223 """
1224 if len(self._intervals) != 2: 1224 ↛ 1225line 1224 didn't jump to line 1225 because the condition on line 1224 was never true
1225 raise TypeError("Box is not 2-d.")
1226 yield YX(self.y.min, self.x.min)
1227 yield YX(self.y.min, self.x.max)
1228 yield YX(self.y.max, self.x.max)
1229 yield YX(self.y.max, self.x.min)
1231 def to_polygon(self) -> Polygon:
1232 """Convert the box to a polygon with floating-point vertices.
1234 Notes
1235 -----
1236 Because the integer min and max coordinates of a box are
1237 interpreted as pixel centers, these are expanded by 0.5 on all sides
1238 before using them to form the polygon vertices.
1239 """
1240 from ._polygon import Polygon
1242 return Polygon.from_box(self)
1244 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1245 """Apply a coordinate transform to the box, returning a polygon.
1247 Parameters
1248 ----------
1249 transform
1250 Coordinate transform to apply (in the forward direction).
1252 Notes
1253 -----
1254 This transforms the polygon representation of the box (see
1255 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1256 full pixels before transforming them.
1257 """
1258 return self.to_polygon().transform(transform)
1260 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1261 return (Box, self._intervals)
1263 @classmethod
1264 def from_legacy(cls, legacy: Any) -> Box:
1265 """Convert from an `lsst.geom.Box2I` instance.
1267 Parameters
1268 ----------
1269 legacy
1270 Legacy `lsst.geom.Box2I` to convert.
1271 """
1272 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1274 def to_legacy(self) -> Any:
1275 """Convert to an `lsst.geom.BoxI` instance."""
1276 from lsst.geom import Box2I
1278 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1280 @classmethod
1281 def __get_pydantic_core_schema__(
1282 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1283 ) -> pcs.CoreSchema:
1284 from_typed_dict = pcs.chain_schema(
1285 [
1286 handler(_SerializedBox),
1287 pcs.no_info_plain_validator_function(cls._validate),
1288 ]
1289 )
1290 return pcs.json_or_python_schema(
1291 json_schema=from_typed_dict,
1292 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1293 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1294 )
1296 @classmethod
1297 def __get_pydantic_json_schema__(
1298 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1299 ) -> JsonSchemaValue:
1300 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1302 @classmethod
1303 def _validate(cls, data: _SerializedBox) -> Box:
1304 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1306 def _serialize(self) -> _SerializedBox:
1307 return {"y": self.y._serialize(), "x": self.x._serialize()}
1309 def serialize(self) -> Box:
1310 """Return a Pydantic-friendly representation of this object.
1312 This method just returns the `Box` itself, since that already provides
1313 Pydantic serialization hooks. It exists for compatibility with the
1314 `Bounds` protocol.
1315 """
1316 return self
1318 def deserialize(self) -> Box:
1319 """Deserialize a bounds object on the assumption it is a `Box`.
1321 This method just returns the `Box` itself, since that already provides
1322 Pydantic serialization hooks. It exists for compatibility with the
1323 `Bounds` protocol.
1324 """
1325 return self
1328class BoxSliceFactory:
1329 """A factory for `Box` objects using array-slice syntax.
1331 Parameters
1332 ----------
1333 y
1334 Slice factory used for the y axis.
1335 x
1336 Slice factory used for the x axis.
1338 Notes
1339 -----
1340 When `Box.factory` is indexed with a pair of slices, this returns a
1341 `Box` with exactly those bounds::
1343 assert (
1344 Box.factory[3:6, -1:2]
1345 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1346 )
1348 A missing start bound is replaced by ``0``, but a missing stop bound is
1349 not allowed.
1351 When obtained from the `Box.absolute` property, indices are absolute
1352 coordinate values, but any omitted bounds are replaced with the parent
1353 box's bounds::
1355 parent = Box.factory[3:6, -1:2]
1356 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1358 The final box is also checked to be contained by the parent box.
1360 When obtained from the `Box.local` property, indices are interpreted
1361 as relative to the parent box, and negative indices are relative to
1362 the end (like `~collections.abc.Sequence` indexing)::
1364 parent = Box.factory[3:6, -1:2]
1365 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1366 """
1368 def __init__(
1369 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1370 ) -> None:
1371 self._y = y
1372 self._x = x
1374 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1375 match key:
1376 case XY(x=x, y=y):
1377 return Box(y=self._y[y], x=self._x[x])
1378 case (y, x):
1379 return Box(y=self._y[y], x=self._x[x])
1380 case _:
1381 raise TypeError("Expected exactly two slices.")
1384Box.factory = BoxSliceFactory()
1387class Bounds(Protocol):
1388 """A protocol for objects that represent the validity region for a function
1389 defined in 2-d pixel coordinates.
1391 Notes
1392 -----
1393 Most objects natively have a simple 2-d bounding box as their bounds
1394 (typically the boundary of a sensor), and the `Box` class is hence the
1395 most common bounds implementation. But sometimes a large chunk of that
1396 box may be missing due to vignetting or bad amplifiers, and we may want to
1397 transform from one coordinate system to another. The Bounds interface is
1398 intended to handle both of these cases as well.
1399 """
1401 @property
1402 def bbox(self) -> Box: ...
1404 @overload
1405 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1407 @overload
1408 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1410 @overload
1411 def contains(self, /, *, x: int | float, y: int | float) -> bool: ...
1413 @overload
1414 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ...
1416 def contains(
1417 self,
1418 point: XY[Any] | YX[Any] | None = None,
1419 /,
1420 *,
1421 x: int | float | npt.ArrayLike | None = None,
1422 y: int | float | npt.ArrayLike | None = None,
1423 ) -> bool | np.ndarray:
1424 """Test whether one or more points fall within these bounds.
1426 Parameters
1427 ----------
1428 point
1429 An `XY` or `YX` coordinate pair to test for containment.
1430 Mutually exclusive with ``x`` and ``y``.
1431 x
1432 One or more X coordinates to test for containment, as a scalar or
1433 any array-like. Results are broadcast against ``y``.
1434 Mutually exclusive with ``point``.
1435 y
1436 One or more Y coordinates to test for containment, as a scalar or
1437 any array-like. Results are broadcast against ``x``.
1438 Mutually exclusive with ``point``.
1440 Returns
1441 -------
1442 `bool` | `numpy.ndarray`
1443 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1444 ``x`` and ``y`` are array-like, a boolean array with their
1445 broadcasted shape.
1446 """
1447 ...
1449 def intersection(self, other: Bounds) -> Bounds:
1450 """Compute the intersection of this bounds object with another.
1452 Parameters
1453 ----------
1454 other
1455 Bounds to intersect with this one.
1456 """
1457 ...
1459 def serialize(self) -> BoundsSerializationModel:
1460 """Convert a bounds instance into a serializable object.
1462 Notes
1463 -----
1464 The returned object must support direct nesting with Pydantic models
1465 and have a ``deserialize`` method (taking no arguments) that converts
1466 back to this `Bounds` type. It is common for `serialize` and
1467 ``deserialize`` to just return ``self``, when the bounds object is
1468 natively serializable.
1469 """
1470 ...
1473class BoundsError(ValueError):
1474 """Exception raised when an object is evaluated outside its bounds."""
1477class NoOverlapError(ValueError):
1478 """Exception raised when intervals or bounds do not overlap."""
1481class NotContainedError(RuntimeError):
1482 """Exception raised when intervals or bounds are not fully contained by
1483 another.
1484 """
1487if TYPE_CHECKING:
1489 def _test_types() -> None:
1490 interval = Interval(0, 10)
1491 box = Box(y=Interval(0, 10), x=Interval(0, 10))
1492 arr = np.zeros(3)
1494 # Interval.contains: scalar → bool, array-like → np.ndarray
1495 assert_type(interval.contains(5), bool)
1496 assert_type(interval.contains(arr), np.ndarray)
1498 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray
1499 assert_type(box.contains(box), bool)
1500 assert_type(box.contains(y=3, x=4), bool)
1501 assert_type(box.contains(y=3.0, x=4.0), bool)
1502 assert_type(box.contains(y=arr, x=arr), np.ndarray)
1503 assert_type(box.contains(XY(3, 4)), bool)
1504 assert_type(box.contains(YX(4, 3)), bool)
1505 assert_type(box.contains(XY(arr, arr)), np.ndarray)
1506 assert_type(box.contains(YX(arr, arr)), np.ndarray)
1508 # Bounds.contains (Protocol): XY/YX, scalar, array-like
1509 bounds: Bounds = box
1510 assert_type(bounds.contains(x=1, y=1), bool)
1511 assert_type(bounds.contains(x=1.0, y=1.0), bool)
1512 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
1513 assert_type(bounds.contains(XY(1, 1)), bool)
1514 assert_type(bounds.contains(YX(1, 1)), bool)
1515 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
1516 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)