Coverage for python / lsst / images / _geom.py: 38%
339 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:13 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:13 +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)
25import math
26from collections.abc import Callable, Iterator, Sequence
27from typing import (
28 TYPE_CHECKING,
29 Any,
30 ClassVar,
31 NamedTuple,
32 Protocol,
33 Self,
34 TypedDict,
35 TypeVar,
36 cast,
37 final,
38 overload,
39)
41import numpy as np
42import pydantic
43import pydantic_core.core_schema as pcs
45if TYPE_CHECKING:
46 from ._concrete_bounds import SerializableBounds
48# This pre-python-3.12 declaration is needed by Sphinx (probably the
49# autodoc-typehints plugin.
50T = TypeVar("T")
52# Interval and Box are defined as regular Python classes rather than
53# dataclasses or Pydantic models because we might want to implement them as
54# compiled-extension types in the future, and we want that to be transparent.
56# In a similar vein, we avoid declaring specific types for multidimensional
57# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
58# shapes) in order to leave room for more fully-featured types to be added
59# upstream of this package in the future.
62class YX[T](NamedTuple):
63 """A pair of per-dimension objects, ordered ``(y, x)``.
65 Notes
66 -----
67 `YX` is used for slices, shapes, and other 2-d pairs when the most
68 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
69 arithmetic operations behave as they would on a
70 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
71 concatenates).
73 See Also
74 --------
75 XY
76 """
78 y: T
79 """The y / row object."""
81 x: T
82 """The x / column object."""
84 @property
85 def xy(self) -> XY:
86 """A tuple of the same objects in the opposite order."""
87 return XY(x=self.x, y=self.y)
89 def map[U](self, func: Callable[[T], U]) -> YX[U]:
90 """Apply a function to both objects."""
91 return YX(y=func(self.y), x=func(self.x))
94class XY[T](NamedTuple):
95 """A pair of per-dimension objects, ordered ``(x, y)``.
97 Notes
98 -----
99 `XY` is used for points and other 2-d pairs when the most natural ordering
100 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
101 behave as they would on a `collections.abc.Sequence`, not a mathematical
102 vector (e.g. adding concatenates).
104 See Also
105 --------
106 YX
107 """
109 x: T
110 """The x / column object."""
112 y: T
113 """The y / row object."""
115 @property
116 def yx(self) -> YX:
117 """A tuple of the same objects in the opposite order."""
118 return YX(y=self.y, x=self.x)
120 def map[U](self, func: Callable[[T], U]) -> XY[U]:
121 """Apply a function to both objects."""
122 return XY(x=func(self.x), y=func(self.y))
125class _SerializedInterval(TypedDict):
126 start: int
127 stop: int
130@final
131class Interval:
132 """A 1-d integer interval with positive size.
134 Parameters
135 ----------
136 start
137 Inclusive minimum point in the interval.
138 stop
139 One past the maximum point in the interval.
141 Notes
142 -----
143 Adding or subtracting an `int` from an interval returns a shifted interval.
145 `Interval` implements the necessary hooks to be included directly in a
146 `pydantic.BaseModel`, even though it is neither a built-in type nor a
147 Pydantic model itself.
148 """
150 def __init__(self, start: int, stop: int):
151 # Coerce to be defensive against numpy int scalars.
152 self._start = int(start)
153 self._stop = int(stop)
154 if not (self._stop > self._start):
155 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
157 __slots__ = ("_start", "_stop")
159 factory: ClassVar[IntervalSliceFactory]
160 """A factory for creating intervals using slice syntax.
162 For example::
164 interval = Interval.factory[2:5]
165 """
167 @classmethod
168 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
169 """Construct an interval that includes all of the given points and/or
170 intervals.
171 """
172 if type(first) is Interval:
173 rmin = first.min
174 rmax = first.max
175 else:
176 rmin = rmax = first
177 for arg in args:
178 if type(arg) is Interval:
179 rmin = min(rmin, arg.min)
180 rmax = max(rmax, arg.max)
181 else:
182 rmin = min(rmin, arg)
183 rmax = max(rmax, arg)
184 return Interval(start=rmin, stop=rmax + 1)
186 @classmethod
187 def from_size(cls, size: int, start: int = 0) -> Interval:
188 """Construct an interval from its size and optional start."""
189 return cls(start=start, stop=start + size)
191 @property
192 def start(self) -> int:
193 """Inclusive minimum point in the interval (`int`)."""
194 return self._start
196 @property
197 def stop(self) -> int:
198 """One past the maximum point in the interval (`int`)."""
199 return self._stop
201 @property
202 def min(self) -> int:
203 """Inclusive minimum point in the interval (`int`)."""
204 return self.start
206 @property
207 def max(self) -> int:
208 """Inclusive maximum point in the interval (`int`)."""
209 return self.stop - 1
211 @property
212 def size(self) -> int:
213 """Size of the interval (`int`)."""
214 return self.stop - self.start
216 @property
217 def range(self) -> __builtins__.range:
218 """An iterable over all values in the interval
219 (`__builtins__.range`).
220 """
221 return range(self.start, self.stop)
223 @property
224 def arange(self) -> np.ndarray:
225 """An array of all the values in the interval (`numpy.ndarray`).
227 Array values are integers.
228 """
229 return np.arange(self.start, self.stop)
231 @property
232 def absolute(self) -> IntervalSliceFactory:
233 """A factory for constructing a contained `Interval` using slice
234 syntax and absolute coordinates.
236 Notes
237 -----
238 Slice bounds that are absent are replaced with the bounds of ``self``.
239 """
240 return IntervalSliceFactory(self, is_local=False)
242 @property
243 def local(self) -> IntervalSliceFactory:
244 """A factory for constructing a contained `Interval` using a slice
245 relative to the start of this one (`IntervalSliceFactory`).
247 Notes
248 -----
249 This factory interprets slices as "local" coordinates, in which ``0``
250 corresponds to ``self.start``. Negative bounds are relative to
251 ``self.stop``, as is usually the case for Python sequences.
252 """
253 return IntervalSliceFactory(self, is_local=True)
255 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
256 """Return an array of values that spans the interval.
258 Parameters
259 ----------
260 n
261 How many values to return. The default (if ``step`` is also not
262 provided) is the size of the interval, i.e. equivalent to the
263 `arange` property (but converted to `float`).
264 step
265 Set ``n`` such that the distance between points is equal to or
266 just less than this. Mutually exclusive with ``n``.
268 Returns
269 -------
270 numpy.ndarray
271 Array of `float` values.
273 See Also
274 --------
275 numpy.linspace
276 """
277 if n is None:
278 if step is None:
279 return self.arange.astype(np.float64)
280 n = math.ceil(self.size / step)
281 elif step is not None:
282 raise TypeError("'n' and 'step' cannot both be provided.")
283 return np.linspace(self.min, self.max, n, dtype=np.float64)
285 @property
286 def center(self) -> float:
287 """The center of the interval (`float`)."""
288 return 0.5 * (self.min + self.max)
290 def __str__(self) -> str:
291 return f"{self.start}:{self.stop}"
293 def __repr__(self) -> str:
294 return f"Interval(start={self.start}, stop={self.stop})"
296 def __eq__(self, other: object) -> bool:
297 if type(other) is Interval:
298 return self._start == other._start and self._stop == other._stop
299 return False
301 def __add__(self, other: int) -> Interval:
302 return Interval(start=self.start + other, stop=self.stop + other)
304 def __sub__(self, other: int) -> Interval:
305 return Interval(start=self.start - other, stop=self.stop - other)
307 def __contains__(self, x: int) -> bool:
308 return x >= self.start and x < self.stop
310 @overload
311 def contains(self, other: Interval | int | float) -> bool: ... 311 ↛ exitline 311 didn't return from function 'contains' because
313 @overload
314 def contains(self, other: np.ndarray) -> np.ndarray: ... 314 ↛ exitline 314 didn't return from function 'contains' because
316 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
317 """Test whether this interval fully contains another or one or more
318 points.
320 Parameters
321 ----------
322 other
323 Another interval to compare to, or one or more position values.
325 Returns
326 -------
327 `bool` | `numpy.ndarray`
328 If a single interval or value was passed, a single `bool`. If an
329 array was passed, an array with the same shape.
331 Notes
332 -----
333 In order to yield the desired behavior for floating-point arguments,
334 points are actually tested against an interval that is 0.5 larger on
335 both sides: this makes positions within the outer boundary of pixels
336 (but beyond the centers of those pixels, which have integer positions)
337 appear "on the image".
338 """
339 if isinstance(other, Interval):
340 return self.start <= other.start and self.stop >= other.stop
341 else:
342 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5)
343 if not result.shape:
344 return bool(result)
345 return result
347 def intersection(self, other: Interval) -> Interval | None:
348 """Return an interval that is contained by both ``self`` and ``other``.
350 When there is no overlap between the intervals, `None` is returned.
351 """
352 new_start = max(self.start, other.start)
353 new_stop = min(self.stop, other.stop)
354 if new_start < new_stop:
355 return Interval(start=new_start, stop=new_stop)
356 return None
358 def dilated_by(self, padding: int) -> Interval:
359 """Return a new interval padded by the given amount on both sides."""
360 return Interval(start=self._start - padding, stop=self._stop + padding)
362 def slice_within(self, other: Interval) -> slice:
363 """Return the `slice` that corresponds to the values in this interval
364 when the items of the container being sliced correspond to ``other``.
366 This assumes ``other.contains(self)``.
367 """
368 if not other.contains(self):
369 raise IndexError(
370 f"Can not calculate a slice of {other} within {self} "
371 "since the given interval does not contain this one."
372 )
373 return slice(self.start - other.start, self.stop - other.start)
375 @classmethod
376 def from_legacy(cls, legacy: Any) -> Interval:
377 """Convert from an `lsst.geom.IntervalI` instance."""
378 return cls(legacy.begin, legacy.end)
380 def to_legacy(self) -> Any:
381 """Convert to an `lsst.geom.IntervalI` instance."""
382 from lsst.geom import IntervalI
384 return IntervalI(min=self.min, max=self.max)
386 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
387 return (
388 Interval,
389 (
390 self._start,
391 self._stop,
392 ),
393 )
395 @classmethod
396 def __get_pydantic_core_schema__(
397 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
398 ) -> pcs.CoreSchema:
399 from_typed_dict = pcs.chain_schema(
400 [
401 handler(_SerializedInterval),
402 pcs.no_info_plain_validator_function(cls._validate),
403 ]
404 )
405 return pcs.json_or_python_schema(
406 json_schema=from_typed_dict,
407 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
408 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
409 )
411 @classmethod
412 def _validate(cls, data: _SerializedInterval) -> Interval:
413 return cls(**data)
415 def _serialize(self) -> _SerializedInterval:
416 return {"start": self._start, "stop": self._stop}
419class IntervalSliceFactory:
420 """A factory for `Interval` objects using array-slice syntax.
422 Notes
423 -----
424 When indexed with a single slice on the `Interval.factory` attribute, this
425 returns an `Interval` with exactly the given bounds::
427 assert Interval.factory[3:6] == Interval(start=3, stop=6)
429 A missing start bound is replaced by ``0``, but a missing stop bound is
430 not allowed.
432 When obtained from the `Interval.absolute` property, indices are absolute
433 coordinate values, but any omitted bounds are replaced with the parent
434 interval's bounds::
436 parent = Interval.factory[3:6]
437 assert Interval.factory[4:5] == parent.absolute[:5]
439 The final interval is also checked to be contained by the parent interval.
441 When obtained from the `Interval.local` property, indices are interpreted
442 as relative to the parent interval, and negative indices are relative to
443 the end (like `~collections.abc.Sequence` indexing)::
445 parent = Interval.factory[3:6]
446 assert Interval.factory[4:5] == parent.local[1:-1]
448 When the stop bound is greater than the size of the parent interval, the
449 returned interval is clipped to be contained by the parent (as in
450 `~collections.abc.Sequence` indexing).
451 """
453 def __init__(self, parent: Interval | None = None, is_local: bool = False):
454 self._parent = parent
455 self._is_local = is_local
457 def __getitem__(self, s: slice) -> Interval:
458 if s.step is not None and s.step != 1:
459 raise ValueError(f"Slice {s} has non-unit step.")
460 if self._is_local:
461 assert self._parent is not None, "is_local=True requires a parent interval"
462 start, stop, _ = s.indices(self._parent.size)
463 start += self._parent.start
464 stop += self._parent.start
465 else:
466 start = s.start
467 stop = s.stop
468 if start is None:
469 if self._parent is None:
470 start = 0
471 else:
472 start = self._parent.start
473 if stop is None:
474 if self._parent is None:
475 raise IndexError("An Interval cannot have an empty upper bound.")
476 stop = self._parent.stop
477 if self._parent is not None:
478 if start < self._parent.start:
479 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
480 if stop > self._parent.stop:
481 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
482 return Interval(start=start, stop=stop)
485Interval.factory = IntervalSliceFactory()
488class _SerializedBox(TypedDict):
489 y: _SerializedInterval
490 x: _SerializedInterval
493class Box:
494 """An axis-aligned 2-d rectangular region.
496 Parameters
497 ----------
498 y
499 Interval for the y dimension.
500 x
501 Interval for the x dimension.
503 Notes
504 -----
505 `Box` implements the necessary hooks to be included directly in a
506 `pydantic.BaseModel`, even though it is neither a built-in type nor a
507 Pydantic model itself.
508 """
510 def __init__(self, y: Interval, x: Interval):
511 self._intervals = YX(y, x)
513 __slots__ = ("_intervals",)
515 factory: ClassVar[BoxSliceFactory]
516 """A factory for creating boxes using slice syntax.
518 For example::
520 box = Box.factory[2:5, 3:9]
521 """
523 @classmethod
524 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
525 """Construct a box from its shape and optional start.
527 Parameters
528 ----------
529 shape
530 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
531 start
532 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
533 """
534 if start is None:
535 start = (0,) * len(shape)
536 match shape:
537 case XY(x=x_size, y=y_size):
538 pass
539 case [y_size, x_size]:
540 pass
541 case _:
542 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
543 match start:
544 case XY(x=x_start, y=y_start):
545 pass
546 case [y_start, x_start]:
547 pass
548 case _:
549 raise ValueError(f"Invalid sequence for start: {start!r}.")
550 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
552 @property
553 def start(self) -> YX[int]:
554 """Tuple holding the starts of the intervals ordered ``(y, x)``
555 (`YX` [`int`]).
556 """
557 return YX(self.y.start, self.x.start)
559 @property
560 def shape(self) -> YX[int]:
561 """Tuple holding the sizes of the intervals ordered ``(y, x)``
562 (`YX` [`int`]).
563 """
564 return YX(self.y.size, self.x.size)
566 @property
567 def x(self) -> Interval:
568 """The x-dimension interval (`int`)."""
569 return self._intervals[-1]
571 @property
572 def y(self) -> Interval:
573 """The y-dimension interval (`int`)."""
574 return self._intervals[-2]
576 @property
577 def absolute(self) -> BoxSliceFactory:
578 """A factory for constructing a contained `Box` using slice
579 syntax and absolute coordinates.
581 Notes
582 -----
583 Slice bounds that are absent are replaced with the bounds of ``self``.
584 """
585 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
587 @property
588 def local(self) -> BoxSliceFactory:
589 """A factory for constructing a contained `Interval` using a slice
590 relative to the start of this one (`BoxSliceFactory`).
592 Notes
593 -----
594 This factory interprets slices as "local" coordinates, in which ``0``
595 corresponds to ``self.start``. Negative bounds are relative to
596 ``self.stop``, as is usually the case for Python sequences.
597 """
598 return BoxSliceFactory(y=self.y.local, x=self.x.local)
600 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
601 """Return a pair of 2-d arrays of the coordinate values of the box.
603 Parameters
604 ----------
605 n
606 Number of points in each dimension. If a sequence, points are
607 assumed to be ordered ``(x, y)`` unless a `YX` instance is
608 provided.
609 step
610 Set ``n`` such that the distance between points is equal to or
611 just less than this in each dimension. Mutually exclusive with
612 ``n``.
614 Returns
615 -------
616 `XY` [`numpy.ndarray`]
617 A pair of arrays, each of which is 2-d with floating-point values.
619 See Also
620 --------
621 numpy.meshgrid
622 """
623 if n is not None and step is not None:
624 raise TypeError("'n' and 'step' cannot both be provided.")
625 match n:
626 case int():
627 ax = self.x.linspace(n)
628 ay = self.y.linspace(n)
629 case YX(y=ny, x=nx):
630 ax = self.x.linspace(nx)
631 ay = self.y.linspace(ny)
632 case [nx, ny]:
633 ax = self.x.linspace(nx)
634 ay = self.y.linspace(ny)
635 case None:
636 ax = self.x.linspace(step=step)
637 ay = self.y.linspace(step=step)
638 case _:
639 raise ValueError(f"Unexpected values for n ({n})")
640 return XY(*np.meshgrid(ax, ay))
642 def __eq__(self, other: object) -> bool:
643 if type(other) is Box:
644 return self._intervals == other._intervals
645 return False
647 def __str__(self) -> str:
648 return f"[y={self.y}, x={self.x}]"
650 def __repr__(self) -> str:
651 return f"Box(y={self.y!r}, x={self.x!r})"
653 @overload
654 def contains(self, other: Box, /) -> bool: ... 654 ↛ exitline 654 didn't return from function 'contains' because
656 @overload
657 def contains(self, *, y: int, x: int) -> bool: ... 657 ↛ exitline 657 didn't return from function 'contains' because
659 @overload
660 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 660 ↛ exitline 660 didn't return from function 'contains' because
662 def contains(
663 self,
664 other: Box | None = None,
665 *,
666 y: int | np.ndarray | None = None,
667 x: int | np.ndarray | None = None,
668 ) -> bool | np.ndarray:
669 """Test whether this box fully contains another or one or more points.
671 Parameters
672 ----------
673 other
674 Another box to compare to. Not compatible with the ``y`` and ``x``
675 arguments.
676 y
677 One or more integer Y coordinates to test for containment.
678 If an array, an array of results will be returned.
679 x
680 One or more integer X coordinates to test for containment.
681 If an array, an array of results will be returned.
683 Returns
684 -------
685 `bool` | `numpy.ndarray`
686 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
687 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
688 array with their broadcasted shape.
690 Notes
691 -----
692 In order to yield the desired behavior for floating-point arguments,
693 points are actually tested against an interval that is 0.5 larger on
694 both sides: this makes positions within the outer boundary of pixels
695 (but beyond the centers of those pixels, which have integer positions)
696 appear "on the image".
697 """
698 if other is not None:
699 if x is not None or y is not None:
700 raise TypeError("Too many arguments to 'Box.contain'.")
701 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
702 elif x is None or y is None:
703 raise TypeError("Not enough arguments to 'Box.contain'.")
704 else:
705 result = np.logical_and(self.x.contains(x), self.y.contains(y))
706 if not result.shape:
707 return bool(result)
708 return result
710 def intersection(self, other: Box) -> Box | None:
711 """Return a box that is contained by both ``self`` and ``other``.
713 When there is no overlap between the boxes, `None` is returned.
714 """
715 intervals = []
716 for a, b in zip(self._intervals, other._intervals, strict=True):
717 if (r := a.intersection(b)) is None:
718 return None
719 intervals.append(r)
720 return Box(*intervals)
722 def dilated_by(self, padding: int) -> Box:
723 """Return a new box padded by the given amount on all sides."""
724 return Box(*[i.dilated_by(padding) for i in self._intervals])
726 def slice_within(self, other: Box) -> YX[slice]:
727 """Return a `tuple` of `slice` objects that correspond to the
728 positions in this box when the items of the container being sliced
729 correspond to ``other``.
731 This assumes ``other.contains(self)``.
732 """
733 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
735 def boundary(self) -> Iterator[YX[int]]:
736 """Iterate over the corners of the box as ``(y, x)`` tuples."""
737 if len(self._intervals) != 2:
738 raise TypeError("Box is not 2-d.")
739 yield YX(self.y.min, self.x.min)
740 yield YX(self.y.min, self.x.max)
741 yield YX(self.y.max, self.x.max)
742 yield YX(self.y.max, self.x.min)
744 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
745 return (Box, self._intervals)
747 @classmethod
748 def from_legacy(cls, legacy: Any) -> Box:
749 """Convert from an `lsst.geom.Box2I` instance."""
750 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
752 def to_legacy(self) -> Any:
753 """Convert to an `lsst.geom.BoxI` instance."""
754 from lsst.geom import Box2I
756 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
758 @classmethod
759 def __get_pydantic_core_schema__(
760 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
761 ) -> pcs.CoreSchema:
762 from_typed_dict = pcs.chain_schema(
763 [
764 handler(_SerializedBox),
765 pcs.no_info_plain_validator_function(cls._validate),
766 ]
767 )
768 return pcs.json_or_python_schema(
769 json_schema=from_typed_dict,
770 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
771 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
772 )
774 @classmethod
775 def _validate(cls, data: _SerializedBox) -> Box:
776 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
778 def _serialize(self) -> _SerializedBox:
779 return {"y": self.y._serialize(), "x": self.x._serialize()}
781 def serialize(self) -> Box:
782 """Return a Pydantic-friendly representation of this object.
784 This method just returns the `Box` itself, since that already provides
785 Pydantic serialization hooks. It exists for compatibility with the
786 `Bounds` protocol.
787 """
788 return self
790 @classmethod
791 def deserialize(cls, serialized: SerializableBounds) -> Box:
792 """Deserialize a bounds object on the assumption it is a `Box`.
794 This method just returns the `Box` itself, since that already provides
795 Pydantic serialization hooks. It exists for compatibility with the
796 `Bounds` protocol.
797 """
798 assert isinstance(serialized, Box)
799 return serialized
802class BoxSliceFactory:
803 """A factory for `Box` objects using array-slice syntax.
805 Notes
806 -----
807 When `Box.factory` is indexed with a pair of slices, this returns a
808 `Box` with exactly those bounds::
810 assert (
811 Box.factory[3:6, -1:2]
812 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
813 )
815 A missing start bound is replaced by ``0``, but a missing stop bound is
816 not allowed.
818 When obtained from the `Box.absolute` property, indices are absolute
819 coordinate values, but any omitted bounds are replaced with the parent
820 box's bounds::
822 parent = Box.factory[3:6, -1:2]
823 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
825 The final box is also checked to be contained by the parent box.
827 When obtained from the `Box.local` property, indices are interpreted
828 as relative to the parent box, and negative indices are relative to
829 the end (like `~collections.abc.Sequence` indexing)::
831 parent = Box.factory[3:6, -1:2]
832 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
833 """
835 def __init__(
836 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
837 ):
838 self._y = y
839 self._x = x
841 def __getitem__(self, key: tuple[slice, slice]) -> Box:
842 match key:
843 case XY(x=x, y=y):
844 return Box(y=self._y[y], x=self._x[x])
845 case (y, x):
846 return Box(y=self._y[y], x=self._x[x])
847 case _:
848 raise TypeError("Expected exactly two slices.")
851Box.factory = BoxSliceFactory()
854class Bounds(Protocol):
855 """A protocol for objects that represent the validity region for a function
856 defined in 2-d pixel coordinates.
858 Notes
859 -----
860 Most objects natively have a simple 2-d bounding box as their bounds
861 (typically the boundary of a sensor), and the `Box` class is hence the
862 most common bounds implementation. But sometimes a large chunk of that
863 box may be missing due to vignetting or bad amplifiers, and we may want to
864 transform from one coordinate system to another. The Bounds interface is
865 intended to handle both of these cases as well.
866 """
868 def boundary(self) -> Iterator[YX[int]]:
869 """Iterate over points on the boundary as ``(y, x)`` tuples."""
870 ...
872 @overload
873 def contains(self, *, x: int, y: int) -> bool: ... 873 ↛ exitline 873 didn't return from function 'contains' because
875 @overload
876 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 876 ↛ exitline 876 didn't return from function 'contains' because
878 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
879 """Test whether this box fully contains another or one or more points.
881 Parameters
882 ----------
883 x
884 One or more integer X coordinates to test for containment.
885 If an array, an array of results will be returned.
886 y
887 One or more integer Y coordinates to test for containment.
888 If an array, an array of results will be returned.
890 Returns
891 -------
892 `bool` | `numpy.ndarray`
893 If ``x`` and ``y`` are both scalars, a single `bool` value. If
894 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
895 shape.
896 """
897 ...
899 def serialize(self) -> SerializableBounds:
900 """Convert a bounds instance into a serializable object."""
901 ...
903 @classmethod
904 def deserialize(cls, serialized: SerializableBounds) -> Self:
905 """Convert a serialized bounds object into its in-memory form."""
906 from ._concrete_bounds import deserialize_bounds
908 return cast(Self, deserialize_bounds(serialized))
911class BoundsError(ValueError):
912 """Exception raised when an object is evaluated outside its bounds."""