Coverage for python / lsst / images / _geom.py: 40%
343 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-21 08:52 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-21 08:52 +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)
26import math
27from collections.abc import Callable, Iterator, Sequence
28from typing import (
29 TYPE_CHECKING,
30 Any,
31 ClassVar,
32 NamedTuple,
33 Protocol,
34 TypedDict,
35 TypeVar,
36 final,
37 overload,
38)
40import numpy as np
41import pydantic
42import pydantic_core.core_schema as pcs
44if TYPE_CHECKING:
45 from ._concrete_bounds import SerializableBounds
47# This pre-python-3.12 declaration is needed by Sphinx (probably the
48# autodoc-typehints plugin.
49T = TypeVar("T")
51# Interval and Box are defined as regular Python classes rather than
52# dataclasses or Pydantic models because we might want to implement them as
53# compiled-extension types in the future, and we want that to be transparent.
55# In a similar vein, we avoid declaring specific types for multidimensional
56# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
57# shapes) in order to leave room for more fully-featured types to be added
58# upstream of this package in the future.
61class YX[T](NamedTuple):
62 """A pair of per-dimension objects, ordered ``(y, x)``.
64 Notes
65 -----
66 `YX` is used for slices, shapes, and other 2-d pairs when the most
67 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
68 arithmetic operations behave as they would on a
69 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
70 concatenates).
72 See Also
73 --------
74 XY
75 """
77 y: T
78 """The y / row object."""
80 x: T
81 """The x / column object."""
83 @property
84 def xy(self) -> XY:
85 """A tuple of the same objects in the opposite order."""
86 return XY(x=self.x, y=self.y)
88 def map[U](self, func: Callable[[T], U]) -> YX[U]:
89 """Apply a function to both objects."""
90 return YX(y=func(self.y), x=func(self.x))
93class XY[T](NamedTuple):
94 """A pair of per-dimension objects, ordered ``(x, y)``.
96 Notes
97 -----
98 `XY` is used for points and other 2-d pairs when the most natural ordering
99 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
100 behave as they would on a `collections.abc.Sequence`, not a mathematical
101 vector (e.g. adding concatenates).
103 See Also
104 --------
105 YX
106 """
108 x: T
109 """The x / column object."""
111 y: T
112 """The y / row object."""
114 @property
115 def yx(self) -> YX:
116 """A tuple of the same objects in the opposite order."""
117 return YX(y=self.y, x=self.x)
119 def map[U](self, func: Callable[[T], U]) -> XY[U]:
120 """Apply a function to both objects."""
121 return XY(x=func(self.x), y=func(self.y))
124class _SerializedInterval(TypedDict):
125 start: int
126 stop: int
129@final
130class Interval:
131 """A 1-d integer interval with positive size.
133 Parameters
134 ----------
135 start
136 Inclusive minimum point in the interval.
137 stop
138 One past the maximum point in the interval.
140 Notes
141 -----
142 Adding or subtracting an `int` from an interval returns a shifted interval.
144 `Interval` implements the necessary hooks to be included directly in a
145 `pydantic.BaseModel`, even though it is neither a built-in type nor a
146 Pydantic model itself.
147 """
149 def __init__(self, start: int, stop: int):
150 # Coerce to be defensive against numpy int scalars.
151 self._start = int(start)
152 self._stop = int(stop)
153 if not (self._stop > self._start):
154 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
156 __slots__ = ("_start", "_stop")
158 factory: ClassVar[IntervalSliceFactory]
159 """A factory for creating intervals using slice syntax.
161 For example::
163 interval = Interval.factory[2:5]
164 """
166 @classmethod
167 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
168 """Construct an interval that includes all of the given points and/or
169 intervals.
170 """
171 if type(first) is Interval:
172 rmin = first.min
173 rmax = first.max
174 else:
175 rmin = rmax = first
176 for arg in args:
177 if type(arg) is Interval:
178 rmin = min(rmin, arg.min)
179 rmax = max(rmax, arg.max)
180 else:
181 rmin = min(rmin, arg)
182 rmax = max(rmax, arg)
183 return Interval(start=rmin, stop=rmax + 1)
185 @classmethod
186 def from_size(cls, size: int, start: int = 0) -> Interval:
187 """Construct an interval from its size and optional start."""
188 return cls(start=start, stop=start + size)
190 @property
191 def start(self) -> int:
192 """Inclusive minimum point in the interval (`int`)."""
193 return self._start
195 @property
196 def stop(self) -> int:
197 """One past the maximum point in the interval (`int`)."""
198 return self._stop
200 @property
201 def min(self) -> int:
202 """Inclusive minimum point in the interval (`int`)."""
203 return self.start
205 @property
206 def max(self) -> int:
207 """Inclusive maximum point in the interval (`int`)."""
208 return self.stop - 1
210 @property
211 def size(self) -> int:
212 """Size of the interval (`int`)."""
213 return self.stop - self.start
215 @property
216 def range(self) -> __builtins__.range:
217 """An iterable over all values in the interval
218 (`__builtins__.range`).
219 """
220 return range(self.start, self.stop)
222 @property
223 def arange(self) -> np.ndarray:
224 """An array of all the values in the interval (`numpy.ndarray`).
226 Array values are integers.
227 """
228 return np.arange(self.start, self.stop)
230 @property
231 def absolute(self) -> IntervalSliceFactory:
232 """A factory for constructing a contained `Interval` using slice
233 syntax and absolute coordinates.
235 Notes
236 -----
237 Slice bounds that are absent are replaced with the bounds of ``self``.
238 """
239 return IntervalSliceFactory(self, is_local=False)
241 @property
242 def local(self) -> IntervalSliceFactory:
243 """A factory for constructing a contained `Interval` using a slice
244 relative to the start of this one (`IntervalSliceFactory`).
246 Notes
247 -----
248 This factory interprets slices as "local" coordinates, in which ``0``
249 corresponds to ``self.start``. Negative bounds are relative to
250 ``self.stop``, as is usually the case for Python sequences.
251 """
252 return IntervalSliceFactory(self, is_local=True)
254 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
255 """Return an array of values that spans the interval.
257 Parameters
258 ----------
259 n
260 How many values to return. The default (if ``step`` is also not
261 provided) is the size of the interval, i.e. equivalent to the
262 `arange` property (but converted to `float`).
263 step
264 Set ``n`` such that the distance between points is equal to or
265 just less than this. Mutually exclusive with ``n``.
267 Returns
268 -------
269 numpy.ndarray
270 Array of `float` values.
272 See Also
273 --------
274 numpy.linspace
275 """
276 if n is None:
277 if step is None:
278 return self.arange.astype(np.float64)
279 n = math.ceil(self.size / step)
280 elif step is not None:
281 raise TypeError("'n' and 'step' cannot both be provided.")
282 return np.linspace(self.min, self.max, n, dtype=np.float64)
284 @property
285 def center(self) -> float:
286 """The center of the interval (`float`)."""
287 return 0.5 * (self.min + self.max)
289 def padded(self, padding: int) -> Interval:
290 """Return a new interval expanded by the given padding on
291 either side.
292 """
293 return Interval(self.start - padding, self.stop + padding)
295 def __str__(self) -> str:
296 return f"{self.start}:{self.stop}"
298 def __repr__(self) -> str:
299 return f"Interval(start={self.start}, stop={self.stop})"
301 def __eq__(self, other: object) -> bool:
302 if type(other) is Interval:
303 return self._start == other._start and self._stop == other._stop
304 return False
306 def __add__(self, other: int) -> Interval:
307 return Interval(start=self.start + other, stop=self.stop + other)
309 def __sub__(self, other: int) -> Interval:
310 return Interval(start=self.start - other, stop=self.stop - other)
312 def __contains__(self, x: int) -> bool:
313 return x >= self.start and x < self.stop
315 @overload
316 def contains(self, other: Interval | int | float) -> bool: ... 316 ↛ exitline 316 didn't return from function 'contains' because
318 @overload
319 def contains(self, other: np.ndarray) -> np.ndarray: ... 319 ↛ exitline 319 didn't return from function 'contains' because
321 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
322 """Test whether this interval fully contains another or one or more
323 points.
325 Parameters
326 ----------
327 other
328 Another interval to compare to, or one or more position values.
330 Returns
331 -------
332 `bool` | `numpy.ndarray`
333 If a single interval or value was passed, a single `bool`. If an
334 array was passed, an array with the same shape.
336 Notes
337 -----
338 In order to yield the desired behavior for floating-point arguments,
339 points are actually tested against an interval that is 0.5 larger on
340 both sides: this makes positions within the outer boundary of pixels
341 (but beyond the centers of those pixels, which have integer positions)
342 appear "on the image".
343 """
344 if isinstance(other, Interval):
345 return self.start <= other.start and self.stop >= other.stop
346 else:
347 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5)
348 if not result.shape:
349 return bool(result)
350 return result
352 def intersection(self, other: Interval) -> Interval:
353 """Return an interval that is contained by both ``self`` and ``other``.
355 When there is no overlap between the intervals, `NoOverlapError` is
356 raised.
357 """
358 new_start = max(self.start, other.start)
359 new_stop = min(self.stop, other.stop)
360 if new_start < new_stop:
361 return Interval(start=new_start, stop=new_stop)
362 raise NoOverlapError(f"No overlap between {self} and {other}.")
364 def dilated_by(self, padding: int) -> Interval:
365 """Return a new interval padded by the given amount on both sides."""
366 return Interval(start=self._start - padding, stop=self._stop + padding)
368 def slice_within(self, other: Interval) -> slice:
369 """Return the `slice` that corresponds to the values in this interval
370 when the items of the container being sliced correspond to ``other``.
372 This assumes ``other.contains(self)``.
373 """
374 if not other.contains(self):
375 raise IndexError(
376 f"Can not calculate a slice of {other} within {self} "
377 "since the given interval does not contain this one."
378 )
379 return slice(self.start - other.start, self.stop - other.start)
381 @classmethod
382 def from_legacy(cls, legacy: Any) -> Interval:
383 """Convert from an `lsst.geom.IntervalI` instance."""
384 return cls(legacy.begin, legacy.end)
386 def to_legacy(self) -> Any:
387 """Convert to an `lsst.geom.IntervalI` instance."""
388 from lsst.geom import IntervalI
390 return IntervalI(min=self.min, max=self.max)
392 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
393 return (
394 Interval,
395 (
396 self._start,
397 self._stop,
398 ),
399 )
401 @classmethod
402 def __get_pydantic_core_schema__(
403 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
404 ) -> pcs.CoreSchema:
405 from_typed_dict = pcs.no_info_after_validator_function(
406 cls._validate,
407 handler(_SerializedInterval),
408 )
409 return pcs.json_or_python_schema(
410 json_schema=from_typed_dict,
411 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
412 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
413 )
415 @classmethod
416 def _validate(cls, data: _SerializedInterval) -> Interval:
417 return cls(**data)
419 def _serialize(self) -> _SerializedInterval:
420 return {"start": self._start, "stop": self._stop}
423class IntervalSliceFactory:
424 """A factory for `Interval` objects using array-slice syntax.
426 Notes
427 -----
428 When indexed with a single slice on the `Interval.factory` attribute, this
429 returns an `Interval` with exactly the given bounds::
431 assert Interval.factory[3:6] == Interval(start=3, stop=6)
433 A missing start bound is replaced by ``0``, but a missing stop bound is
434 not allowed.
436 When obtained from the `Interval.absolute` property, indices are absolute
437 coordinate values, but any omitted bounds are replaced with the parent
438 interval's bounds::
440 parent = Interval.factory[3:6]
441 assert Interval.factory[4:5] == parent.absolute[:5]
443 The final interval is also checked to be contained by the parent interval.
445 When obtained from the `Interval.local` property, indices are interpreted
446 as relative to the parent interval, and negative indices are relative to
447 the end (like `~collections.abc.Sequence` indexing)::
449 parent = Interval.factory[3:6]
450 assert Interval.factory[4:5] == parent.local[1:-1]
452 When the stop bound is greater than the size of the parent interval, the
453 returned interval is clipped to be contained by the parent (as in
454 `~collections.abc.Sequence` indexing).
455 """
457 def __init__(self, parent: Interval | None = None, is_local: bool = False):
458 self._parent = parent
459 self._is_local = is_local
461 def __getitem__(self, s: slice) -> Interval:
462 if s.step is not None and s.step != 1:
463 raise ValueError(f"Slice {s} has non-unit step.")
464 if self._is_local:
465 assert self._parent is not None, "is_local=True requires a parent interval"
466 start, stop, _ = s.indices(self._parent.size)
467 start += self._parent.start
468 stop += self._parent.start
469 else:
470 start = s.start
471 stop = s.stop
472 if start is None:
473 if self._parent is None:
474 start = 0
475 else:
476 start = self._parent.start
477 if stop is None:
478 if self._parent is None:
479 raise IndexError("An Interval cannot have an empty upper bound.")
480 stop = self._parent.stop
481 if self._parent is not None:
482 if start < self._parent.start:
483 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
484 if stop > self._parent.stop:
485 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
486 return Interval(start=start, stop=stop)
489Interval.factory = IntervalSliceFactory()
492class _SerializedBox(TypedDict):
493 y: _SerializedInterval
494 x: _SerializedInterval
497class Box:
498 """An axis-aligned 2-d rectangular region.
500 Parameters
501 ----------
502 y
503 Interval for the y dimension.
504 x
505 Interval for the x dimension.
507 Notes
508 -----
509 `Box` implements the necessary hooks to be included directly in a
510 `pydantic.BaseModel`, even though it is neither a built-in type nor a
511 Pydantic model itself.
512 """
514 def __init__(self, y: Interval, x: Interval):
515 self._intervals = YX(y, x)
517 __slots__ = ("_intervals",)
519 factory: ClassVar[BoxSliceFactory]
520 """A factory for creating boxes using slice syntax.
522 For example::
524 box = Box.factory[2:5, 3:9]
525 """
527 @classmethod
528 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
529 """Construct a box from its shape and optional start.
531 Parameters
532 ----------
533 shape
534 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
535 start
536 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
537 """
538 if start is None:
539 start = (0,) * len(shape)
540 match shape:
541 case XY(x=x_size, y=y_size):
542 pass
543 case [y_size, x_size]:
544 pass
545 case _:
546 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
547 match start:
548 case XY(x=x_start, y=y_start):
549 pass
550 case [y_start, x_start]:
551 pass
552 case _:
553 raise ValueError(f"Invalid sequence for start: {start!r}.")
554 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
556 @property
557 def start(self) -> YX[int]:
558 """Tuple holding the starts of the intervals ordered ``(y, x)``
559 (`YX` [`int`]).
560 """
561 return YX(self.y.start, self.x.start)
563 @property
564 def shape(self) -> YX[int]:
565 """Tuple holding the sizes of the intervals ordered ``(y, x)``
566 (`YX` [`int`]).
567 """
568 return YX(self.y.size, self.x.size)
570 @property
571 def x(self) -> Interval:
572 """The x-dimension interval (`int`)."""
573 return self._intervals[-1]
575 @property
576 def y(self) -> Interval:
577 """The y-dimension interval (`int`)."""
578 return self._intervals[-2]
580 @property
581 def absolute(self) -> BoxSliceFactory:
582 """A factory for constructing a contained `Box` using slice
583 syntax and absolute coordinates.
585 Notes
586 -----
587 Slice bounds that are absent are replaced with the bounds of ``self``.
588 """
589 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
591 @property
592 def local(self) -> BoxSliceFactory:
593 """A factory for constructing a contained `Interval` using a slice
594 relative to the start of this one (`BoxSliceFactory`).
596 Notes
597 -----
598 This factory interprets slices as "local" coordinates, in which ``0``
599 corresponds to ``self.start``. Negative bounds are relative to
600 ``self.stop``, as is usually the case for Python sequences.
601 """
602 return BoxSliceFactory(y=self.y.local, x=self.x.local)
604 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
605 """Return a pair of 2-d arrays of the coordinate values of the box.
607 Parameters
608 ----------
609 n
610 Number of points in each dimension. If a sequence, points are
611 assumed to be ordered ``(x, y)`` unless a `YX` instance is
612 provided.
613 step
614 Set ``n`` such that the distance between points is equal to or
615 just less than this in each dimension. Mutually exclusive with
616 ``n``.
618 Returns
619 -------
620 `XY` [`numpy.ndarray`]
621 A pair of arrays, each of which is 2-d with floating-point values.
623 See Also
624 --------
625 numpy.meshgrid
626 """
627 if n is not None and step is not None:
628 raise TypeError("'n' and 'step' cannot both be provided.")
629 match n:
630 case int():
631 ax = self.x.linspace(n)
632 ay = self.y.linspace(n)
633 case YX(y=ny, x=nx):
634 ax = self.x.linspace(nx)
635 ay = self.y.linspace(ny)
636 case [nx, ny]:
637 ax = self.x.linspace(nx)
638 ay = self.y.linspace(ny)
639 case None:
640 ax = self.x.linspace(step=step)
641 ay = self.y.linspace(step=step)
642 case _:
643 raise ValueError(f"Unexpected values for n ({n})")
644 return XY(*np.meshgrid(ax, ay))
646 def padded(self, padding: int) -> Box:
647 """Return a new box expanded by the given padding on
648 all sides.
649 """
650 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
652 def __eq__(self, other: object) -> bool:
653 if type(other) is Box:
654 return self._intervals == other._intervals
655 return False
657 def __str__(self) -> str:
658 return f"[y={self.y}, x={self.x}]"
660 def __repr__(self) -> str:
661 return f"Box(y={self.y!r}, x={self.x!r})"
663 @overload
664 def contains(self, other: Box, /) -> bool: ... 664 ↛ exitline 664 didn't return from function 'contains' because
666 @overload
667 def contains(self, *, y: int, x: int) -> bool: ... 667 ↛ exitline 667 didn't return from function 'contains' because
669 @overload
670 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 670 ↛ exitline 670 didn't return from function 'contains' because
672 def contains(
673 self,
674 other: Box | None = None,
675 *,
676 y: int | np.ndarray | None = None,
677 x: int | np.ndarray | None = None,
678 ) -> bool | np.ndarray:
679 """Test whether this box fully contains another or one or more points.
681 Parameters
682 ----------
683 other
684 Another box to compare to. Not compatible with the ``y`` and ``x``
685 arguments.
686 y
687 One or more integer Y coordinates to test for containment.
688 If an array, an array of results will be returned.
689 x
690 One or more integer X coordinates to test for containment.
691 If an array, an array of results will be returned.
693 Returns
694 -------
695 `bool` | `numpy.ndarray`
696 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
697 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
698 array with their broadcasted shape.
700 Notes
701 -----
702 In order to yield the desired behavior for floating-point arguments,
703 points are actually tested against an interval that is 0.5 larger on
704 both sides: this makes positions within the outer boundary of pixels
705 (but beyond the centers of those pixels, which have integer positions)
706 appear "on the image".
707 """
708 if other is not None:
709 if x is not None or y is not None:
710 raise TypeError("Too many arguments to 'Box.contains'.")
711 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
712 elif x is None or y is None:
713 raise TypeError("Not enough arguments to 'Box.contains'.")
714 else:
715 result = np.logical_and(self.x.contains(x), self.y.contains(y))
716 if not result.shape:
717 return bool(result)
718 return result
720 @overload
721 def intersection(self, other: Box) -> Box: ... 721 ↛ exitline 721 didn't return from function 'intersection' because
723 @overload
724 def intersection(self, other: Bounds) -> Bounds: ... 724 ↛ exitline 724 didn't return from function 'intersection' because
726 def intersection(self, other: Bounds) -> Bounds:
727 """Return a bounds object that is contained by both ``self`` and
728 ``other``.
730 When there is no overlap, `NoOverlapError` is raised.
731 """
732 from ._concrete_bounds import _intersect_box
734 return _intersect_box(self, other)
736 def dilated_by(self, padding: int) -> Box:
737 """Return a new box padded by the given amount on all sides."""
738 return Box(*[i.dilated_by(padding) for i in self._intervals])
740 def slice_within(self, other: Box) -> YX[slice]:
741 """Return a `tuple` of `slice` objects that correspond to the
742 positions in this box when the items of the container being sliced
743 correspond to ``other``.
745 This assumes ``other.contains(self)``.
746 """
747 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
749 @property
750 def bbox(self) -> Box:
751 """The box itself (`Box`).
753 This is provided for compatibility with the `Bounds` interface.
754 """
755 return self
757 def boundary(self) -> Iterator[YX[int]]:
758 """Iterate over the corners of the box as ``(y, x)`` tuples."""
759 if len(self._intervals) != 2:
760 raise TypeError("Box is not 2-d.")
761 yield YX(self.y.min, self.x.min)
762 yield YX(self.y.min, self.x.max)
763 yield YX(self.y.max, self.x.max)
764 yield YX(self.y.max, self.x.min)
766 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
767 return (Box, self._intervals)
769 @classmethod
770 def from_legacy(cls, legacy: Any) -> Box:
771 """Convert from an `lsst.geom.Box2I` instance."""
772 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
774 def to_legacy(self) -> Any:
775 """Convert to an `lsst.geom.BoxI` instance."""
776 from lsst.geom import Box2I
778 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
780 @classmethod
781 def __get_pydantic_core_schema__(
782 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
783 ) -> pcs.CoreSchema:
784 from_typed_dict = pcs.no_info_after_validator_function(
785 cls._validate,
786 handler(_SerializedBox),
787 )
788 return pcs.json_or_python_schema(
789 json_schema=from_typed_dict,
790 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
791 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
792 )
794 @classmethod
795 def _validate(cls, data: _SerializedBox) -> Box:
796 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
798 def _serialize(self) -> _SerializedBox:
799 return {"y": self.y._serialize(), "x": self.x._serialize()}
801 def serialize(self) -> Box:
802 """Return a Pydantic-friendly representation of this object.
804 This method just returns the `Box` itself, since that already provides
805 Pydantic serialization hooks. It exists for compatibility with the
806 `Bounds` protocol.
807 """
808 return self
810 def deserialize(self) -> Box:
811 """Deserialize a bounds object on the assumption it is a `Box`.
813 This method just returns the `Box` itself, since that already provides
814 Pydantic serialization hooks. It exists for compatibility with the
815 `Bounds` protocol.
816 """
817 return self
820class BoxSliceFactory:
821 """A factory for `Box` objects using array-slice syntax.
823 Notes
824 -----
825 When `Box.factory` is indexed with a pair of slices, this returns a
826 `Box` with exactly those bounds::
828 assert (
829 Box.factory[3:6, -1:2]
830 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
831 )
833 A missing start bound is replaced by ``0``, but a missing stop bound is
834 not allowed.
836 When obtained from the `Box.absolute` property, indices are absolute
837 coordinate values, but any omitted bounds are replaced with the parent
838 box's bounds::
840 parent = Box.factory[3:6, -1:2]
841 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
843 The final box is also checked to be contained by the parent box.
845 When obtained from the `Box.local` property, indices are interpreted
846 as relative to the parent box, and negative indices are relative to
847 the end (like `~collections.abc.Sequence` indexing)::
849 parent = Box.factory[3:6, -1:2]
850 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
851 """
853 def __init__(
854 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
855 ):
856 self._y = y
857 self._x = x
859 def __getitem__(self, key: tuple[slice, slice]) -> Box:
860 match key:
861 case XY(x=x, y=y):
862 return Box(y=self._y[y], x=self._x[x])
863 case (y, x):
864 return Box(y=self._y[y], x=self._x[x])
865 case _:
866 raise TypeError("Expected exactly two slices.")
869Box.factory = BoxSliceFactory()
872class Bounds(Protocol):
873 """A protocol for objects that represent the validity region for a function
874 defined in 2-d pixel coordinates.
876 Notes
877 -----
878 Most objects natively have a simple 2-d bounding box as their bounds
879 (typically the boundary of a sensor), and the `Box` class is hence the
880 most common bounds implementation. But sometimes a large chunk of that
881 box may be missing due to vignetting or bad amplifiers, and we may want to
882 transform from one coordinate system to another. The Bounds interface is
883 intended to handle both of these cases as well.
884 """
886 @property
887 def bbox(self) -> Box: ... 887 ↛ exitline 887 didn't return from function 'bbox' because
889 @overload
890 def contains(self, *, x: int, y: int) -> bool: ... 890 ↛ exitline 890 didn't return from function 'contains' because
892 @overload
893 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 893 ↛ exitline 893 didn't return from function 'contains' because
895 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
896 """Test whether this box fully contains another or one or more points.
898 Parameters
899 ----------
900 x
901 One or more integer X coordinates to test for containment.
902 If an array, an array of results will be returned.
903 y
904 One or more integer Y coordinates to test for containment.
905 If an array, an array of results will be returned.
907 Returns
908 -------
909 `bool` | `numpy.ndarray`
910 If ``x`` and ``y`` are both scalars, a single `bool` value. If
911 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
912 shape.
913 """
914 ...
916 def intersection(self, other: Bounds) -> Bounds:
917 """Compute the intersection of this bounds object with another."""
918 ...
920 def serialize(self) -> SerializableBounds:
921 """Convert a bounds instance into a serializable object.
923 Notes
924 -----
925 The returned object must support direct nesting with Pydantic models
926 and have a ``deserialize`` method (taking no arguments) that converts
927 back to this `Bounds` type. It is common for `serialize` and
928 ``deserialize`` to just return ``self``, when the bounds object is
929 natively serializable.
930 """
931 ...
934class BoundsError(ValueError):
935 """Exception raised when an object is evaluated outside its bounds."""
938class NoOverlapError(ValueError):
939 """Exception raised when intervals or bounds do not overlap."""