Coverage for python / lsst / images / _geom.py: 38%
341 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 00:49 -0800
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-03 00:49 -0800
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14__all__ = (
15 "XY",
16 "YX",
17 "Bounds",
18 "Box",
19 "BoxSliceFactory",
20 "Interval",
21 "IntervalSliceFactory",
22 "SerializableBounds",
23)
25import math
26from collections.abc import Callable, Iterator, Sequence
27from typing import Any, ClassVar, NamedTuple, Protocol, Self, TypedDict, TypeVar, final, overload
29import numpy as np
30import pydantic
31import pydantic_core.core_schema as pcs
33# This pre-python-3.12 declaration is needed by Sphinx (probably the
34# autodoc-typehints plugin.
35T = TypeVar("T")
37# Interval and Box are defined as regular Python classes rather than
38# dataclasses or Pydantic models because we might want to implement them as
39# compiled-extension types in the future, and we want that to be transparent.
41# In a similar vein, we avoid declaring specific types for multidimensional
42# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
43# shapes) in order to leave room for more fully-featured types to be added
44# upstream of this package in the future.
47class YX[T](NamedTuple):
48 """A pair of per-dimension objects, ordered ``(y, x)``.
50 Notes
51 -----
52 `YX` is used for slices, shapes, and other 2-d pairs when the most
53 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
54 arithmetic operations behave as they would on a
55 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
56 concatenates).
58 See Also
59 --------
60 XY
61 """
63 y: T
64 """The y / row object."""
66 x: T
67 """The x / column object."""
69 @property
70 def xy(self) -> XY:
71 """A tuple of the same objects in the opposite order."""
72 return XY(x=self.x, y=self.y)
74 def map[U](self, func: Callable[[T], U]) -> YX[U]:
75 """Apply a function to both objects."""
76 return YX(y=func(self.y), x=func(self.x))
79class XY[T](NamedTuple):
80 """A pair of per-dimension objects, ordered ``(x, y)``.
82 Notes
83 -----
84 `XY` is used for points and other 2-d pairs when the most natural ordering
85 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
86 behave as they would on a `collections.abc.Sequence`, not a mathematical
87 vector (e.g. adding concatenates).
89 See Also
90 --------
91 YX
92 """
94 x: T
95 """The x / column object."""
97 y: T
98 """The y / row object."""
100 @property
101 def yx(self) -> YX:
102 """A tuple of the same objects in the opposite order."""
103 return YX(y=self.y, x=self.x)
105 def map[U](self, func: Callable[[T], U]) -> XY[U]:
106 """Apply a function to both objects."""
107 return XY(x=func(self.x), y=func(self.y))
110class _SerializedInterval(TypedDict):
111 start: int
112 stop: int
115@final
116class Interval:
117 """A 1-d integer interval with positive size.
119 Parameters
120 ----------
121 start
122 Inclusive minimum point in the interval.
123 stop
124 One past the maximum point in the interval.
126 Notes
127 -----
128 Adding or subtracting an `int` from an interval returns a shifted interval.
130 `Interval` implements the necessary hooks to be included directly in a
131 `pydantic.BaseModel`, even though it is neither a built-in type nor a
132 Pydantic model itself.
133 """
135 def __init__(self, start: int, stop: int):
136 # Coerce to be defensive against numpy int scalars.
137 self._start = int(start)
138 self._stop = int(stop)
139 if not (self._stop > self._start):
140 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
142 __slots__ = ("_start", "_stop")
144 factory: ClassVar[IntervalSliceFactory]
145 """A factory for creating intervals using slice syntax.
147 For example::
149 interval = Interval.factory[2:5]
150 """
152 @classmethod
153 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
154 """Construct an interval that includes all of the given points and/or
155 intervals.
156 """
157 if type(first) is Interval:
158 rmin = first.min
159 rmax = first.max
160 else:
161 rmin = rmax = first
162 for arg in args:
163 if type(arg) is Interval:
164 rmin = min(rmin, arg.min)
165 rmax = max(rmax, arg.max)
166 else:
167 rmin = min(rmin, arg)
168 rmax = max(rmax, arg)
169 return Interval(start=rmin, stop=rmax + 1)
171 @classmethod
172 def from_size(cls, size: int, start: int = 0) -> Interval:
173 """Construct an interval from its size and optional start."""
174 return cls(start=start, stop=start + size)
176 @property
177 def start(self) -> int:
178 """Inclusive minimum point in the interval (`int`)."""
179 return self._start
181 @property
182 def stop(self) -> int:
183 """One past the maximum point in the interval (`int`)."""
184 return self._stop
186 @property
187 def min(self) -> int:
188 """Inclusive minimum point in the interval (`int`)."""
189 return self.start
191 @property
192 def max(self) -> int:
193 """Inclusive maximum point in the interval (`int`)."""
194 return self.stop - 1
196 @property
197 def size(self) -> int:
198 """Size of the interval (`int`)."""
199 return self.stop - self.start
201 @property
202 def range(self) -> __builtins__.range:
203 """An iterable over all values in the interval
204 (`__builtins__.range`).
205 """
206 return range(self.start, self.stop)
208 @property
209 def arange(self) -> np.ndarray:
210 """An array of all the values in the interval (`numpy.ndarray`).
212 Array values are integers.
213 """
214 return np.arange(self.start, self.stop)
216 @property
217 def absolute(self) -> IntervalSliceFactory:
218 """A factory for constructing a contained `Interval` using slice
219 syntax and absolute coordinates.
221 Notes
222 -----
223 Slice bounds that are absent are replaced with the bounds of ``self``.
224 """
225 return IntervalSliceFactory(self, is_local=False)
227 @property
228 def local(self) -> IntervalSliceFactory:
229 """A factory for constructing a contained `Interval` using a slice
230 relative to the start of this one (`IntervalSliceFactory`).
232 Notes
233 -----
234 This factory interprets slices as "local" coordinates, in which ``0``
235 corresponds to ``self.start``. Negative bounds are relative to
236 ``self.stop``, as is usually the case for Python sequences.
237 """
238 return IntervalSliceFactory(self, is_local=True)
240 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
241 """Return an array of values that spans the interval.
243 Parameters
244 ----------
245 n
246 How many values to return. The default (if ``step`` is also not
247 provided) is the size of the interval, i.e. equivalent to the
248 `arange` property (but converted to `float`).
249 step
250 Set ``n`` such that the distance between points is equal to or
251 just less than this. Mutually exclusive with ``n``.
253 Returns
254 -------
255 numpy.ndarray
256 Array of `float` values.
258 See Also
259 --------
260 numpy.linspace
261 """
262 if n is None:
263 if step is None:
264 return self.arange.astype(np.float64)
265 n = math.ceil(self.size / step)
266 elif step is not None:
267 raise TypeError("'n' and 'step' cannot both be provided.")
268 return np.linspace(self.min, self.max, n, dtype=np.float64)
270 @property
271 def center(self) -> float:
272 """The center of the interval (`float`)."""
273 return 0.5 * (self.min + self.max)
275 def __str__(self) -> str:
276 return f"{self.start}:{self.stop}"
278 def __repr__(self) -> str:
279 return f"Interval(start={self.start}, stop={self.stop})"
281 def __eq__(self, other: object) -> bool:
282 if type(other) is Interval:
283 return self._start == other._start and self._stop == other._stop
284 return False
286 def __add__(self, other: int) -> Interval:
287 return Interval(start=self.start + other, stop=self.stop + other)
289 def __sub__(self, other: int) -> Interval:
290 return Interval(start=self.start - other, stop=self.stop - other)
292 def __contains__(self, x: int) -> bool:
293 return x >= self.start and x < self.stop
295 @overload
296 def contains(self, other: Interval | int | float) -> bool: ... 296 ↛ exitline 296 didn't return from function 'contains' because
298 @overload
299 def contains(self, other: np.ndarray) -> np.ndarray: ... 299 ↛ exitline 299 didn't return from function 'contains' because
301 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
302 """Test whether this interval fully contains another or one or more
303 points.
305 Parameters
306 ----------
307 other
308 Another interval to compare to, or one or more position values.
310 Returns
311 -------
312 `bool` | `numpy.ndarray`
313 If a single interval or value was passed, a single `bool`. If an
314 array was passed, an array with the same shape.
316 Notes
317 -----
318 In order to yield the desired behavior for floating-point arguments,
319 points are actually tested against an interval that is 0.5 larger on
320 both sides: this makes positions within the outer boundary of pixels
321 (but beyond the centers of those pixels, which have integer positions)
322 appear "on the image".
323 """
324 if isinstance(other, Interval):
325 return self.start <= other.start and self.stop >= other.stop
326 else:
327 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5)
328 if not result.shape:
329 return bool(result)
330 return result
332 def intersection(self, other: Interval) -> Interval | None:
333 """Return an interval that is contained by both ``self`` and ``other``.
335 When there is no overlap between the intervals, `None` is returned.
336 """
337 new_start = max(self.start, other.start)
338 new_stop = min(self.stop, other.stop)
339 if new_start < new_stop:
340 return Interval(start=new_start, stop=new_stop)
341 return None
343 def dilated_by(self, padding: int) -> Interval:
344 """Return a new interval padded by the given amount on both sides."""
345 return Interval(start=self._start - padding, stop=self._stop + padding)
347 def slice_within(self, other: Interval) -> slice:
348 """Return the `slice` that corresponds to the values in this interval
349 when the items of the container being sliced correspond to ``other``.
351 This assumes ``other.contains(self)``.
352 """
353 if not other.contains(self):
354 raise IndexError(
355 f"Can not calculate a slice of {other} within {self} "
356 "since the given interval does not contain this one."
357 )
358 return slice(self.start - other.start, self.stop - other.start)
360 @classmethod
361 def from_legacy(cls, legacy: Any) -> Interval:
362 """Convert from an `lsst.geom.IntervalI` instance."""
363 return cls(legacy.begin, legacy.end)
365 def to_legacy(self) -> Any:
366 """Convert to an `lsst.geom.IntervalI` instance."""
367 from lsst.geom import IntervalI
369 return IntervalI(min=self.min, max=self.max)
371 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
372 return (
373 Interval,
374 (
375 self._start,
376 self._stop,
377 ),
378 )
380 @classmethod
381 def __get_pydantic_core_schema__(
382 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
383 ) -> pcs.CoreSchema:
384 from_typed_dict = pcs.chain_schema(
385 [
386 handler(_SerializedInterval),
387 pcs.no_info_plain_validator_function(cls._validate),
388 ]
389 )
390 return pcs.json_or_python_schema(
391 json_schema=from_typed_dict,
392 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
393 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
394 )
396 @classmethod
397 def _validate(cls, data: _SerializedInterval) -> Interval:
398 return cls(**data)
400 def _serialize(self) -> _SerializedInterval:
401 return {"start": self._start, "stop": self._stop}
404class IntervalSliceFactory:
405 """A factory for `Interval` objects using array-slice syntax.
407 Notes
408 -----
409 When indexed with a single slice on the `Interval.factory` attribute, this
410 returns an `Interval` with exactly the given bounds::
412 assert Interval.factory[3:6] == Interval(start=3, stop=6)
414 A missing start bound is replaced by ``0``, but a missing stop bound is
415 not allowed.
417 When obtained from the `Interval.absolute` property, indices are absolute
418 coordinate values, but any omitted bounds are replaced with the parent
419 interval's bounds::
421 parent = Interval.factory[3:6]
422 assert Interval.factory[4:5] == parent.absolute[:5]
424 The final interval is also checked to be contained by the parent interval.
426 When obtained from the `Interval.local` property, indices are interpreted
427 as relative to the parent interval, and negative indices are relative to
428 the end (like `~collections.abc.Sequence` indexing)::
430 parent = Interval.factory[3:6]
431 assert Interval.factory[4:5] == parent.local[1:-1]
433 When the stop bound is greater than the size of the parent interval, the
434 returned interval is clipped to be contained by the parent (as in
435 `~collections.abc.Sequence` indexing).
436 """
438 def __init__(self, parent: Interval | None = None, is_local: bool = False):
439 self._parent = parent
440 self._is_local = is_local
442 def __getitem__(self, s: slice) -> Interval:
443 if s.step is not None and s.step != 1:
444 raise ValueError(f"Slice {s} has non-unit step.")
445 if self._is_local:
446 assert self._parent is not None, "is_local=True requires a parent interval"
447 start, stop, _ = s.indices(self._parent.size)
448 start += self._parent.start
449 stop += self._parent.start
450 else:
451 start = s.start
452 stop = s.stop
453 if start is None:
454 if self._parent is None:
455 start = 0
456 else:
457 start = self._parent.start
458 if stop is None:
459 if self._parent is None:
460 raise IndexError("An Interval cannot have an empty upper bound.")
461 stop = self._parent.stop
462 if self._parent is not None:
463 if start < self._parent.start:
464 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
465 if stop > self._parent.stop:
466 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
467 return Interval(start=start, stop=stop)
470Interval.factory = IntervalSliceFactory()
473class _SerializedBox(TypedDict):
474 y: _SerializedInterval
475 x: _SerializedInterval
478class Box:
479 """An axis-aligned 2-d rectangular region.
481 Parameters
482 ----------
483 y
484 Interval for the y dimension.
485 x
486 Interval for the x dimension.
488 Notes
489 -----
490 `Box` implements the necessary hooks to be included directly in a
491 `pydantic.BaseModel`, even though it is neither a built-in type nor a
492 Pydantic model itself.
493 """
495 def __init__(self, y: Interval, x: Interval):
496 self._intervals = YX(y, x)
498 __slots__ = ("_intervals",)
500 factory: ClassVar[BoxSliceFactory]
501 """A factory for creating boxes using slice syntax.
503 For example::
505 box = Box.factory[2:5, 3:9]
506 """
508 @classmethod
509 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
510 """Construct a box from its shape and optional start.
512 Parameters
513 ----------
514 shape
515 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
516 start
517 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
518 """
519 if start is None:
520 start = (0,) * len(shape)
521 match shape:
522 case XY(x=x_size, y=y_size):
523 pass
524 case [y_size, x_size]:
525 pass
526 case _:
527 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
528 match start:
529 case XY(x=x_start, y=y_start):
530 pass
531 case [y_start, x_start]:
532 pass
533 case _:
534 raise ValueError(f"Invalid sequence for start: {start!r}.")
535 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
537 @property
538 def start(self) -> YX[int]:
539 """Tuple holding the starts of the intervals ordered ``(y, x)``
540 (`YX` [`int`]).
541 """
542 return YX(self.y.start, self.x.start)
544 @property
545 def shape(self) -> YX[int]:
546 """Tuple holding the sizes of the intervals ordered ``(y, x)``
547 (`YX` [`int`]).
548 """
549 return YX(self.y.size, self.x.size)
551 @property
552 def x(self) -> Interval:
553 """The x-dimension interval (`int`)."""
554 return self._intervals[-1]
556 @property
557 def y(self) -> Interval:
558 """The y-dimension interval (`int`)."""
559 return self._intervals[-2]
561 @property
562 def absolute(self) -> BoxSliceFactory:
563 """A factory for constructing a contained `Box` using slice
564 syntax and absolute coordinates.
566 Notes
567 -----
568 Slice bounds that are absent are replaced with the bounds of ``self``.
569 """
570 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
572 @property
573 def local(self) -> BoxSliceFactory:
574 """A factory for constructing a contained `Interval` using a slice
575 relative to the start of this one (`BoxSliceFactory`).
577 Notes
578 -----
579 This factory interprets slices as "local" coordinates, in which ``0``
580 corresponds to ``self.start``. Negative bounds are relative to
581 ``self.stop``, as is usually the case for Python sequences.
582 """
583 return BoxSliceFactory(y=self.y.local, x=self.x.local)
585 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
586 """Return a pair of 2-d arrays of the coordinate values of the box.
588 Parameters
589 ----------
590 n
591 Number of points in each dimension. If a sequence, points are
592 assumed to be ordered ``(x, y)`` unless a `YX` instance is
593 provided.
594 step
595 Set ``n`` such that the distance between points is equal to or
596 just less than this in each dimension. Mutually exclusive with
597 ``n``.
599 Returns
600 -------
601 `XY` [`numpy.ndarray`]
602 A pair of arrays, each of which is 2-d with floating-point values.
604 See Also
605 --------
606 numpy.meshgrid
607 """
608 if n is not None and step is not None:
609 raise TypeError("'n' and 'step' cannot both be provided.")
610 match n:
611 case int():
612 ax = self.x.linspace(n)
613 ay = self.y.linspace(n)
614 case YX(y=ny, x=nx):
615 ax = self.x.linspace(nx)
616 ay = self.y.linspace(ny)
617 case [nx, ny]:
618 ax = self.x.linspace(nx)
619 ay = self.y.linspace(ny)
620 case None:
621 ax = self.x.linspace(step=step)
622 ay = self.y.linspace(step=step)
623 case _:
624 raise ValueError(f"Unexpected values for n ({n})")
625 return XY(*np.meshgrid(ax, ay))
627 def __eq__(self, other: object) -> bool:
628 if type(other) is Box:
629 return self._intervals == other._intervals
630 return False
632 def __str__(self) -> str:
633 return f"[y={self.y}, x={self.x}]"
635 def __repr__(self) -> str:
636 return f"Box(y={self.y!r}, x={self.x!r})"
638 @overload
639 def contains(self, other: Box, /) -> bool: ... 639 ↛ exitline 639 didn't return from function 'contains' because
641 @overload
642 def contains(self, *, y: int, x: int) -> bool: ... 642 ↛ exitline 642 didn't return from function 'contains' because
644 @overload
645 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 645 ↛ exitline 645 didn't return from function 'contains' because
647 def contains(
648 self,
649 other: Box | None = None,
650 *,
651 y: int | np.ndarray | None = None,
652 x: int | np.ndarray | None = None,
653 ) -> bool | np.ndarray:
654 """Test whether this box fully contains another or one or more points.
656 Parameters
657 ----------
658 other
659 Another box to compare to. Not compatible with the ``y`` and ``x``
660 arguments.
661 y
662 One or more integer Y coordinates to test for containment.
663 If an array, an array of results will be returned.
664 x
665 One or more integer X coordinates to test for containment.
666 If an array, an array of results will be returned.
668 Returns
669 -------
670 `bool` | `numpy.ndarray`
671 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
672 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
673 array with their broadcasted shape.
675 Notes
676 -----
677 In order to yield the desired behavior for floating-point arguments,
678 points are actually tested against an interval that is 0.5 larger on
679 both sides: this makes positions within the outer boundary of pixels
680 (but beyond the centers of those pixels, which have integer positions)
681 appear "on the image".
682 """
683 if other is not None:
684 if x is not None or y is not None:
685 raise TypeError("Too many arguments to 'Box.contain'.")
686 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
687 elif x is None or y is None:
688 raise TypeError("Not enough arguments to 'Box.contain'.")
689 else:
690 result = np.logical_and(self.x.contains(x), self.y.contains(y))
691 if not result.shape:
692 return bool(result)
693 return result
695 def intersection(self, other: Box) -> Box | None:
696 """Return a box that is contained by both ``self`` and ``other``.
698 When there is no overlap between the boxes, `None` is returned.
699 """
700 intervals = []
701 for a, b in zip(self._intervals, other._intervals, strict=True):
702 if (r := a.intersection(b)) is None:
703 return None
704 intervals.append(r)
705 return Box(*intervals)
707 def dilated_by(self, padding: int) -> Box:
708 """Return a new box padded by the given amount on all sides."""
709 return Box(*[i.dilated_by(padding) for i in self._intervals])
711 def slice_within(self, other: Box) -> YX[slice]:
712 """Return a `tuple` of `slice` objects that correspond to the
713 positions in this box when the items of the container being sliced
714 correspond to ``other``.
716 This assumes ``other.contains(self)``.
717 """
718 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
720 def boundary(self) -> Iterator[YX[int]]:
721 """Iterate over the corners of the box as ``(y, x)`` tuples."""
722 if len(self._intervals) != 2:
723 raise TypeError("Box is not 2-d.")
724 yield YX(self.y.min, self.x.min)
725 yield YX(self.y.min, self.x.max)
726 yield YX(self.y.max, self.x.max)
727 yield YX(self.y.max, self.x.min)
729 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
730 return (Box, self._intervals)
732 @classmethod
733 def from_legacy(cls, legacy: Any) -> Box:
734 """Convert from an `lsst.geom.Box2I` instance."""
735 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
737 def to_legacy(self) -> Any:
738 """Convert to an `lsst.geom.BoxI` instance."""
739 from lsst.geom import Box2I
741 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
743 @classmethod
744 def __get_pydantic_core_schema__(
745 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
746 ) -> pcs.CoreSchema:
747 from_typed_dict = pcs.chain_schema(
748 [
749 handler(_SerializedBox),
750 pcs.no_info_plain_validator_function(cls._validate),
751 ]
752 )
753 return pcs.json_or_python_schema(
754 json_schema=from_typed_dict,
755 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
756 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
757 )
759 @classmethod
760 def _validate(cls, data: _SerializedBox) -> Box:
761 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
763 def _serialize(self) -> _SerializedBox:
764 return {"y": self.y._serialize(), "x": self.x._serialize()}
766 def serialize(self) -> Box:
767 """Return a Pydantic-friendly representation of this object.
769 This method just returns the `Box` itself, since that already provides
770 Pydantic serialization hooks. It exists for compatibility with the
771 `Bounds` protocol.
772 """
773 return self
775 @classmethod
776 def deserialize(cls, serialized: SerializableBounds) -> Box:
777 """Deserialize a bounds object on the assumption it is a `Box`.
779 This method just returns the `Box` itself, since that already provides
780 Pydantic serialization hooks. It exists for compatibility with the
781 `Bounds` protocol.
782 """
783 assert isinstance(serialized, Box)
784 return serialized
787class BoxSliceFactory:
788 """A factory for `Box` objects using array-slice syntax.
790 Notes
791 -----
792 When `Box.factory` is indexed with a pair of slices, this returns a
793 `Box` with exactly those bounds::
795 assert (
796 Box.factory[3:6, -1:2]
797 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
798 )
800 A missing start bound is replaced by ``0``, but a missing stop bound is
801 not allowed.
803 When obtained from the `Box.absolute` property, indices are absolute
804 coordinate values, but any omitted bounds are replaced with the parent
805 box's bounds::
807 parent = Box.factory[3:6, -1:2]
808 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
810 The final box is also checked to be contained by the parent box.
812 When obtained from the `Box.local` property, indices are interpreted
813 as relative to the parent box, and negative indices are relative to
814 the end (like `~collections.abc.Sequence` indexing)::
816 parent = Box.factory[3:6, -1:2]
817 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
818 """
820 def __init__(
821 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
822 ):
823 self._y = y
824 self._x = x
826 def __getitem__(self, key: tuple[slice, slice]) -> Box:
827 match key:
828 case XY(x=x, y=y):
829 return Box(y=self._y[y], x=self._x[x])
830 case (y, x):
831 return Box(y=self._y[y], x=self._x[x])
832 case _:
833 raise TypeError("Expected exactly two slices.")
836Box.factory = BoxSliceFactory()
839# This is expected to become a union of concrete Bounds types that we can
840# serialize via pydantic. Right now that's only Box.
841type SerializableBounds = Box
844class Bounds(Protocol):
845 """A protocol for objects that represent the validity region for a function
846 defined in 2-d pixel coordinates.
848 Notes
849 -----
850 Most objects natively have a simple 2-d bounding box as their bounds
851 (typically the boundary of a sensor), and the `Box` class is hence the
852 most common bounds implementation. But sometimes a large chunk of that
853 box may be missing due to vignetting or bad amplifiers, and we may want to
854 transform from one coordinate system to another. The Bounds interface is
855 intended to handle both of these cases as well.
856 """
858 def boundary(self) -> Iterator[YX[int]]:
859 """Iterate over points on the boundary as ``(y, x)`` tuples."""
860 ...
862 @overload
863 def contains(self, *, x: int, y: int) -> bool: ... 863 ↛ exitline 863 didn't return from function 'contains' because
865 @overload
866 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 866 ↛ exitline 866 didn't return from function 'contains' because
868 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
869 """Test whether this box fully contains another or one or more points.
871 Parameters
872 ----------
873 x
874 One or more integer X coordinates to test for containment.
875 If an array, an array of results will be returned.
876 y
877 One or more integer Y coordinates to test for containment.
878 If an array, an array of results will be returned.
880 Returns
881 -------
882 `bool` | `numpy.ndarray`
883 If ``x`` and ``y`` are both scalars, a single `bool` value. If
884 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
885 shape.
886 """
887 ...
889 def serialize(self) -> SerializableBounds:
890 """Convert a bounds instance into a serializable object."""
891 ...
893 @classmethod
894 def deserialize(cls, serialized: SerializableBounds) -> Self:
895 """Convert a serialized bounds object into its in-memory form."""
896 match serialized:
897 case Box():
898 return serialized # type: ignore[return-value]
899 raise RuntimeError(f"Cannot deserialize {serialized!r}.")