Coverage for python / lsst / images / _geom.py: 40%
349 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-07 08:34 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-07 08:34 +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 Self,
35 TypedDict,
36 TypeVar,
37 cast,
38 final,
39 overload,
40)
42import numpy as np
43import pydantic
44import pydantic_core.core_schema as pcs
46if TYPE_CHECKING:
47 from ._concrete_bounds import SerializableBounds
49# This pre-python-3.12 declaration is needed by Sphinx (probably the
50# autodoc-typehints plugin.
51T = TypeVar("T")
53# Interval and Box are defined as regular Python classes rather than
54# dataclasses or Pydantic models because we might want to implement them as
55# compiled-extension types in the future, and we want that to be transparent.
57# In a similar vein, we avoid declaring specific types for multidimensional
58# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
59# shapes) in order to leave room for more fully-featured types to be added
60# upstream of this package in the future.
63class YX[T](NamedTuple):
64 """A pair of per-dimension objects, ordered ``(y, x)``.
66 Notes
67 -----
68 `YX` is used for slices, shapes, and other 2-d pairs when the most
69 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
70 arithmetic operations behave as they would on a
71 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
72 concatenates).
74 See Also
75 --------
76 XY
77 """
79 y: T
80 """The y / row object."""
82 x: T
83 """The x / column object."""
85 @property
86 def xy(self) -> XY:
87 """A tuple of the same objects in the opposite order."""
88 return XY(x=self.x, y=self.y)
90 def map[U](self, func: Callable[[T], U]) -> YX[U]:
91 """Apply a function to both objects."""
92 return YX(y=func(self.y), x=func(self.x))
95class XY[T](NamedTuple):
96 """A pair of per-dimension objects, ordered ``(x, y)``.
98 Notes
99 -----
100 `XY` is used for points and other 2-d pairs when the most natural ordering
101 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
102 behave as they would on a `collections.abc.Sequence`, not a mathematical
103 vector (e.g. adding concatenates).
105 See Also
106 --------
107 YX
108 """
110 x: T
111 """The x / column object."""
113 y: T
114 """The y / row object."""
116 @property
117 def yx(self) -> YX:
118 """A tuple of the same objects in the opposite order."""
119 return YX(y=self.y, x=self.x)
121 def map[U](self, func: Callable[[T], U]) -> XY[U]:
122 """Apply a function to both objects."""
123 return XY(x=func(self.x), y=func(self.y))
126class _SerializedInterval(TypedDict):
127 start: int
128 stop: int
131@final
132class Interval:
133 """A 1-d integer interval with positive size.
135 Parameters
136 ----------
137 start
138 Inclusive minimum point in the interval.
139 stop
140 One past the maximum point in the interval.
142 Notes
143 -----
144 Adding or subtracting an `int` from an interval returns a shifted interval.
146 `Interval` implements the necessary hooks to be included directly in a
147 `pydantic.BaseModel`, even though it is neither a built-in type nor a
148 Pydantic model itself.
149 """
151 def __init__(self, start: int, stop: int):
152 # Coerce to be defensive against numpy int scalars.
153 self._start = int(start)
154 self._stop = int(stop)
155 if not (self._stop > self._start):
156 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
158 __slots__ = ("_start", "_stop")
160 factory: ClassVar[IntervalSliceFactory]
161 """A factory for creating intervals using slice syntax.
163 For example::
165 interval = Interval.factory[2:5]
166 """
168 @classmethod
169 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
170 """Construct an interval that includes all of the given points and/or
171 intervals.
172 """
173 if type(first) is Interval:
174 rmin = first.min
175 rmax = first.max
176 else:
177 rmin = rmax = first
178 for arg in args:
179 if type(arg) is Interval:
180 rmin = min(rmin, arg.min)
181 rmax = max(rmax, arg.max)
182 else:
183 rmin = min(rmin, arg)
184 rmax = max(rmax, arg)
185 return Interval(start=rmin, stop=rmax + 1)
187 @classmethod
188 def from_size(cls, size: int, start: int = 0) -> Interval:
189 """Construct an interval from its size and optional start."""
190 return cls(start=start, stop=start + size)
192 @property
193 def start(self) -> int:
194 """Inclusive minimum point in the interval (`int`)."""
195 return self._start
197 @property
198 def stop(self) -> int:
199 """One past the maximum point in the interval (`int`)."""
200 return self._stop
202 @property
203 def min(self) -> int:
204 """Inclusive minimum point in the interval (`int`)."""
205 return self.start
207 @property
208 def max(self) -> int:
209 """Inclusive maximum point in the interval (`int`)."""
210 return self.stop - 1
212 @property
213 def size(self) -> int:
214 """Size of the interval (`int`)."""
215 return self.stop - self.start
217 @property
218 def range(self) -> __builtins__.range:
219 """An iterable over all values in the interval
220 (`__builtins__.range`).
221 """
222 return range(self.start, self.stop)
224 @property
225 def arange(self) -> np.ndarray:
226 """An array of all the values in the interval (`numpy.ndarray`).
228 Array values are integers.
229 """
230 return np.arange(self.start, self.stop)
232 @property
233 def absolute(self) -> IntervalSliceFactory:
234 """A factory for constructing a contained `Interval` using slice
235 syntax and absolute coordinates.
237 Notes
238 -----
239 Slice bounds that are absent are replaced with the bounds of ``self``.
240 """
241 return IntervalSliceFactory(self, is_local=False)
243 @property
244 def local(self) -> IntervalSliceFactory:
245 """A factory for constructing a contained `Interval` using a slice
246 relative to the start of this one (`IntervalSliceFactory`).
248 Notes
249 -----
250 This factory interprets slices as "local" coordinates, in which ``0``
251 corresponds to ``self.start``. Negative bounds are relative to
252 ``self.stop``, as is usually the case for Python sequences.
253 """
254 return IntervalSliceFactory(self, is_local=True)
256 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
257 """Return an array of values that spans the interval.
259 Parameters
260 ----------
261 n
262 How many values to return. The default (if ``step`` is also not
263 provided) is the size of the interval, i.e. equivalent to the
264 `arange` property (but converted to `float`).
265 step
266 Set ``n`` such that the distance between points is equal to or
267 just less than this. Mutually exclusive with ``n``.
269 Returns
270 -------
271 numpy.ndarray
272 Array of `float` values.
274 See Also
275 --------
276 numpy.linspace
277 """
278 if n is None:
279 if step is None:
280 return self.arange.astype(np.float64)
281 n = math.ceil(self.size / step)
282 elif step is not None:
283 raise TypeError("'n' and 'step' cannot both be provided.")
284 return np.linspace(self.min, self.max, n, dtype=np.float64)
286 @property
287 def center(self) -> float:
288 """The center of the interval (`float`)."""
289 return 0.5 * (self.min + self.max)
291 def padded(self, padding: int) -> Interval:
292 """Return a new interval expanded by the given padding on
293 either side.
294 """
295 return Interval(self.start - padding, self.stop + padding)
297 def __str__(self) -> str:
298 return f"{self.start}:{self.stop}"
300 def __repr__(self) -> str:
301 return f"Interval(start={self.start}, stop={self.stop})"
303 def __eq__(self, other: object) -> bool:
304 if type(other) is Interval:
305 return self._start == other._start and self._stop == other._stop
306 return False
308 def __add__(self, other: int) -> Interval:
309 return Interval(start=self.start + other, stop=self.stop + other)
311 def __sub__(self, other: int) -> Interval:
312 return Interval(start=self.start - other, stop=self.stop - other)
314 def __contains__(self, x: int) -> bool:
315 return x >= self.start and x < self.stop
317 @overload
318 def contains(self, other: Interval | int | float) -> bool: ... 318 ↛ exitline 318 didn't return from function 'contains' because
320 @overload
321 def contains(self, other: np.ndarray) -> np.ndarray: ... 321 ↛ exitline 321 didn't return from function 'contains' because
323 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
324 """Test whether this interval fully contains another or one or more
325 points.
327 Parameters
328 ----------
329 other
330 Another interval to compare to, or one or more position values.
332 Returns
333 -------
334 `bool` | `numpy.ndarray`
335 If a single interval or value was passed, a single `bool`. If an
336 array was passed, an array with the same shape.
338 Notes
339 -----
340 In order to yield the desired behavior for floating-point arguments,
341 points are actually tested against an interval that is 0.5 larger on
342 both sides: this makes positions within the outer boundary of pixels
343 (but beyond the centers of those pixels, which have integer positions)
344 appear "on the image".
345 """
346 if isinstance(other, Interval):
347 return self.start <= other.start and self.stop >= other.stop
348 else:
349 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5)
350 if not result.shape:
351 return bool(result)
352 return result
354 def intersection(self, other: Interval) -> Interval:
355 """Return an interval that is contained by both ``self`` and ``other``.
357 When there is no overlap between the intervals, `NoOverlapError` is
358 raised.
359 """
360 new_start = max(self.start, other.start)
361 new_stop = min(self.stop, other.stop)
362 if new_start < new_stop:
363 return Interval(start=new_start, stop=new_stop)
364 raise NoOverlapError(f"No overlap between {self} and {other}.")
366 def dilated_by(self, padding: int) -> Interval:
367 """Return a new interval padded by the given amount on both sides."""
368 return Interval(start=self._start - padding, stop=self._stop + padding)
370 def slice_within(self, other: Interval) -> slice:
371 """Return the `slice` that corresponds to the values in this interval
372 when the items of the container being sliced correspond to ``other``.
374 This assumes ``other.contains(self)``.
375 """
376 if not other.contains(self):
377 raise IndexError(
378 f"Can not calculate a slice of {other} within {self} "
379 "since the given interval does not contain this one."
380 )
381 return slice(self.start - other.start, self.stop - other.start)
383 @classmethod
384 def from_legacy(cls, legacy: Any) -> Interval:
385 """Convert from an `lsst.geom.IntervalI` instance."""
386 return cls(legacy.begin, legacy.end)
388 def to_legacy(self) -> Any:
389 """Convert to an `lsst.geom.IntervalI` instance."""
390 from lsst.geom import IntervalI
392 return IntervalI(min=self.min, max=self.max)
394 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
395 return (
396 Interval,
397 (
398 self._start,
399 self._stop,
400 ),
401 )
403 @classmethod
404 def __get_pydantic_core_schema__(
405 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
406 ) -> pcs.CoreSchema:
407 from_typed_dict = pcs.chain_schema(
408 [
409 handler(_SerializedInterval),
410 pcs.no_info_plain_validator_function(cls._validate),
411 ]
412 )
413 return pcs.json_or_python_schema(
414 json_schema=from_typed_dict,
415 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
416 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
417 )
419 @classmethod
420 def _validate(cls, data: _SerializedInterval) -> Interval:
421 return cls(**data)
423 def _serialize(self) -> _SerializedInterval:
424 return {"start": self._start, "stop": self._stop}
427class IntervalSliceFactory:
428 """A factory for `Interval` objects using array-slice syntax.
430 Notes
431 -----
432 When indexed with a single slice on the `Interval.factory` attribute, this
433 returns an `Interval` with exactly the given bounds::
435 assert Interval.factory[3:6] == Interval(start=3, stop=6)
437 A missing start bound is replaced by ``0``, but a missing stop bound is
438 not allowed.
440 When obtained from the `Interval.absolute` property, indices are absolute
441 coordinate values, but any omitted bounds are replaced with the parent
442 interval's bounds::
444 parent = Interval.factory[3:6]
445 assert Interval.factory[4:5] == parent.absolute[:5]
447 The final interval is also checked to be contained by the parent interval.
449 When obtained from the `Interval.local` property, indices are interpreted
450 as relative to the parent interval, and negative indices are relative to
451 the end (like `~collections.abc.Sequence` indexing)::
453 parent = Interval.factory[3:6]
454 assert Interval.factory[4:5] == parent.local[1:-1]
456 When the stop bound is greater than the size of the parent interval, the
457 returned interval is clipped to be contained by the parent (as in
458 `~collections.abc.Sequence` indexing).
459 """
461 def __init__(self, parent: Interval | None = None, is_local: bool = False):
462 self._parent = parent
463 self._is_local = is_local
465 def __getitem__(self, s: slice) -> Interval:
466 if s.step is not None and s.step != 1:
467 raise ValueError(f"Slice {s} has non-unit step.")
468 if self._is_local:
469 assert self._parent is not None, "is_local=True requires a parent interval"
470 start, stop, _ = s.indices(self._parent.size)
471 start += self._parent.start
472 stop += self._parent.start
473 else:
474 start = s.start
475 stop = s.stop
476 if start is None:
477 if self._parent is None:
478 start = 0
479 else:
480 start = self._parent.start
481 if stop is None:
482 if self._parent is None:
483 raise IndexError("An Interval cannot have an empty upper bound.")
484 stop = self._parent.stop
485 if self._parent is not None:
486 if start < self._parent.start:
487 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
488 if stop > self._parent.stop:
489 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
490 return Interval(start=start, stop=stop)
493Interval.factory = IntervalSliceFactory()
496class _SerializedBox(TypedDict):
497 y: _SerializedInterval
498 x: _SerializedInterval
501class Box:
502 """An axis-aligned 2-d rectangular region.
504 Parameters
505 ----------
506 y
507 Interval for the y dimension.
508 x
509 Interval for the x dimension.
511 Notes
512 -----
513 `Box` implements the necessary hooks to be included directly in a
514 `pydantic.BaseModel`, even though it is neither a built-in type nor a
515 Pydantic model itself.
516 """
518 def __init__(self, y: Interval, x: Interval):
519 self._intervals = YX(y, x)
521 __slots__ = ("_intervals",)
523 factory: ClassVar[BoxSliceFactory]
524 """A factory for creating boxes using slice syntax.
526 For example::
528 box = Box.factory[2:5, 3:9]
529 """
531 @classmethod
532 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
533 """Construct a box from its shape and optional start.
535 Parameters
536 ----------
537 shape
538 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
539 start
540 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
541 """
542 if start is None:
543 start = (0,) * len(shape)
544 match shape:
545 case XY(x=x_size, y=y_size):
546 pass
547 case [y_size, x_size]:
548 pass
549 case _:
550 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
551 match start:
552 case XY(x=x_start, y=y_start):
553 pass
554 case [y_start, x_start]:
555 pass
556 case _:
557 raise ValueError(f"Invalid sequence for start: {start!r}.")
558 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
560 @property
561 def start(self) -> YX[int]:
562 """Tuple holding the starts of the intervals ordered ``(y, x)``
563 (`YX` [`int`]).
564 """
565 return YX(self.y.start, self.x.start)
567 @property
568 def shape(self) -> YX[int]:
569 """Tuple holding the sizes of the intervals ordered ``(y, x)``
570 (`YX` [`int`]).
571 """
572 return YX(self.y.size, self.x.size)
574 @property
575 def x(self) -> Interval:
576 """The x-dimension interval (`int`)."""
577 return self._intervals[-1]
579 @property
580 def y(self) -> Interval:
581 """The y-dimension interval (`int`)."""
582 return self._intervals[-2]
584 @property
585 def absolute(self) -> BoxSliceFactory:
586 """A factory for constructing a contained `Box` using slice
587 syntax and absolute coordinates.
589 Notes
590 -----
591 Slice bounds that are absent are replaced with the bounds of ``self``.
592 """
593 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
595 @property
596 def local(self) -> BoxSliceFactory:
597 """A factory for constructing a contained `Interval` using a slice
598 relative to the start of this one (`BoxSliceFactory`).
600 Notes
601 -----
602 This factory interprets slices as "local" coordinates, in which ``0``
603 corresponds to ``self.start``. Negative bounds are relative to
604 ``self.stop``, as is usually the case for Python sequences.
605 """
606 return BoxSliceFactory(y=self.y.local, x=self.x.local)
608 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
609 """Return a pair of 2-d arrays of the coordinate values of the box.
611 Parameters
612 ----------
613 n
614 Number of points in each dimension. If a sequence, points are
615 assumed to be ordered ``(x, y)`` unless a `YX` instance is
616 provided.
617 step
618 Set ``n`` such that the distance between points is equal to or
619 just less than this in each dimension. Mutually exclusive with
620 ``n``.
622 Returns
623 -------
624 `XY` [`numpy.ndarray`]
625 A pair of arrays, each of which is 2-d with floating-point values.
627 See Also
628 --------
629 numpy.meshgrid
630 """
631 if n is not None and step is not None:
632 raise TypeError("'n' and 'step' cannot both be provided.")
633 match n:
634 case int():
635 ax = self.x.linspace(n)
636 ay = self.y.linspace(n)
637 case YX(y=ny, x=nx):
638 ax = self.x.linspace(nx)
639 ay = self.y.linspace(ny)
640 case [nx, ny]:
641 ax = self.x.linspace(nx)
642 ay = self.y.linspace(ny)
643 case None:
644 ax = self.x.linspace(step=step)
645 ay = self.y.linspace(step=step)
646 case _:
647 raise ValueError(f"Unexpected values for n ({n})")
648 return XY(*np.meshgrid(ax, ay))
650 def padded(self, padding: int) -> Box:
651 """Return a new box expanded by the given padding on
652 all sides.
653 """
654 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
656 def __eq__(self, other: object) -> bool:
657 if type(other) is Box:
658 return self._intervals == other._intervals
659 return False
661 def __str__(self) -> str:
662 return f"[y={self.y}, x={self.x}]"
664 def __repr__(self) -> str:
665 return f"Box(y={self.y!r}, x={self.x!r})"
667 @overload
668 def contains(self, other: Box, /) -> bool: ... 668 ↛ exitline 668 didn't return from function 'contains' because
670 @overload
671 def contains(self, *, y: int, x: int) -> bool: ... 671 ↛ exitline 671 didn't return from function 'contains' because
673 @overload
674 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 674 ↛ exitline 674 didn't return from function 'contains' because
676 def contains(
677 self,
678 other: Box | None = None,
679 *,
680 y: int | np.ndarray | None = None,
681 x: int | np.ndarray | None = None,
682 ) -> bool | np.ndarray:
683 """Test whether this box fully contains another or one or more points.
685 Parameters
686 ----------
687 other
688 Another box to compare to. Not compatible with the ``y`` and ``x``
689 arguments.
690 y
691 One or more integer Y coordinates to test for containment.
692 If an array, an array of results will be returned.
693 x
694 One or more integer X coordinates to test for containment.
695 If an array, an array of results will be returned.
697 Returns
698 -------
699 `bool` | `numpy.ndarray`
700 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
701 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
702 array with their broadcasted shape.
704 Notes
705 -----
706 In order to yield the desired behavior for floating-point arguments,
707 points are actually tested against an interval that is 0.5 larger on
708 both sides: this makes positions within the outer boundary of pixels
709 (but beyond the centers of those pixels, which have integer positions)
710 appear "on the image".
711 """
712 if other is not None:
713 if x is not None or y is not None:
714 raise TypeError("Too many arguments to 'Box.contains'.")
715 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
716 elif x is None or y is None:
717 raise TypeError("Not enough arguments to 'Box.contains'.")
718 else:
719 result = np.logical_and(self.x.contains(x), self.y.contains(y))
720 if not result.shape:
721 return bool(result)
722 return result
724 @overload
725 def intersection(self, other: Box) -> Box: ... 725 ↛ exitline 725 didn't return from function 'intersection' because
727 @overload
728 def intersection(self, other: Bounds) -> Bounds: ... 728 ↛ exitline 728 didn't return from function 'intersection' because
730 def intersection(self, other: Bounds) -> Bounds:
731 """Return a bounds object that is contained by both ``self`` and
732 ``other``.
734 When there is no overlap, `NoOverlapError` is raised.
735 """
736 from ._concrete_bounds import _intersect_box
738 return _intersect_box(self, other)
740 def dilated_by(self, padding: int) -> Box:
741 """Return a new box padded by the given amount on all sides."""
742 return Box(*[i.dilated_by(padding) for i in self._intervals])
744 def slice_within(self, other: Box) -> YX[slice]:
745 """Return a `tuple` of `slice` objects that correspond to the
746 positions in this box when the items of the container being sliced
747 correspond to ``other``.
749 This assumes ``other.contains(self)``.
750 """
751 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
753 @property
754 def bbox(self) -> Box:
755 """The box itself (`Box`).
757 This is provided for compatibility with the `Bounds` interface.
758 """
759 return self
761 def boundary(self) -> Iterator[YX[int]]:
762 """Iterate over the corners of the box as ``(y, x)`` tuples."""
763 if len(self._intervals) != 2:
764 raise TypeError("Box is not 2-d.")
765 yield YX(self.y.min, self.x.min)
766 yield YX(self.y.min, self.x.max)
767 yield YX(self.y.max, self.x.max)
768 yield YX(self.y.max, self.x.min)
770 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
771 return (Box, self._intervals)
773 @classmethod
774 def from_legacy(cls, legacy: Any) -> Box:
775 """Convert from an `lsst.geom.Box2I` instance."""
776 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
778 def to_legacy(self) -> Any:
779 """Convert to an `lsst.geom.BoxI` instance."""
780 from lsst.geom import Box2I
782 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
784 @classmethod
785 def __get_pydantic_core_schema__(
786 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
787 ) -> pcs.CoreSchema:
788 from_typed_dict = pcs.chain_schema(
789 [
790 handler(_SerializedBox),
791 pcs.no_info_plain_validator_function(cls._validate),
792 ]
793 )
794 return pcs.json_or_python_schema(
795 json_schema=from_typed_dict,
796 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
797 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
798 )
800 @classmethod
801 def _validate(cls, data: _SerializedBox) -> Box:
802 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
804 def _serialize(self) -> _SerializedBox:
805 return {"y": self.y._serialize(), "x": self.x._serialize()}
807 def serialize(self) -> Box:
808 """Return a Pydantic-friendly representation of this object.
810 This method just returns the `Box` itself, since that already provides
811 Pydantic serialization hooks. It exists for compatibility with the
812 `Bounds` protocol.
813 """
814 return self
816 @classmethod
817 def deserialize(cls, serialized: SerializableBounds) -> Box:
818 """Deserialize a bounds object on the assumption it is a `Box`.
820 This method just returns the `Box` itself, since that already provides
821 Pydantic serialization hooks. It exists for compatibility with the
822 `Bounds` protocol.
823 """
824 assert isinstance(serialized, Box)
825 return serialized
828class BoxSliceFactory:
829 """A factory for `Box` objects using array-slice syntax.
831 Notes
832 -----
833 When `Box.factory` is indexed with a pair of slices, this returns a
834 `Box` with exactly those bounds::
836 assert (
837 Box.factory[3:6, -1:2]
838 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
839 )
841 A missing start bound is replaced by ``0``, but a missing stop bound is
842 not allowed.
844 When obtained from the `Box.absolute` property, indices are absolute
845 coordinate values, but any omitted bounds are replaced with the parent
846 box's bounds::
848 parent = Box.factory[3:6, -1:2]
849 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
851 The final box is also checked to be contained by the parent box.
853 When obtained from the `Box.local` property, indices are interpreted
854 as relative to the parent box, and negative indices are relative to
855 the end (like `~collections.abc.Sequence` indexing)::
857 parent = Box.factory[3:6, -1:2]
858 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
859 """
861 def __init__(
862 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
863 ):
864 self._y = y
865 self._x = x
867 def __getitem__(self, key: tuple[slice, slice]) -> Box:
868 match key:
869 case XY(x=x, y=y):
870 return Box(y=self._y[y], x=self._x[x])
871 case (y, x):
872 return Box(y=self._y[y], x=self._x[x])
873 case _:
874 raise TypeError("Expected exactly two slices.")
877Box.factory = BoxSliceFactory()
880class Bounds(Protocol):
881 """A protocol for objects that represent the validity region for a function
882 defined in 2-d pixel coordinates.
884 Notes
885 -----
886 Most objects natively have a simple 2-d bounding box as their bounds
887 (typically the boundary of a sensor), and the `Box` class is hence the
888 most common bounds implementation. But sometimes a large chunk of that
889 box may be missing due to vignetting or bad amplifiers, and we may want to
890 transform from one coordinate system to another. The Bounds interface is
891 intended to handle both of these cases as well.
892 """
894 @property
895 def bbox(self) -> Box: ... 895 ↛ exitline 895 didn't return from function 'bbox' because
897 @overload
898 def contains(self, *, x: int, y: int) -> bool: ... 898 ↛ exitline 898 didn't return from function 'contains' because
900 @overload
901 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 901 ↛ exitline 901 didn't return from function 'contains' because
903 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
904 """Test whether this box fully contains another or one or more points.
906 Parameters
907 ----------
908 x
909 One or more integer X coordinates to test for containment.
910 If an array, an array of results will be returned.
911 y
912 One or more integer Y coordinates to test for containment.
913 If an array, an array of results will be returned.
915 Returns
916 -------
917 `bool` | `numpy.ndarray`
918 If ``x`` and ``y`` are both scalars, a single `bool` value. If
919 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
920 shape.
921 """
922 ...
924 def intersection(self, other: Bounds) -> Bounds:
925 """Compute the intersection of this bounds object with another."""
926 ...
928 def serialize(self) -> SerializableBounds:
929 """Convert a bounds instance into a serializable object."""
930 ...
932 @classmethod
933 def deserialize(cls, serialized: SerializableBounds) -> Self:
934 """Convert a serialized bounds object into its in-memory form."""
935 from ._concrete_bounds import deserialize_bounds
937 return cast(Self, deserialize_bounds(serialized))
940class BoundsError(ValueError):
941 """Exception raised when an object is evaluated outside its bounds."""
944class NoOverlapError(ValueError):
945 """Exception raised when intervals or bounds do not overlap."""