Coverage for python / lsst / images / _geom.py: 37%
327 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-28 01:29 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-28 01:29 -0800
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 "Box",
19 "Interval",
20 "SerializableBounds",
21)
23import math
24from collections.abc import Callable, Iterator, Sequence
25from typing import Any, ClassVar, NamedTuple, Protocol, Self, TypedDict, TypeVar, final, overload
27import numpy as np
28import pydantic
29import pydantic_core.core_schema as pcs
31# This pre-python-3.12 declaration is needed by Sphinx (probably the
32# autodoc-typehints plugin.
33T = TypeVar("T")
35# Interval and Box are defined as regular Python classes rather than
36# dataclasses or Pydantic models because we might want to implement them as
37# compiled-extension types in the future, and we want that to be transparent.
39# In a similar vein, we avoid declaring specific types for multidimensional
40# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
41# shapes) in order to leave room for more fully-featured types to be added
42# upstream of this package in the future.
45class YX[T](NamedTuple):
46 """A pair of per-dimension objects, ordered ``(y, x)``.
48 Notes
49 -----
50 `YX` is used for slices, shapes, and other 2-d pairs when the most
51 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
52 arithmetic operations behave as they would on a
53 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
54 concatenates).
56 See Also
57 --------
58 XY
59 """
61 y: T
62 """The y / row object."""
64 x: T
65 """The x / column object."""
67 @property
68 def xy(self) -> XY:
69 """A tuple of the same objects in the opposite order."""
70 return XY(x=self.x, y=self.y)
72 def map[U](self, func: Callable[[T], U]) -> YX[U]:
73 """Apply a function to both objects."""
74 return YX(y=func(self.y), x=func(self.x))
77class XY[T](NamedTuple):
78 """A pair of per-dimension objects, ordered ``(x, y)``.
80 Notes
81 -----
82 `XY` is used for points and other 2-d pairs when the most natural ordering
83 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
84 behave as they would on a `collections.abc.Sequence`, not a mathematical
85 vector (e.g. adding concatenates).
87 See Also
88 --------
89 YX
90 """
92 x: T
93 """The x / column object."""
95 y: T
96 """The y / row object."""
98 @property
99 def yx(self) -> YX:
100 """A tuple of the same objects in the opposite order."""
101 return YX(y=self.y, x=self.x)
103 def map[U](self, func: Callable[[T], U]) -> XY[U]:
104 """Apply a function to both objects."""
105 return XY(x=func(self.x), y=func(self.y))
108class _SerializedInterval(TypedDict):
109 start: int
110 stop: int
113@final
114class Interval:
115 """A 1-d integer interval with positive size.
117 Parameters
118 ----------
119 start
120 Inclusive minimum point in the interval.
121 stop
122 One past the maximum point in the interval.
124 Notes
125 -----
126 Adding or subtracting an `int` from an interval returns a shifted interval.
128 `Interval` implements the necessary hooks to be included directly in a
129 `pydantic.BaseModel`, even though it is neither a built-in type nor a
130 Pydantic model itself.
131 """
133 def __init__(self, start: int, stop: int):
134 # Coerce to be defensive against numpy int scalars.
135 self._start = int(start)
136 self._stop = int(stop)
137 if not (self._stop > self._start):
138 raise ValueError(f"Interval must have positive size; got [{self._start}, {self._stop})")
140 __slots__ = ("_start", "_stop")
142 factory: ClassVar[IntervalSliceFactory]
143 """A factory for creating intervals using slice syntax.
145 For example::
147 interval = Interval.factory[2:5]
148 """
150 @classmethod
151 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
152 """Construct an interval that includes all of the given points and/or
153 intervals.
154 """
155 if type(first) is Interval:
156 rmin = first.min
157 rmax = first.max
158 else:
159 rmin = rmax = first
160 for arg in args:
161 if type(arg) is Interval:
162 rmin = min(rmin, arg.min)
163 rmax = max(rmax, arg.max)
164 else:
165 rmin = min(rmin, arg)
166 rmax = max(rmax, arg)
167 return Interval(start=rmin, stop=rmax + 1)
169 @classmethod
170 def from_size(cls, size: int, start: int = 0) -> Interval:
171 """Construct an interval from its size and optional start."""
172 return cls(start=start, stop=start + size)
174 @property
175 def start(self) -> int:
176 """Inclusive minimum point in the interval (`int`)."""
177 return self._start
179 @property
180 def stop(self) -> int:
181 """One past the maximum point in the interval (`int`)."""
182 return self._stop
184 @property
185 def min(self) -> int:
186 """Inclusive minimum point in the interval (`int`)."""
187 return self.start
189 @property
190 def max(self) -> int:
191 """Inclusive maximum point in the interval (`int`)."""
192 return self.stop - 1
194 @property
195 def size(self) -> int:
196 """Size of the interval (`int`)."""
197 return self.stop - self.start
199 @property
200 def range(self) -> __builtins__.range:
201 """An iterable over all values in the interval
202 (`__builtins__.range`).
203 """
204 return range(self.start, self.stop)
206 @property
207 def arange(self) -> np.ndarray:
208 """An array of all the values in the interval (`numpy.ndarray`).
210 Array values are integers.
211 """
212 return np.arange(self.start, self.stop)
214 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
215 """Return an array of values that spans the interval.
217 Parameters
218 ----------
219 n
220 How many values to return. The default (if ``step`` is also not
221 provided) is the size of the interval, i.e. equivalent to the
222 `arange` property (but converted to `float`).
223 step
224 Set ``n`` such that the distance between points is equal to or
225 just less than this. Mutually exclusive with ``n``.
227 Returns
228 -------
229 numpy.ndarray
230 Array of `float` values.
232 See Also
233 --------
234 numpy.linspace
235 """
236 if n is None:
237 if step is None:
238 return self.arange.astype(np.float64)
239 n = math.ceil(self.size / step)
240 elif step is not None:
241 raise TypeError("'n' and 'step' cannot both be provided.")
242 return np.linspace(self.min, self.max, n, dtype=np.float64)
244 @property
245 def center(self) -> float:
246 """The center of the interval (`float`)."""
247 return 0.5 * (self.min + self.max)
249 def __str__(self) -> str:
250 return f"{self.start}:{self.stop}"
252 def __repr__(self) -> str:
253 return f"Interval(start={self.start}, stop={self.stop})"
255 def __eq__(self, other: object) -> bool:
256 if type(other) is Interval:
257 return self._start == other._start and self._stop == other._stop
258 return False
260 def __add__(self, other: int) -> Interval:
261 return Interval(start=self.start + other, stop=self.stop + other)
263 def __sub__(self, other: int) -> Interval:
264 return Interval(start=self.start - other, stop=self.stop - other)
266 def __contains__(self, x: int) -> bool:
267 return x >= self.start and x < self.stop
269 @overload
270 def contains(self, other: Interval | int | float) -> bool: ... 270 ↛ exitline 270 didn't return from function 'contains' because
272 @overload
273 def contains(self, other: np.ndarray) -> np.ndarray: ... 273 ↛ exitline 273 didn't return from function 'contains' because
275 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
276 """Test whether this interval fully contains another or one or more
277 points.
279 Parameters
280 ----------
281 other
282 Another interval to compare to, or one or more position values.
284 Returns
285 -------
286 `bool` | `numpy.ndarray`
287 If a single interval or value was passed, a single `bool`. If an
288 array was passed, an array with the same shape.
290 Notes
291 -----
292 In order to yield the desired behavior for floating-point arguments,
293 points are actually tested against an interval that is 0.5 larger on
294 both sides: this makes positions within the outer boundary of pixels
295 (but beyond the centers of those pixels, which have integer positions)
296 appear "on the image".
297 """
298 if isinstance(other, Interval):
299 return self.start <= other.start and self.stop >= other.stop
300 else:
301 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5)
302 if not result.shape:
303 return bool(result)
304 return result
306 def intersection(self, other: Interval) -> Interval | None:
307 """Return an interval that is contained by both ``self`` and ``other``.
309 When there is no overlap between the intervals, `None` is returned.
310 """
311 new_start = max(self.start, other.start)
312 new_stop = min(self.stop, other.stop)
313 if new_start < new_stop:
314 return Interval(start=new_start, stop=new_stop)
315 return None
317 def dilated_by(self, padding: int) -> Interval:
318 """Return a new interval padded by the given amount on both sides."""
319 return Interval(start=self._start - padding, stop=self._stop + padding)
321 def slice_within(self, other: Interval) -> slice:
322 """Return the `slice` that corresponds to the values in this interval
323 when the items of the container being sliced correspond to ``other``.
325 This assumes ``other.contains(self)``.
326 """
327 if not other.contains(self):
328 raise IndexError(
329 f"Can not calculate a slice of {other} within {self} "
330 "since the given interval does not contain this one."
331 )
332 return slice(self.start - other.start, self.stop - other.start)
334 @classmethod
335 def from_legacy(cls, legacy: Any) -> Interval:
336 """Convert from an `lsst.geom.IntervalI` instance."""
337 return cls(legacy.begin, legacy.end)
339 def to_legacy(self) -> Any:
340 """Convert to an `lsst.geom.IntervalI` instance."""
341 from lsst.geom import IntervalI
343 return IntervalI(min=self.min, max=self.max)
345 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
346 return (
347 Interval,
348 (
349 self._start,
350 self._stop,
351 ),
352 )
354 @classmethod
355 def __get_pydantic_core_schema__(
356 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
357 ) -> pcs.CoreSchema:
358 from_typed_dict = pcs.chain_schema(
359 [
360 handler(_SerializedInterval),
361 pcs.no_info_plain_validator_function(cls._validate),
362 ]
363 )
364 return pcs.json_or_python_schema(
365 json_schema=from_typed_dict,
366 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
367 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
368 )
370 @classmethod
371 def _validate(cls, data: _SerializedInterval) -> Interval:
372 return cls(**data)
374 def _serialize(self) -> _SerializedInterval:
375 return {"start": self._start, "stop": self._stop}
377 def __getitem__(self, s: slice) -> Interval:
378 """Return a subset of the interval.
380 If the start is omitted from the slice, the current start is assumed.
381 """
382 if s.step is not None and s.step != 1:
383 raise ValueError(f"Slice {s} has non-unit step.")
384 start = s.start if s.start is not None else self.start
385 stop = s.stop if s.stop is not None else self.stop
386 try:
387 new = Interval(start=start, stop=stop)
388 except ValueError as e:
389 raise IndexError(f"Bounds failure when creating slice with start={start} stop={stop}") from e
390 if not self.contains(new):
391 raise IndexError(
392 f"The requested slice ({start}, {stop}) "
393 f"is not contained within the enclosing interval ({self})"
394 )
395 return new
398class IntervalSliceFactory:
399 """A factory for `Interval` objects using array-slice syntax.
401 Notes
402 -----
403 When indexed with a single slice, this returns an `Interval`::
405 assert Interval.factory[3:6] == Interval(start=3, stop=6)
407 """
409 def __getitem__(self, s: slice) -> Interval:
410 if s.step is not None and s.step != 1:
411 raise ValueError(f"Slice {s} has non-unit step.")
412 return Interval(start=s.start or 0, stop=s.stop)
415Interval.factory = IntervalSliceFactory()
418class _SerializedBox(TypedDict):
419 y: _SerializedInterval
420 x: _SerializedInterval
423class Box:
424 """An axis-aligned 2-d rectangular region.
426 Parameters
427 ----------
428 y
429 Interval for the y dimension.
430 x
431 Interval for the x dimension.
433 Notes
434 -----
435 `Box` implements the necessary hooks to be included directly in a
436 `pydantic.BaseModel`, even though it is neither a built-in type nor a
437 Pydantic model itself.
438 """
440 def __init__(self, y: Interval, x: Interval):
441 self._intervals = YX(y, x)
443 __slots__ = ("_intervals",)
445 factory: ClassVar[BoxSliceFactory]
446 """A factory for creating boxes using slice syntax.
448 For example::
450 box = Box.factory[2:5, 3:9]
451 """
453 @classmethod
454 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
455 """Construct a box from its shape and optional start.
457 Parameters
458 ----------
459 shape
460 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
461 start
462 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
463 """
464 if start is None:
465 start = (0,) * len(shape)
466 match shape:
467 case XY(x=x_size, y=y_size):
468 pass
469 case [y_size, x_size]:
470 pass
471 case _:
472 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
473 match start:
474 case XY(x=x_start, y=y_start):
475 pass
476 case [y_start, x_start]:
477 pass
478 case _:
479 raise ValueError(f"Invalid sequence for start: {start!r}.")
480 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
482 @property
483 def start(self) -> YX[int]:
484 """Tuple holding the starts of the intervals ordered ``(y, x)``
485 (`YX` [`int`]).
486 """
487 return YX(self.y.start, self.x.start)
489 @property
490 def shape(self) -> YX[int]:
491 """Tuple holding the sizes of the intervals ordered ``(y, x)``
492 (`YX` [`int`]).
493 """
494 return YX(self.y.size, self.x.size)
496 @property
497 def x(self) -> Interval:
498 """Shortcut for the last dimension's interval (`int`)."""
499 return self._intervals[-1]
501 @property
502 def y(self) -> Interval:
503 """Shortcut for the second-to-last dimension's interval (`int`)."""
504 return self._intervals[-2]
506 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
507 """Return a pair of 2-d arrays of the coordinate values of the box.
509 Parameters
510 ----------
511 n
512 Number of points in each dimension. If a sequence, points are
513 assumed to be ordered ``(x, y)`` unless a `YX` instance is
514 provided.
515 step
516 Set ``n`` such that the distance between points is equal to or
517 just less than this in each dimension. Mutually exclusive with
518 ``n``.
520 Returns
521 -------
522 `XY` [`numpy.ndarray`]
523 A pair of arrays, each of which is 2-d with floating-point values.
525 See Also
526 --------
527 numpy.meshgrid
528 """
529 if n is not None and step is not None:
530 raise TypeError("'n' and 'step' cannot both be provided.")
531 match n:
532 case int():
533 ax = self.x.linspace(n)
534 ay = self.y.linspace(n)
535 case YX(y=ny, x=nx):
536 ax = self.x.linspace(nx)
537 ay = self.y.linspace(ny)
538 case [nx, ny]:
539 ax = self.x.linspace(nx)
540 ay = self.y.linspace(ny)
541 case None:
542 ax = self.x.linspace(step=step)
543 ay = self.y.linspace(step=step)
544 case _:
545 raise ValueError(f"Unexpected values for n ({n})")
546 return XY(*np.meshgrid(ax, ay))
548 def __eq__(self, other: object) -> bool:
549 if type(other) is Box:
550 return self._intervals == other._intervals
551 return False
553 def __str__(self) -> str:
554 return f"[y={self.y}, x={self.x}]"
556 def __repr__(self) -> str:
557 return f"Box(y={self.y!r}, x={self.x!r})"
559 @overload
560 def contains(self, other: Box, /) -> bool: ... 560 ↛ exitline 560 didn't return from function 'contains' because
562 @overload
563 def contains(self, *, y: int, x: int) -> bool: ... 563 ↛ exitline 563 didn't return from function 'contains' because
565 @overload
566 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 566 ↛ exitline 566 didn't return from function 'contains' because
568 def contains(
569 self,
570 other: Box | None = None,
571 *,
572 y: int | np.ndarray | None = None,
573 x: int | np.ndarray | None = None,
574 ) -> bool | np.ndarray:
575 """Test whether this box fully contains another or one or more points.
577 Parameters
578 ----------
579 other
580 Another box to compare to. Not compatible with the ``y`` and ``x``
581 arguments.
582 y
583 One or more integer Y coordinates to test for containment.
584 If an array, an array of results will be returned.
585 x
586 One or more integer X coordinates to test for containment.
587 If an array, an array of results will be returned.
589 Returns
590 -------
591 `bool` | `numpy.ndarray`
592 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
593 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
594 array with their broadcasted shape.
596 Notes
597 -----
598 In order to yield the desired behavior for floating-point arguments,
599 points are actually tested against an interval that is 0.5 larger on
600 both sides: this makes positions within the outer boundary of pixels
601 (but beyond the centers of those pixels, which have integer positions)
602 appear "on the image".
603 """
604 if other is not None:
605 if x is not None or y is not None:
606 raise TypeError("Too many arguments to 'Box.contain'.")
607 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
608 elif x is None or y is None:
609 raise TypeError("Not enough arguments to 'Box.contain'.")
610 else:
611 result = np.logical_and(self.x.contains(x), self.y.contains(y))
612 if not result.shape:
613 return bool(result)
614 return result
616 def intersection(self, other: Box) -> Box | None:
617 """Return a box that is contained by both ``self`` and ``other``.
619 When there is no overlap between the boxes, `None` is returned.
620 """
621 intervals = []
622 for a, b in zip(self._intervals, other._intervals, strict=True):
623 if (r := a.intersection(b)) is None:
624 return None
625 intervals.append(r)
626 return Box(*intervals)
628 def dilated_by(self, padding: int) -> Box:
629 """Return a new box padded by the given amount on all sides."""
630 return Box(*[i.dilated_by(padding) for i in self._intervals])
632 def slice_within(self, other: Box) -> YX[slice]:
633 """Return a `tuple` of `slice` objects that correspond to the
634 positions in this box when the items of the container being sliced
635 correspond to ``other``.
637 This assumes ``other.contains(self)``.
638 """
639 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
641 def boundary(self) -> Iterator[YX[int]]:
642 """Iterate over the corners of the box as ``(y, x)`` tuples."""
643 if len(self._intervals) != 2:
644 raise TypeError("Box is not 2-d.")
645 yield YX(self.y.min, self.x.min)
646 yield YX(self.y.min, self.x.max)
647 yield YX(self.y.max, self.x.max)
648 yield YX(self.y.max, self.x.min)
650 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
651 return (Box, self._intervals)
653 @classmethod
654 def from_legacy(cls, legacy: Any) -> Box:
655 """Convert from an `lsst.geom.Box2I` instance."""
656 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
658 def to_legacy(self) -> Any:
659 """Convert to an `lsst.geom.BoxI` instance."""
660 from lsst.geom import Box2I
662 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
664 @classmethod
665 def __get_pydantic_core_schema__(
666 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
667 ) -> pcs.CoreSchema:
668 from_typed_dict = pcs.chain_schema(
669 [
670 handler(_SerializedBox),
671 pcs.no_info_plain_validator_function(cls._validate),
672 ]
673 )
674 return pcs.json_or_python_schema(
675 json_schema=from_typed_dict,
676 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
677 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
678 )
680 @classmethod
681 def _validate(cls, data: _SerializedBox) -> Box:
682 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
684 def _serialize(self) -> _SerializedBox:
685 return {"y": self.y._serialize(), "x": self.x._serialize()}
687 def serialize(self) -> Box:
688 """Return a Pydantic-friendly representation of this object.
690 This method just returns the `Box` itself, since that already provides
691 Pydantic serialization hooks. It exists for compatibility with the
692 `Bounds` protocol.
693 """
694 return self
696 @classmethod
697 def deserialize(cls, serialized: SerializableBounds) -> Box:
698 """Deserialize a bounds object on the assumption it is a `Box`.
700 This method just returns the `Box` itself, since that already provides
701 Pydantic serialization hooks. It exists for compatibility with the
702 `Bounds` protocol.
703 """
704 assert isinstance(serialized, Box)
705 return serialized
707 def __getitem__(self, key: tuple[slice, slice]) -> Box:
708 """Given a slice, return a new box."""
709 # If the slice is missing the start, use the same start as the
710 # current object.
711 try:
712 match key:
713 case XY(x=x, y=y):
714 return Box(self.y[y], self.x[x])
715 case (y, x):
716 return Box(self.y[y], self.x[x])
717 case _:
718 raise TypeError("Expected exactly two slices.")
719 except IndexError as e:
720 e.add_note(f"Error applying slice {key} to {self}")
721 raise
724class BoxSliceFactory:
725 """A factory for `Box` objects using array-slice syntax.
727 Notes
728 -----
729 When indexed with one or more slices, this returns a `Box`:
731 assert (
732 Box.factory[3:6, -1:2]
733 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
734 )
735 """
737 def __getitem__(self, key: tuple[slice, slice]) -> Box:
738 match key:
739 case XY(x=x, y=y):
740 return Box(Interval.factory[y], Interval.factory[x])
741 case (y, x):
742 return Box(Interval.factory[y], Interval.factory[x])
743 case _:
744 raise TypeError("Expected exactly two slices.")
747Box.factory = BoxSliceFactory()
750# This is expected to become a union of concrete Bounds types that we can
751# serialize via pydantic. Right now that's only Box.
752type SerializableBounds = Box
755class Bounds(Protocol):
756 """A protocol for objects that represent the validity region for a function
757 defined in 2-d pixel coordinates.
759 Notes
760 -----
761 Most objects natively have a simple 2-d bounding box as their bounds
762 (typically the boundary of a sensor), and the `Box` class is hence the
763 most common bounds implementation. But sometimes a large chunk of that
764 box may be missing due to vignetting or bad amplifiers, and we may want to
765 transform from one coordinate system to another. The Bounds interface is
766 intended to handle both of these cases as well.
767 """
769 def boundary(self) -> Iterator[YX[int]]:
770 """Iterate over points on the boundary as ``(y, x)`` tuples."""
771 ...
773 @overload
774 def contains(self, *, x: int, y: int) -> bool: ... 774 ↛ exitline 774 didn't return from function 'contains' because
776 @overload
777 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 777 ↛ exitline 777 didn't return from function 'contains' because
779 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
780 """Test whether this box fully contains another or one or more points.
782 Parameters
783 ----------
784 x
785 One or more integer X coordinates to test for containment.
786 If an array, an array of results will be returned.
787 y
788 One or more integer Y coordinates to test for containment.
789 If an array, an array of results will be returned.
791 Returns
792 -------
793 `bool` | `numpy.ndarray`
794 If ``x`` and ``y`` are both scalars, a single `bool` value. If
795 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
796 shape.
797 """
798 ...
800 def serialize(self) -> SerializableBounds:
801 """Convert a bounds instance into a serializable object."""
802 ...
804 @classmethod
805 def deserialize(cls, serialized: SerializableBounds) -> Self:
806 """Convert a serialized bounds object into its in-memory form."""
807 match serialized:
808 case Box():
809 return serialized # type: ignore[return-value]
810 raise RuntimeError(f"Cannot deserialize {serialized!r}.")