Coverage for python/lsst/images/_geom.py: 40%
350 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 09:00 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-05-30 09:00 +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
43from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue
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 padded(self, padding: int) -> Interval:
291 """Return a new interval expanded by the given padding on
292 either side.
293 """
294 return Interval(self.start - padding, self.stop + padding)
296 def __str__(self) -> str:
297 return f"{self.start}:{self.stop}"
299 def __repr__(self) -> str:
300 return f"Interval(start={self.start}, stop={self.stop})"
302 def __eq__(self, other: object) -> bool:
303 if type(other) is Interval:
304 return self._start == other._start and self._stop == other._stop
305 return False
307 def __add__(self, other: int) -> Interval:
308 return Interval(start=self.start + other, stop=self.stop + other)
310 def __sub__(self, other: int) -> Interval:
311 return Interval(start=self.start - other, stop=self.stop - other)
313 def __contains__(self, x: int) -> bool:
314 return x >= self.start and x < self.stop
316 @overload
317 def contains(self, other: Interval | int | float) -> bool: ... 317 ↛ exitline 317 didn't return from function 'contains' because
319 @overload
320 def contains(self, other: np.ndarray) -> np.ndarray: ... 320 ↛ exitline 320 didn't return from function 'contains' because
322 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
323 """Test whether this interval fully contains another or one or more
324 points.
326 Parameters
327 ----------
328 other
329 Another interval to compare to, or one or more position values.
331 Returns
332 -------
333 `bool` | `numpy.ndarray`
334 If a single interval or value was passed, a single `bool`. If an
335 array was passed, an array with the same shape.
337 Notes
338 -----
339 In order to yield the desired behavior for floating-point arguments,
340 points are actually tested against an interval that is 0.5 larger on
341 both sides: this makes positions within the outer boundary of pixels
342 (but beyond the centers of those pixels, which have integer positions)
343 appear "on the image".
344 """
345 if isinstance(other, Interval):
346 return self.start <= other.start and self.stop >= other.stop
347 else:
348 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5)
349 if not result.shape:
350 return bool(result)
351 return result
353 def intersection(self, other: Interval) -> Interval:
354 """Return an interval that is contained by both ``self`` and ``other``.
356 When there is no overlap between the intervals, `NoOverlapError` is
357 raised.
358 """
359 new_start = max(self.start, other.start)
360 new_stop = min(self.stop, other.stop)
361 if new_start < new_stop:
362 return Interval(start=new_start, stop=new_stop)
363 raise NoOverlapError(f"No overlap between {self} and {other}.")
365 def dilated_by(self, padding: int) -> Interval:
366 """Return a new interval padded by the given amount on both sides."""
367 return Interval(start=self._start - padding, stop=self._stop + padding)
369 def slice_within(self, other: Interval) -> slice:
370 """Return the `slice` that corresponds to the values in this interval
371 when the items of the container being sliced correspond to ``other``.
373 This assumes ``other.contains(self)``.
374 """
375 if not other.contains(self):
376 raise IndexError(
377 f"Can not calculate a slice of {other} within {self} "
378 "since the given interval does not contain this one."
379 )
380 return slice(self.start - other.start, self.stop - other.start)
382 @classmethod
383 def from_legacy(cls, legacy: Any) -> Interval:
384 """Convert from an `lsst.geom.IntervalI` instance."""
385 return cls(legacy.begin, legacy.end)
387 def to_legacy(self) -> Any:
388 """Convert to an `lsst.geom.IntervalI` instance."""
389 from lsst.geom import IntervalI
391 return IntervalI(min=self.min, max=self.max)
393 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
394 return (
395 Interval,
396 (
397 self._start,
398 self._stop,
399 ),
400 )
402 @classmethod
403 def __get_pydantic_core_schema__(
404 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
405 ) -> pcs.CoreSchema:
406 from_typed_dict = pcs.chain_schema(
407 [
408 handler(_SerializedInterval),
409 pcs.no_info_plain_validator_function(cls._validate),
410 ]
411 )
412 return pcs.json_or_python_schema(
413 json_schema=from_typed_dict,
414 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
415 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
416 )
418 @classmethod
419 def __get_pydantic_json_schema__(
420 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
421 ) -> JsonSchemaValue:
422 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
424 @classmethod
425 def _validate(cls, data: _SerializedInterval) -> Interval:
426 return cls(**data)
428 def _serialize(self) -> _SerializedInterval:
429 return {"start": self._start, "stop": self._stop}
432class IntervalSliceFactory:
433 """A factory for `Interval` objects using array-slice syntax.
435 Notes
436 -----
437 When indexed with a single slice on the `Interval.factory` attribute, this
438 returns an `Interval` with exactly the given bounds::
440 assert Interval.factory[3:6] == Interval(start=3, stop=6)
442 A missing start bound is replaced by ``0``, but a missing stop bound is
443 not allowed.
445 When obtained from the `Interval.absolute` property, indices are absolute
446 coordinate values, but any omitted bounds are replaced with the parent
447 interval's bounds::
449 parent = Interval.factory[3:6]
450 assert Interval.factory[4:5] == parent.absolute[:5]
452 The final interval is also checked to be contained by the parent interval.
454 When obtained from the `Interval.local` property, indices are interpreted
455 as relative to the parent interval, and negative indices are relative to
456 the end (like `~collections.abc.Sequence` indexing)::
458 parent = Interval.factory[3:6]
459 assert Interval.factory[4:5] == parent.local[1:-1]
461 When the stop bound is greater than the size of the parent interval, the
462 returned interval is clipped to be contained by the parent (as in
463 `~collections.abc.Sequence` indexing).
464 """
466 def __init__(self, parent: Interval | None = None, is_local: bool = False):
467 self._parent = parent
468 self._is_local = is_local
470 def __getitem__(self, s: slice) -> Interval:
471 if s.step is not None and s.step != 1:
472 raise ValueError(f"Slice {s} has non-unit step.")
473 if self._is_local:
474 assert self._parent is not None, "is_local=True requires a parent interval"
475 start, stop, _ = s.indices(self._parent.size)
476 start += self._parent.start
477 stop += self._parent.start
478 else:
479 start = s.start
480 stop = s.stop
481 if start is None:
482 if self._parent is None:
483 start = 0
484 else:
485 start = self._parent.start
486 if stop is None:
487 if self._parent is None:
488 raise IndexError("An Interval cannot have an empty upper bound.")
489 stop = self._parent.stop
490 if self._parent is not None:
491 if start < self._parent.start:
492 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
493 if stop > self._parent.stop:
494 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
495 return Interval(start=start, stop=stop)
498Interval.factory = IntervalSliceFactory()
501class _SerializedBox(TypedDict):
502 y: _SerializedInterval
503 x: _SerializedInterval
506class Box:
507 """An axis-aligned 2-d rectangular region.
509 Parameters
510 ----------
511 y
512 Interval for the y dimension.
513 x
514 Interval for the x dimension.
516 Notes
517 -----
518 `Box` implements the necessary hooks to be included directly in a
519 `pydantic.BaseModel`, even though it is neither a built-in type nor a
520 Pydantic model itself.
521 """
523 def __init__(self, y: Interval, x: Interval):
524 self._intervals = YX(y, x)
526 __slots__ = ("_intervals",)
528 factory: ClassVar[BoxSliceFactory]
529 """A factory for creating boxes using slice syntax.
531 For example::
533 box = Box.factory[2:5, 3:9]
534 """
536 @classmethod
537 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
538 """Construct a box from its shape and optional start.
540 Parameters
541 ----------
542 shape
543 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
544 start
545 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
546 """
547 if start is None:
548 start = (0,) * len(shape)
549 match shape:
550 case XY(x=x_size, y=y_size):
551 pass
552 case [y_size, x_size]:
553 pass
554 case _:
555 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
556 match start:
557 case XY(x=x_start, y=y_start):
558 pass
559 case [y_start, x_start]:
560 pass
561 case _:
562 raise ValueError(f"Invalid sequence for start: {start!r}.")
563 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
565 @property
566 def start(self) -> YX[int]:
567 """Tuple holding the starts of the intervals ordered ``(y, x)``
568 (`YX` [`int`]).
569 """
570 return YX(self.y.start, self.x.start)
572 @property
573 def shape(self) -> YX[int]:
574 """Tuple holding the sizes of the intervals ordered ``(y, x)``
575 (`YX` [`int`]).
576 """
577 return YX(self.y.size, self.x.size)
579 @property
580 def x(self) -> Interval:
581 """The x-dimension interval (`int`)."""
582 return self._intervals[-1]
584 @property
585 def y(self) -> Interval:
586 """The y-dimension interval (`int`)."""
587 return self._intervals[-2]
589 @property
590 def absolute(self) -> BoxSliceFactory:
591 """A factory for constructing a contained `Box` using slice
592 syntax and absolute coordinates.
594 Notes
595 -----
596 Slice bounds that are absent are replaced with the bounds of ``self``.
597 """
598 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
600 @property
601 def local(self) -> BoxSliceFactory:
602 """A factory for constructing a contained `Interval` using a slice
603 relative to the start of this one (`BoxSliceFactory`).
605 Notes
606 -----
607 This factory interprets slices as "local" coordinates, in which ``0``
608 corresponds to ``self.start``. Negative bounds are relative to
609 ``self.stop``, as is usually the case for Python sequences.
610 """
611 return BoxSliceFactory(y=self.y.local, x=self.x.local)
613 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
614 """Return a pair of 2-d arrays of the coordinate values of the box.
616 Parameters
617 ----------
618 n
619 Number of points in each dimension. If a sequence, points are
620 assumed to be ordered ``(x, y)`` unless a `YX` instance is
621 provided.
622 step
623 Set ``n`` such that the distance between points is equal to or
624 just less than this in each dimension. Mutually exclusive with
625 ``n``.
627 Returns
628 -------
629 `XY` [`numpy.ndarray`]
630 A pair of arrays, each of which is 2-d with floating-point values.
632 See Also
633 --------
634 numpy.meshgrid
635 """
636 if n is not None and step is not None:
637 raise TypeError("'n' and 'step' cannot both be provided.")
638 match n:
639 case int():
640 ax = self.x.linspace(n)
641 ay = self.y.linspace(n)
642 case YX(y=ny, x=nx):
643 ax = self.x.linspace(nx)
644 ay = self.y.linspace(ny)
645 case [nx, ny]:
646 ax = self.x.linspace(nx)
647 ay = self.y.linspace(ny)
648 case None:
649 ax = self.x.linspace(step=step)
650 ay = self.y.linspace(step=step)
651 case _:
652 raise ValueError(f"Unexpected values for n ({n})")
653 return XY(*np.meshgrid(ax, ay))
655 def padded(self, padding: int) -> Box:
656 """Return a new box expanded by the given padding on
657 all sides.
658 """
659 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
661 def __eq__(self, other: object) -> bool:
662 if type(other) is Box:
663 return self._intervals == other._intervals
664 return False
666 def __str__(self) -> str:
667 return f"[y={self.y}, x={self.x}]"
669 def __repr__(self) -> str:
670 return f"Box(y={self.y!r}, x={self.x!r})"
672 @overload
673 def contains(self, other: Box, /) -> bool: ... 673 ↛ exitline 673 didn't return from function 'contains' because
675 @overload
676 def contains(self, *, y: int, x: int) -> bool: ... 676 ↛ exitline 676 didn't return from function 'contains' because
678 @overload
679 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 679 ↛ exitline 679 didn't return from function 'contains' because
681 def contains(
682 self,
683 other: Box | None = None,
684 *,
685 y: int | np.ndarray | None = None,
686 x: int | np.ndarray | None = None,
687 ) -> bool | np.ndarray:
688 """Test whether this box fully contains another or one or more points.
690 Parameters
691 ----------
692 other
693 Another box to compare to. Not compatible with the ``y`` and ``x``
694 arguments.
695 y
696 One or more integer Y coordinates to test for containment.
697 If an array, an array of results will be returned.
698 x
699 One or more integer X coordinates to test for containment.
700 If an array, an array of results will be returned.
702 Returns
703 -------
704 `bool` | `numpy.ndarray`
705 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
706 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
707 array with their broadcasted shape.
709 Notes
710 -----
711 In order to yield the desired behavior for floating-point arguments,
712 points are actually tested against an interval that is 0.5 larger on
713 both sides: this makes positions within the outer boundary of pixels
714 (but beyond the centers of those pixels, which have integer positions)
715 appear "on the image".
716 """
717 if other is not None:
718 if x is not None or y is not None:
719 raise TypeError("Too many arguments to 'Box.contains'.")
720 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
721 elif x is None or y is None:
722 raise TypeError("Not enough arguments to 'Box.contains'.")
723 else:
724 result = np.logical_and(self.x.contains(x), self.y.contains(y))
725 if not result.shape:
726 return bool(result)
727 return result
729 @overload
730 def intersection(self, other: Box) -> Box: ... 730 ↛ exitline 730 didn't return from function 'intersection' because
732 @overload
733 def intersection(self, other: Bounds) -> Bounds: ... 733 ↛ exitline 733 didn't return from function 'intersection' because
735 def intersection(self, other: Bounds) -> Bounds:
736 """Return a bounds object that is contained by both ``self`` and
737 ``other``.
739 When there is no overlap, `NoOverlapError` is raised.
740 """
741 from ._concrete_bounds import _intersect_box
743 return _intersect_box(self, other)
745 def dilated_by(self, padding: int) -> Box:
746 """Return a new box padded by the given amount on all sides."""
747 return Box(*[i.dilated_by(padding) for i in self._intervals])
749 def slice_within(self, other: Box) -> YX[slice]:
750 """Return a `tuple` of `slice` objects that correspond to the
751 positions in this box when the items of the container being sliced
752 correspond to ``other``.
754 This assumes ``other.contains(self)``.
755 """
756 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
758 @property
759 def bbox(self) -> Box:
760 """The box itself (`Box`).
762 This is provided for compatibility with the `Bounds` interface.
763 """
764 return self
766 def boundary(self) -> Iterator[YX[int]]:
767 """Iterate over the corners of the box as ``(y, x)`` tuples."""
768 if len(self._intervals) != 2:
769 raise TypeError("Box is not 2-d.")
770 yield YX(self.y.min, self.x.min)
771 yield YX(self.y.min, self.x.max)
772 yield YX(self.y.max, self.x.max)
773 yield YX(self.y.max, self.x.min)
775 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
776 return (Box, self._intervals)
778 @classmethod
779 def from_legacy(cls, legacy: Any) -> Box:
780 """Convert from an `lsst.geom.Box2I` instance."""
781 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
783 def to_legacy(self) -> Any:
784 """Convert to an `lsst.geom.BoxI` instance."""
785 from lsst.geom import Box2I
787 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
789 @classmethod
790 def __get_pydantic_core_schema__(
791 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
792 ) -> pcs.CoreSchema:
793 from_typed_dict = pcs.chain_schema(
794 [
795 handler(_SerializedBox),
796 pcs.no_info_plain_validator_function(cls._validate),
797 ]
798 )
799 return pcs.json_or_python_schema(
800 json_schema=from_typed_dict,
801 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
802 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
803 )
805 @classmethod
806 def __get_pydantic_json_schema__(
807 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
808 ) -> JsonSchemaValue:
809 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
811 @classmethod
812 def _validate(cls, data: _SerializedBox) -> Box:
813 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
815 def _serialize(self) -> _SerializedBox:
816 return {"y": self.y._serialize(), "x": self.x._serialize()}
818 def serialize(self) -> Box:
819 """Return a Pydantic-friendly representation of this object.
821 This method just returns the `Box` itself, since that already provides
822 Pydantic serialization hooks. It exists for compatibility with the
823 `Bounds` protocol.
824 """
825 return self
827 def deserialize(self) -> Box:
828 """Deserialize a bounds object on the assumption it is a `Box`.
830 This method just returns the `Box` itself, since that already provides
831 Pydantic serialization hooks. It exists for compatibility with the
832 `Bounds` protocol.
833 """
834 return self
837class BoxSliceFactory:
838 """A factory for `Box` objects using array-slice syntax.
840 Notes
841 -----
842 When `Box.factory` is indexed with a pair of slices, this returns a
843 `Box` with exactly those bounds::
845 assert (
846 Box.factory[3:6, -1:2]
847 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
848 )
850 A missing start bound is replaced by ``0``, but a missing stop bound is
851 not allowed.
853 When obtained from the `Box.absolute` property, indices are absolute
854 coordinate values, but any omitted bounds are replaced with the parent
855 box's bounds::
857 parent = Box.factory[3:6, -1:2]
858 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
860 The final box is also checked to be contained by the parent box.
862 When obtained from the `Box.local` property, indices are interpreted
863 as relative to the parent box, and negative indices are relative to
864 the end (like `~collections.abc.Sequence` indexing)::
866 parent = Box.factory[3:6, -1:2]
867 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
868 """
870 def __init__(
871 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
872 ):
873 self._y = y
874 self._x = x
876 def __getitem__(self, key: tuple[slice, slice]) -> Box:
877 match key:
878 case XY(x=x, y=y):
879 return Box(y=self._y[y], x=self._x[x])
880 case (y, x):
881 return Box(y=self._y[y], x=self._x[x])
882 case _:
883 raise TypeError("Expected exactly two slices.")
886Box.factory = BoxSliceFactory()
889class Bounds(Protocol):
890 """A protocol for objects that represent the validity region for a function
891 defined in 2-d pixel coordinates.
893 Notes
894 -----
895 Most objects natively have a simple 2-d bounding box as their bounds
896 (typically the boundary of a sensor), and the `Box` class is hence the
897 most common bounds implementation. But sometimes a large chunk of that
898 box may be missing due to vignetting or bad amplifiers, and we may want to
899 transform from one coordinate system to another. The Bounds interface is
900 intended to handle both of these cases as well.
901 """
903 @property
904 def bbox(self) -> Box: ... 904 ↛ exitline 904 didn't return from function 'bbox' because
906 @overload
907 def contains(self, *, x: int, y: int) -> bool: ... 907 ↛ exitline 907 didn't return from function 'contains' because
909 @overload
910 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 910 ↛ exitline 910 didn't return from function 'contains' because
912 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
913 """Test whether this box fully contains another or one or more points.
915 Parameters
916 ----------
917 x
918 One or more integer X coordinates to test for containment.
919 If an array, an array of results will be returned.
920 y
921 One or more integer Y coordinates to test for containment.
922 If an array, an array of results will be returned.
924 Returns
925 -------
926 `bool` | `numpy.ndarray`
927 If ``x`` and ``y`` are both scalars, a single `bool` value. If
928 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
929 shape.
930 """
931 ...
933 def intersection(self, other: Bounds) -> Bounds:
934 """Compute the intersection of this bounds object with another."""
935 ...
937 def serialize(self) -> SerializableBounds:
938 """Convert a bounds instance into a serializable object.
940 Notes
941 -----
942 The returned object must support direct nesting with Pydantic models
943 and have a ``deserialize`` method (taking no arguments) that converts
944 back to this `Bounds` type. It is common for `serialize` and
945 ``deserialize`` to just return ``self``, when the bounds object is
946 natively serializable.
947 """
948 ...
951class BoundsError(ValueError):
952 """Exception raised when an object is evaluated outside its bounds."""
955class NoOverlapError(ValueError):
956 """Exception raised when intervals or bounds do not overlap."""