Coverage for python/lsst/images/_geom.py: 52%
478 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 17:43 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-22 17:43 +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 Parameters
340 ----------
341 start
342 Inclusive minimum point in the interval.
343 stop
344 One past the maximum point in the interval.
346 Notes
347 -----
348 Adding or subtracting an `int` from an interval returns a shifted interval.
350 `Interval` implements the necessary hooks to be included directly in a
351 `pydantic.BaseModel`, even though it is neither a built-in type nor a
352 Pydantic model itself.
353 """
355 def __init__(self, start: int, stop: int) -> None:
356 # Coerce to be defensive against numpy int scalars.
357 self._start = int(start)
358 self._stop = int(stop)
359 if not (self._stop > self._start):
360 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
362 __slots__ = ("_start", "_stop")
364 factory: ClassVar[IntervalSliceFactory]
365 """A factory for creating intervals using slice syntax.
367 For example::
369 interval = Interval.factory[2:5]
370 """
372 @classmethod
373 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
374 """Construct an interval that includes all of the given points and/or
375 intervals.
377 Parameters
378 ----------
379 first
380 First point or interval to include in the hull.
381 *args
382 Additional points and/or intervals to include in the hull.
383 """
384 if type(first) is Interval:
385 rmin = first.min
386 rmax = first.max
387 else:
388 rmin = rmax = first
389 for arg in args:
390 if type(arg) is Interval:
391 rmin = min(rmin, arg.min)
392 rmax = max(rmax, arg.max)
393 else:
394 rmin = min(rmin, arg)
395 rmax = max(rmax, arg)
396 return Interval(start=rmin, stop=rmax + 1)
398 @classmethod
399 def from_size(cls, size: int, start: int = 0) -> Interval:
400 """Construct an interval from its size and optional start.
402 Parameters
403 ----------
404 size
405 Number of points in the interval.
406 start
407 Inclusive minimum point in the interval.
408 """
409 return cls(start=start, stop=start + size)
411 @property
412 def min(self) -> int:
413 """Inclusive minimum point in the interval (`int`)."""
414 return self.start
416 @property
417 def max(self) -> int:
418 """Inclusive maximum point in the interval (`int`)."""
419 return self.stop - 1
421 @property
422 def start(self) -> int:
423 """Inclusive minimum point in the interval (`int`)."""
424 return self._start
426 @property
427 def stop(self) -> int:
428 """One past the maximum point in the interval (`int`)."""
429 return self._stop
431 @property
432 def size(self) -> int:
433 """Size of the interval (`int`)."""
434 return self.stop - self.start
436 @property
437 def range(self) -> __builtins__.range:
438 """An iterable over all values in the interval
439 (`__builtins__.range`).
440 """
441 return range(self.start, self.stop)
443 @property
444 def arange(self) -> np.ndarray:
445 """An array of all the values in the interval (`numpy.ndarray`).
447 Array values are integers.
448 """
449 return np.arange(self.start, self.stop)
451 @property
452 def absolute(self) -> IntervalSliceFactory:
453 """A factory for constructing a contained `Interval` using slice
454 syntax and absolute coordinates.
456 Notes
457 -----
458 Slice bounds that are absent are replaced with the bounds of ``self``.
459 """
460 return IntervalSliceFactory(self, is_local=False)
462 @property
463 def local(self) -> IntervalSliceFactory:
464 """A factory for constructing a contained `Interval` using a slice
465 relative to the start of this one (`IntervalSliceFactory`).
467 Notes
468 -----
469 This factory interprets slices as "local" coordinates, in which ``0``
470 corresponds to ``self.start``. Negative bounds are relative to
471 ``self.stop``, as is usually the case for Python sequences.
472 """
473 return IntervalSliceFactory(self, is_local=True)
475 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
476 """Return an array of values that spans the interval.
478 Parameters
479 ----------
480 n
481 How many values to return. The default (if ``step`` is also not
482 provided) is the size of the interval, i.e. equivalent to the
483 `arange` property (but converted to `float`).
484 step
485 Set ``n`` such that the distance between points is equal to or
486 just less than this. Mutually exclusive with ``n``.
488 Returns
489 -------
490 numpy.ndarray
491 Array of `float` values.
493 See Also
494 --------
495 numpy.linspace
496 """
497 if n is None:
498 if step is None:
499 return self.arange.astype(np.float64)
500 n = math.ceil(self.size / step)
501 elif step is not None:
502 raise TypeError("'n' and 'step' cannot both be provided.")
503 return np.linspace(self.min, self.max, n, dtype=np.float64)
505 @property
506 def center(self) -> float:
507 """The center of the interval (`float`)."""
508 return 0.5 * (self.min + self.max)
510 def padded(self, padding: int) -> Interval:
511 """Return a new interval expanded by the given padding on
512 either side.
514 Parameters
515 ----------
516 padding
517 Number of points to add to each side of the interval.
518 """
519 return Interval(self.start - padding, self.stop + padding)
521 def __str__(self) -> str:
522 return f"{self.start}:{self.stop}"
524 def __repr__(self) -> str:
525 return f"Interval(start={self.start}, stop={self.stop})"
527 def __eq__(self, other: object) -> bool:
528 if type(other) is Interval:
529 return self._start == other._start and self._stop == other._stop
530 return False
532 def __add__(self, other: int) -> Interval:
533 return Interval(start=self.start + other, stop=self.stop + other)
535 def __sub__(self, other: int) -> Interval:
536 return Interval(start=self.start - other, stop=self.stop - other)
538 def __contains__(self, x: int) -> bool:
539 return x >= self.start and x < self.stop
541 @overload
542 def contains(self, other: Interval | int | float) -> bool: ...
544 @overload
545 def contains(self, other: npt.ArrayLike) -> np.ndarray: ...
547 def contains(self, other: Interval | int | float | npt.ArrayLike) -> bool | np.ndarray:
548 """Test whether this interval fully contains another or one or more
549 points.
551 Parameters
552 ----------
553 other
554 Another interval to compare to, or one or more position values as
555 a scalar or any array-like.
557 Returns
558 -------
559 `bool` | `numpy.ndarray`
560 If a single interval or value was passed, a single `bool`. If an
561 array-like was passed, an array with the broadcasted shape.
563 Notes
564 -----
565 In order to yield the desired behavior for floating-point arguments,
566 points are actually tested against an interval that is 0.5 larger on
567 both sides: this makes positions within the outer boundary of pixels
568 (but beyond the centers of those pixels, which have integer positions)
569 appear "on the image".
570 """
571 if isinstance(other, Interval):
572 return self.start <= other.start and self.stop >= other.stop
573 else:
574 other = np.asarray(other)
575 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
576 if not result.shape:
577 return bool(result)
578 return result
580 def intersection(self, other: Interval) -> Interval:
581 """Return an interval that is contained by both ``self`` and ``other``.
583 When there is no overlap between the intervals, `NoOverlapError` is
584 raised.
586 Parameters
587 ----------
588 other
589 Interval to intersect with this one.
590 """
591 new_start = max(self.start, other.start)
592 new_stop = min(self.stop, other.stop)
593 if new_start < new_stop:
594 return Interval(start=new_start, stop=new_stop)
595 raise NoOverlapError(f"No overlap between {self} and {other}.")
597 def dilated_by(self, padding: int) -> Interval:
598 """Return a new interval padded by the given amount on both sides.
600 Parameters
601 ----------
602 padding
603 Number of points to add to each side of the interval.
604 """
605 return Interval(start=self._start - padding, stop=self._stop + padding)
607 def slice_within(self, other: Interval) -> slice:
608 """Return the `slice` that corresponds to the values in this interval
609 when the items of the container being sliced correspond to ``other``.
611 This assumes ``other.contains(self)``.
613 Parameters
614 ----------
615 other
616 Interval whose values correspond to the container being sliced.
617 """
618 if not other.contains(self):
619 raise IndexError(
620 f"Can not calculate a slice of {other} within {self} "
621 "since the given interval does not contain this one."
622 )
623 return slice(self.start - other.start, self.stop - other.start)
625 @classmethod
626 def from_legacy(cls, legacy: Any) -> Interval:
627 """Convert from an `lsst.geom.IntervalI` instance.
629 Parameters
630 ----------
631 legacy
632 Legacy `lsst.geom.IntervalI` instance to convert.
633 """
634 return cls(legacy.begin, legacy.end)
636 def to_legacy(self) -> Any:
637 """Convert to an `lsst.geom.IntervalI` instance."""
638 from lsst.geom import IntervalI
640 return IntervalI(min=self.min, max=self.max)
642 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
643 return (
644 Interval,
645 (
646 self._start,
647 self._stop,
648 ),
649 )
651 @classmethod
652 def __get_pydantic_core_schema__(
653 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
654 ) -> pcs.CoreSchema:
655 from_typed_dict = pcs.chain_schema(
656 [
657 handler(_SerializedInterval),
658 pcs.no_info_plain_validator_function(cls._validate),
659 ]
660 )
661 return pcs.json_or_python_schema(
662 json_schema=from_typed_dict,
663 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
664 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
665 )
667 @classmethod
668 def __get_pydantic_json_schema__(
669 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
670 ) -> JsonSchemaValue:
671 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
673 @classmethod
674 def _validate(cls, data: _SerializedInterval) -> Interval:
675 return cls(**data)
677 def _serialize(self) -> _SerializedInterval:
678 return {"start": self._start, "stop": self._stop}
681class IntervalSliceFactory:
682 """A factory for `Interval` objects using array-slice syntax.
684 Parameters
685 ----------
686 parent
687 Interval that constructed intervals must be contained by, or `None`
688 to allow any bounds.
689 is_local
690 Whether slice bounds are interpreted relative to the start of
691 ``parent`` rather than as absolute coordinates.
693 Notes
694 -----
695 When indexed with a single slice on the `Interval.factory` attribute, this
696 returns an `Interval` with exactly the given bounds::
698 assert Interval.factory[3:6] == Interval(start=3, stop=6)
700 A missing start bound is replaced by ``0``, but a missing stop bound is
701 not allowed.
703 When obtained from the `Interval.absolute` property, indices are absolute
704 coordinate values, but any omitted bounds are replaced with the parent
705 interval's bounds::
707 parent = Interval.factory[3:6]
708 assert Interval.factory[4:5] == parent.absolute[:5]
710 The final interval is also checked to be contained by the parent interval.
712 When obtained from the `Interval.local` property, indices are interpreted
713 as relative to the parent interval, and negative indices are relative to
714 the end (like `~collections.abc.Sequence` indexing)::
716 parent = Interval.factory[3:6]
717 assert Interval.factory[4:5] == parent.local[1:-1]
719 When the stop bound is greater than the size of the parent interval, the
720 returned interval is clipped to be contained by the parent (as in
721 `~collections.abc.Sequence` indexing).
722 """
724 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
725 self._parent = parent
726 self._is_local = is_local
728 def __getitem__(self, s: slice) -> Interval:
729 if s.step is not None and s.step != 1:
730 raise ValueError(f"Slice {s} has non-unit step.")
731 if self._is_local:
732 assert self._parent is not None, "is_local=True requires a parent interval"
733 start, stop, _ = s.indices(self._parent.size)
734 start += self._parent.start
735 stop += self._parent.start
736 else:
737 start = s.start
738 stop = s.stop
739 if start is None:
740 if self._parent is None:
741 start = 0
742 else:
743 start = self._parent.start
744 if stop is None:
745 if self._parent is None: 745 ↛ 746line 745 didn't jump to line 746 because the condition on line 745 was never true
746 raise IndexError("An Interval cannot have an empty upper bound.")
747 stop = self._parent.stop
748 if self._parent is not None:
749 if start < self._parent.start:
750 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
751 if stop > self._parent.stop:
752 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
753 return Interval(start=start, stop=stop)
756Interval.factory = IntervalSliceFactory()
759class _SerializedBox(TypedDict):
760 y: _SerializedInterval
761 x: _SerializedInterval
764class Box:
765 """An axis-aligned 2-d rectangular region.
767 Parameters
768 ----------
769 y
770 Interval for the y dimension.
771 x
772 Interval for the x dimension.
774 Raises
775 ------
776 TypeError
777 Raised if ``y`` or ``x`` is not an `Interval`.
779 Notes
780 -----
781 `Box` implements the necessary hooks to be included directly in a
782 `pydantic.BaseModel`, even though it is neither a built-in type nor a
783 Pydantic model itself.
784 """
786 def __init__(self, y: Interval, x: Interval) -> None:
787 match (y, x):
788 case (Interval(), Interval()):
789 self._intervals = YX(y, x)
790 case _:
791 raise TypeError(
792 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). "
793 "See the Box.factory slice syntax and the from_* class methods for other ways "
794 "to construct a Box."
795 )
797 __slots__ = ("_intervals",)
799 factory: ClassVar[BoxSliceFactory]
800 """A factory for creating boxes using slice syntax.
802 For example::
804 box = Box.factory[2:5, 3:9]
805 """
807 @classmethod
808 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
809 """Construct a box from its shape and optional start.
811 Parameters
812 ----------
813 shape
814 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
815 start
816 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
817 """
818 if start is None:
819 start = (0,) * len(shape)
820 match shape:
821 case XY(x=x_size, y=y_size):
822 pass
823 case [y_size, x_size]:
824 pass
825 case _:
826 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
827 match start:
828 case XY(x=x_start, y=y_start):
829 pass
830 case [y_start, x_start]:
831 pass
832 case _:
833 raise ValueError(f"Invalid sequence for start: {start!r}.")
834 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
836 @classmethod
837 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
838 """Construct a box from floating-point bounds ensuring that all the
839 are contained in the new box.
841 Parameters
842 ----------
843 x_min
844 Minimum X value.
845 x_max
846 Maximum X value.
847 y_min
848 Minimum Y value.
849 y_max
850 Maximum Y value.
852 Notes
853 -----
854 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
855 pixels whose centers lie within the bounds are included.
856 """
857 return Box.factory[
858 round_half_up(y_min) : round_half_down(y_max) + 1,
859 round_half_up(x_min) : round_half_down(x_max) + 1,
860 ]
862 @classmethod
863 def from_sky_circle(
864 cls,
865 sky_projection: SkyProjection,
866 center: astropy.coordinates.SkyCoord,
867 radius: astropy.coordinates.Angle,
868 ) -> Box:
869 """Calculate a bounding box from a sky projection and a circular
870 sky region.
872 Parameters
873 ----------
874 sky_projection
875 The sky projection mapping pixels to sky.
876 center
877 The center of the circle, as a scalar
878 `astropy.coordinates.SkyCoord` in any frame.
879 radius
880 Radius of the circle, as a scalar `astropy.coordinates.Angle`.
882 Returns
883 -------
884 Box
885 Bounding box enclosing the circle based on the sky projection.
887 Raises
888 ------
889 ValueError
890 Raised if ``center`` or ``radius`` is not scalar, or if this
891 image has no sky projection.
892 """
893 if not center.isscalar:
894 raise ValueError("The center of the sky circle must be a scalar SkyCoord.")
895 if not radius.isscalar:
896 raise ValueError("The radius of the sky circle must be a scalar Angle.")
897 center = center.transform_to("icrs")
899 # Use pyast directly for the region handling.
900 sky_region = Ast.Circle(
901 Ast.SkyFrame("System=ICRS"),
902 1,
903 [center.ra.rad, center.dec.rad],
904 [radius.to_value(u.rad)],
905 )
907 # If it is not already a pyast mapping
908 # (e.g., it is implemented with astshim), convert it by round-tripping
909 # the AST textual serialization through a pyast Channel.
910 sky_to_pixel: Any = sky_projection.sky_to_pixel_transform._ast_mapping
911 if not isinstance(sky_to_pixel, Ast.Mapping): 911 ↛ 917line 911 didn't jump to line 917 because the condition on line 911 was always true
912 # Comments must be disabled for pyast to be able to parse the
913 # astshim serialization.
914 sky_to_pixel = Ast.Channel(sky_to_pixel.show(False).splitlines()).read()
916 # Calculate the Box around the region.
917 pixel_region = sky_region.mapregion(sky_to_pixel, Ast.Frame(2))
918 lbnd, ubnd = pixel_region.getregionbounds()
919 return Box.from_float_bounds(
920 x_min=float(lbnd[0]),
921 x_max=float(ubnd[0]),
922 y_min=float(lbnd[1]),
923 y_max=float(ubnd[1]),
924 )
926 @property
927 def min(self) -> YX[int]:
928 """The inclusive minimum bounds of the box, ordered ``(y, x)``
929 (`YX` [`int`]).
930 """
931 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
933 @property
934 def max(self) -> YX[int]:
935 """The inclusive maximum bounds of the box, ordered ``(y, x)``
936 (`YX` [`int`]).
937 """
938 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
940 @property
941 def start(self) -> YX[int]:
942 """Tuple holding the inclusive `Interval.start` bvound, ordered
943 ``(y, x)`` (`YX` [`int`]).
945 This is an alias for `min`, typically paired with `stop` for
946 half-exclusive ranges.
947 """
948 return YX(self.y.start, self.x.start)
950 @property
951 def stop(self) -> YX[int]:
952 """Tuple holding the exclusive `Interval.stop` bound, ordered
953 ``(y, x)`` (`YX` [`int`]).
955 The values in this tuple are one greater than those in `max`. It is
956 typically paired with `start` for half-exclusive ranges.
957 """
958 return YX(self.y.stop, self.x.stop)
960 @property
961 def shape(self) -> YX[int]:
962 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
963 (`YX` [`int`]).
964 """
965 return YX(self.y.size, self.x.size)
967 @property
968 def x(self) -> Interval:
969 """The x-dimension interval (`int`)."""
970 return self._intervals[-1]
972 @property
973 def y(self) -> Interval:
974 """The y-dimension interval (`int`)."""
975 return self._intervals[-2]
977 @property
978 def area(self) -> int:
979 """The number of pixels in the box (`int`)."""
980 return self.x.size * self.y.size
982 @property
983 def absolute(self) -> BoxSliceFactory:
984 """A factory for constructing a contained `Box` using slice
985 syntax and absolute coordinates.
987 Notes
988 -----
989 Slice bounds that are absent are replaced with the bounds of ``self``.
990 """
991 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
993 @property
994 def local(self) -> BoxSliceFactory:
995 """A factory for constructing a contained `Interval` using a slice
996 relative to the start of this one (`BoxSliceFactory`).
998 Notes
999 -----
1000 This factory interprets slices as "local" coordinates, in which ``0``
1001 corresponds to ``self.start``. Negative bounds are relative to
1002 ``self.stop``, as is usually the case for Python sequences.
1003 """
1004 return BoxSliceFactory(y=self.y.local, x=self.x.local)
1006 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
1007 """Return a pair of 2-d arrays of the coordinate values of the box.
1009 Parameters
1010 ----------
1011 n
1012 Number of points in each dimension. If a sequence, points are
1013 assumed to be ordered ``(x, y)`` unless a `YX` instance is
1014 provided.
1015 step
1016 Set ``n`` such that the distance between points is equal to or
1017 just less than this in each dimension. Mutually exclusive with
1018 ``n``.
1020 Returns
1021 -------
1022 `XY` [`numpy.ndarray`]
1023 A pair of arrays, each of which is 2-d with floating-point values.
1025 See Also
1026 --------
1027 numpy.meshgrid
1028 """
1029 if n is not None and step is not None:
1030 raise TypeError("'n' and 'step' cannot both be provided.")
1031 match n:
1032 case int():
1033 ax = self.x.linspace(n)
1034 ay = self.y.linspace(n)
1035 case YX(y=ny, x=nx):
1036 ax = self.x.linspace(nx)
1037 ay = self.y.linspace(ny)
1038 case [nx, ny]:
1039 ax = self.x.linspace(nx)
1040 ay = self.y.linspace(ny)
1041 case None:
1042 ax = self.x.linspace(step=step)
1043 ay = self.y.linspace(step=step)
1044 case _:
1045 raise ValueError(f"Unexpected values for n ({n})")
1046 return XY(*np.meshgrid(ax, ay))
1048 def padded(self, padding: int) -> Box:
1049 """Return a new box expanded by the given padding on
1050 all sides.
1052 Parameters
1053 ----------
1054 padding
1055 Number of pixels to expand the box by on every side.
1056 """
1057 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
1059 def __eq__(self, other: object) -> bool:
1060 if type(other) is Box:
1061 return self._intervals == other._intervals
1062 return False
1064 def __str__(self) -> str:
1065 return f"[y={self.y}, x={self.x}]"
1067 def __repr__(self) -> str:
1068 return f"Box(y={self.y!r}, x={self.x!r})"
1070 @overload
1071 def contains(self, other: Box, /) -> bool: ...
1073 @overload
1074 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1076 @overload
1077 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1079 @overload
1080 def contains(self, /, *, y: int | float, x: int | float) -> bool: ...
1082 @overload
1083 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ...
1085 def contains(
1086 self,
1087 other: Box | XY[Any] | YX[Any] | None = None,
1088 /,
1089 *,
1090 y: Any = None,
1091 x: Any = None,
1092 ) -> bool | np.ndarray:
1093 """Test whether this box fully contains another or one or more points.
1095 Parameters
1096 ----------
1097 other
1098 Another box, or an `XY` or `YX` coordinate pair, to compare to.
1099 Mutually exclusive with ``x`` and ``y``.
1100 y
1101 One or more Y coordinates to test for containment, as a scalar or
1102 any array-like. Results are broadcast against ``x``.
1103 Mutually exclusive with ``other``.
1104 x
1105 One or more X coordinates to test for containment, as a scalar or
1106 any array-like. Results are broadcast against ``y``.
1107 Mutually exclusive with ``other``.
1109 Returns
1110 -------
1111 `bool` | `numpy.ndarray`
1112 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
1113 single `bool` value. If ``x`` and ``y`` are array-like, a boolean
1114 array with their broadcasted shape.
1116 Notes
1117 -----
1118 In order to yield the desired behavior for floating-point arguments,
1119 points are actually tested against an interval that is 0.5 larger on
1120 both sides: this makes positions within the outer boundary of pixels
1121 (but beyond the centers of those pixels, which have integer positions)
1122 appear "on the image".
1123 """
1124 match other:
1125 case None:
1126 if x is None or y is None:
1127 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.")
1128 case Box():
1129 if x is not None or y is not None:
1130 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1131 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
1132 case XY() | YX(): 1132 ↛ 1136line 1132 didn't jump to line 1136 because the pattern on line 1132 always matched
1133 if x is not None or y is not None:
1134 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1135 x, y = other.x, other.y
1136 case _:
1137 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
1138 result = np.logical_and(self.x.contains(x), self.y.contains(y))
1139 if not result.shape:
1140 return bool(result)
1141 return result
1143 @overload
1144 def intersection(self, other: Box) -> Box: ...
1146 @overload
1147 def intersection(self, other: Region) -> Region | Box: ...
1149 @overload
1150 def intersection(self, other: Bounds) -> Bounds: ...
1152 def intersection(self, other: Bounds) -> Bounds:
1153 """Return a bounds object that is contained by both ``self`` and
1154 ``other``.
1156 When there is no overlap, `NoOverlapError` is raised.
1158 Parameters
1159 ----------
1160 other
1161 Bounds to intersect with this one.
1162 """
1163 from ._concrete_bounds import _intersect_box
1165 return _intersect_box(self, other)
1167 def dilated_by(self, padding: int) -> Box:
1168 """Return a new box padded by the given amount on all sides.
1170 Parameters
1171 ----------
1172 padding
1173 Number of pixels to pad the box by on every side.
1174 """
1175 return Box(*[i.dilated_by(padding) for i in self._intervals])
1177 def slice_within(self, other: Box) -> YX[slice]:
1178 """Return a `tuple` of `slice` objects that correspond to the
1179 positions in this box when the items of the container being sliced
1180 correspond to ``other``.
1182 This assumes ``other.contains(self)``.
1184 Parameters
1185 ----------
1186 other
1187 Box that the sliced container's items correspond to.
1188 """
1189 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
1191 @property
1192 def bbox(self) -> Box:
1193 """The box itself (`Box`).
1195 This is provided for compatibility with the `Bounds` interface.
1196 """
1197 return self
1199 def boundary(self) -> Iterator[YX[int]]:
1200 """Iterate over the corners of the box as ``(y, x)`` tuples.
1202 Yields
1203 ------
1204 corner
1205 Each corner in turn.
1206 """
1207 if len(self._intervals) != 2: 1207 ↛ 1208line 1207 didn't jump to line 1208 because the condition on line 1207 was never true
1208 raise TypeError("Box is not 2-d.")
1209 yield YX(self.y.min, self.x.min)
1210 yield YX(self.y.min, self.x.max)
1211 yield YX(self.y.max, self.x.max)
1212 yield YX(self.y.max, self.x.min)
1214 def to_polygon(self) -> Polygon:
1215 """Convert the box to a polygon with floating-point vertices.
1217 Notes
1218 -----
1219 Because the integer min and max coordinates of a box are
1220 interpreted as pixel centers, these are expanded by 0.5 on all sides
1221 before using them to form the polygon vertices.
1222 """
1223 from ._polygon import Polygon
1225 return Polygon.from_box(self)
1227 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1228 """Apply a coordinate transform to the box, returning a polygon.
1230 Parameters
1231 ----------
1232 transform
1233 Coordinate transform to apply (in the forward direction).
1235 Notes
1236 -----
1237 This transforms the polygon representation of the box (see
1238 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1239 full pixels before transforming them.
1240 """
1241 return self.to_polygon().transform(transform)
1243 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1244 return (Box, self._intervals)
1246 @classmethod
1247 def from_legacy(cls, legacy: Any) -> Box:
1248 """Convert from an `lsst.geom.Box2I` instance.
1250 Parameters
1251 ----------
1252 legacy
1253 Legacy `lsst.geom.Box2I` to convert.
1254 """
1255 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1257 def to_legacy(self) -> Any:
1258 """Convert to an `lsst.geom.BoxI` instance."""
1259 from lsst.geom import Box2I
1261 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1263 @classmethod
1264 def __get_pydantic_core_schema__(
1265 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1266 ) -> pcs.CoreSchema:
1267 from_typed_dict = pcs.chain_schema(
1268 [
1269 handler(_SerializedBox),
1270 pcs.no_info_plain_validator_function(cls._validate),
1271 ]
1272 )
1273 return pcs.json_or_python_schema(
1274 json_schema=from_typed_dict,
1275 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1276 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1277 )
1279 @classmethod
1280 def __get_pydantic_json_schema__(
1281 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1282 ) -> JsonSchemaValue:
1283 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1285 @classmethod
1286 def _validate(cls, data: _SerializedBox) -> Box:
1287 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1289 def _serialize(self) -> _SerializedBox:
1290 return {"y": self.y._serialize(), "x": self.x._serialize()}
1292 def serialize(self) -> Box:
1293 """Return a Pydantic-friendly representation of this object.
1295 This method just returns the `Box` itself, since that already provides
1296 Pydantic serialization hooks. It exists for compatibility with the
1297 `Bounds` protocol.
1298 """
1299 return self
1301 def deserialize(self) -> Box:
1302 """Deserialize a bounds object on the assumption it is a `Box`.
1304 This method just returns the `Box` itself, since that already provides
1305 Pydantic serialization hooks. It exists for compatibility with the
1306 `Bounds` protocol.
1307 """
1308 return self
1311class BoxSliceFactory:
1312 """A factory for `Box` objects using array-slice syntax.
1314 Parameters
1315 ----------
1316 y
1317 Slice factory used for the y axis.
1318 x
1319 Slice factory used for the x axis.
1321 Notes
1322 -----
1323 When `Box.factory` is indexed with a pair of slices, this returns a
1324 `Box` with exactly those bounds::
1326 assert (
1327 Box.factory[3:6, -1:2]
1328 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1329 )
1331 A missing start bound is replaced by ``0``, but a missing stop bound is
1332 not allowed.
1334 When obtained from the `Box.absolute` property, indices are absolute
1335 coordinate values, but any omitted bounds are replaced with the parent
1336 box's bounds::
1338 parent = Box.factory[3:6, -1:2]
1339 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1341 The final box is also checked to be contained by the parent box.
1343 When obtained from the `Box.local` property, indices are interpreted
1344 as relative to the parent box, and negative indices are relative to
1345 the end (like `~collections.abc.Sequence` indexing)::
1347 parent = Box.factory[3:6, -1:2]
1348 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1349 """
1351 def __init__(
1352 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1353 ) -> None:
1354 self._y = y
1355 self._x = x
1357 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1358 match key:
1359 case XY(x=x, y=y):
1360 return Box(y=self._y[y], x=self._x[x])
1361 case (y, x):
1362 return Box(y=self._y[y], x=self._x[x])
1363 case _:
1364 raise TypeError("Expected exactly two slices.")
1367Box.factory = BoxSliceFactory()
1370class Bounds(Protocol):
1371 """A protocol for objects that represent the validity region for a function
1372 defined in 2-d pixel coordinates.
1374 Notes
1375 -----
1376 Most objects natively have a simple 2-d bounding box as their bounds
1377 (typically the boundary of a sensor), and the `Box` class is hence the
1378 most common bounds implementation. But sometimes a large chunk of that
1379 box may be missing due to vignetting or bad amplifiers, and we may want to
1380 transform from one coordinate system to another. The Bounds interface is
1381 intended to handle both of these cases as well.
1382 """
1384 @property
1385 def bbox(self) -> Box: ...
1387 @overload
1388 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1390 @overload
1391 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1393 @overload
1394 def contains(self, /, *, x: int | float, y: int | float) -> bool: ...
1396 @overload
1397 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ...
1399 def contains(
1400 self,
1401 point: XY[Any] | YX[Any] | None = None,
1402 /,
1403 *,
1404 x: int | float | npt.ArrayLike | None = None,
1405 y: int | float | npt.ArrayLike | None = None,
1406 ) -> bool | np.ndarray:
1407 """Test whether one or more points fall within these bounds.
1409 Parameters
1410 ----------
1411 point
1412 An `XY` or `YX` coordinate pair to test for containment.
1413 Mutually exclusive with ``x`` and ``y``.
1414 x
1415 One or more X coordinates to test for containment, as a scalar or
1416 any array-like. Results are broadcast against ``y``.
1417 Mutually exclusive with ``point``.
1418 y
1419 One or more Y coordinates to test for containment, as a scalar or
1420 any array-like. Results are broadcast against ``x``.
1421 Mutually exclusive with ``point``.
1423 Returns
1424 -------
1425 `bool` | `numpy.ndarray`
1426 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1427 ``x`` and ``y`` are array-like, a boolean array with their
1428 broadcasted shape.
1429 """
1430 ...
1432 def intersection(self, other: Bounds) -> Bounds:
1433 """Compute the intersection of this bounds object with another.
1435 Parameters
1436 ----------
1437 other
1438 Bounds to intersect with this one.
1439 """
1440 ...
1442 def serialize(self) -> BoundsSerializationModel:
1443 """Convert a bounds instance into a serializable object.
1445 Notes
1446 -----
1447 The returned object must support direct nesting with Pydantic models
1448 and have a ``deserialize`` method (taking no arguments) that converts
1449 back to this `Bounds` type. It is common for `serialize` and
1450 ``deserialize`` to just return ``self``, when the bounds object is
1451 natively serializable.
1452 """
1453 ...
1456class BoundsError(ValueError):
1457 """Exception raised when an object is evaluated outside its bounds."""
1460class NoOverlapError(ValueError):
1461 """Exception raised when intervals or bounds do not overlap."""
1464class NotContainedError(RuntimeError):
1465 """Exception raised when intervals or bounds are not fully contained by
1466 another.
1467 """
1470if TYPE_CHECKING:
1472 def _test_types() -> None:
1473 interval = Interval(0, 10)
1474 box = Box(y=Interval(0, 10), x=Interval(0, 10))
1475 arr = np.zeros(3)
1477 # Interval.contains: scalar → bool, array-like → np.ndarray
1478 assert_type(interval.contains(5), bool)
1479 assert_type(interval.contains(arr), np.ndarray)
1481 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray
1482 assert_type(box.contains(box), bool)
1483 assert_type(box.contains(y=3, x=4), bool)
1484 assert_type(box.contains(y=3.0, x=4.0), bool)
1485 assert_type(box.contains(y=arr, x=arr), np.ndarray)
1486 assert_type(box.contains(XY(3, 4)), bool)
1487 assert_type(box.contains(YX(4, 3)), bool)
1488 assert_type(box.contains(XY(arr, arr)), np.ndarray)
1489 assert_type(box.contains(YX(arr, arr)), np.ndarray)
1491 # Bounds.contains (Protocol): XY/YX, scalar, array-like
1492 bounds: Bounds = box
1493 assert_type(bounds.contains(x=1, y=1), bool)
1494 assert_type(bounds.contains(x=1.0, y=1.0), bool)
1495 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
1496 assert_type(bounds.contains(XY(1, 1)), bool)
1497 assert_type(bounds.contains(YX(1, 1)), bool)
1498 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
1499 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)