Coverage for python/lsst/images/_geom.py: 94%
375 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 08:25 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-06-25 08:25 +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
45from .utils import round_half_down, round_half_up
47if TYPE_CHECKING:
48 from ._concrete_bounds import SerializableBounds
50 try:
51 from lsst.geom import Extent2I as LegacyExtent2I
52 from lsst.geom import Point2I as LegacyPoint2I
53 except ImportError:
54 type LegacyExtent2I = Any # type: ignore[no-redef]
55 type LegacyPoint2I = Any # type: ignore[no-redef]
57# This pre-python-3.12 declaration is needed by Sphinx (probably the
58# autodoc-typehints plugin.
59T = TypeVar("T")
61# Interval and Box are defined as regular Python classes rather than
62# dataclasses or Pydantic models because we might want to implement them as
63# compiled-extension types in the future, and we want that to be transparent.
65# In a similar vein, we avoid declaring specific types for multidimensional
66# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
67# shapes) in order to leave room for more fully-featured types to be added
68# upstream of this package in the future.
71class YX[T](NamedTuple):
72 """A pair of per-dimension objects, ordered ``(y, x)``.
74 Notes
75 -----
76 `YX` is used for slices, shapes, and other 2-d pairs when the most
77 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
78 arithmetic operations behave as they would on a
79 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
80 concatenates).
82 See Also
83 --------
84 XY
85 """
87 y: T
88 """The y / row object."""
90 x: T
91 """The x / column object."""
93 @property
94 def xy(self) -> XY:
95 """A tuple of the same objects in the opposite order."""
96 return XY(x=self.x, y=self.y)
98 def map[U](self, func: Callable[[T], U]) -> YX[U]:
99 """Apply a function to both objects."""
100 return YX(y=func(self.y), x=func(self.x))
102 def to_legacy_extent(self) -> LegacyExtent2I:
103 """Convert to a legacy `lsst.geom.Extent2I` object."""
104 from lsst.geom import Extent2I as LegacyExtent2I
106 return LegacyExtent2I(self.x, self.y)
108 def to_legacy_point(self) -> LegacyPoint2I:
109 """Convert to a legacy `lsst.geom.Point2I` object."""
110 from lsst.geom import Point2I as LegacyPoint2I
112 return LegacyPoint2I(self.x, self.y)
115class XY[T](NamedTuple):
116 """A pair of per-dimension objects, ordered ``(x, y)``.
118 Notes
119 -----
120 `XY` is used for points and other 2-d pairs when the most natural ordering
121 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
122 behave as they would on a `collections.abc.Sequence`, not a mathematical
123 vector (e.g. adding concatenates).
125 See Also
126 --------
127 YX
128 """
130 x: T
131 """The x / column object."""
133 y: T
134 """The y / row object."""
136 @property
137 def yx(self) -> YX:
138 """A tuple of the same objects in the opposite order."""
139 return YX(y=self.y, x=self.x)
141 def map[U](self, func: Callable[[T], U]) -> XY[U]:
142 """Apply a function to both objects."""
143 return XY(x=func(self.x), y=func(self.y))
145 def to_legacy_extent(self) -> LegacyExtent2I:
146 """Convert to a legacy `lsst.geom.Extent2I` object."""
147 from lsst.geom import Extent2I as LegacyExtent2I
149 return LegacyExtent2I(self.x, self.y)
151 def to_legacy_point(self) -> LegacyPoint2I:
152 """Convert to a legacy `lsst.geom.Point2I` object."""
153 from lsst.geom import Point2I as LegacyPoint2I
155 return LegacyPoint2I(self.x, self.y)
158class _SerializedInterval(TypedDict):
159 start: int
160 stop: int
163@final
164class Interval:
165 """A 1-d integer interval with positive size.
167 Parameters
168 ----------
169 start
170 Inclusive minimum point in the interval.
171 stop
172 One past the maximum point in the interval.
174 Notes
175 -----
176 Adding or subtracting an `int` from an interval returns a shifted interval.
178 `Interval` implements the necessary hooks to be included directly in a
179 `pydantic.BaseModel`, even though it is neither a built-in type nor a
180 Pydantic model itself.
181 """
183 def __init__(self, start: int, stop: int) -> None:
184 # Coerce to be defensive against numpy int scalars.
185 self._start = int(start)
186 self._stop = int(stop)
187 if not (self._stop > self._start):
188 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
190 __slots__ = ("_start", "_stop")
192 factory: ClassVar[IntervalSliceFactory]
193 """A factory for creating intervals using slice syntax.
195 For example::
197 interval = Interval.factory[2:5]
198 """
200 @classmethod
201 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
202 """Construct an interval that includes all of the given points and/or
203 intervals.
204 """
205 if type(first) is Interval:
206 rmin = first.min
207 rmax = first.max
208 else:
209 rmin = rmax = first
210 for arg in args:
211 if type(arg) is Interval:
212 rmin = min(rmin, arg.min)
213 rmax = max(rmax, arg.max)
214 else:
215 rmin = min(rmin, arg)
216 rmax = max(rmax, arg)
217 return Interval(start=rmin, stop=rmax + 1)
219 @classmethod
220 def from_size(cls, size: int, start: int = 0) -> Interval:
221 """Construct an interval from its size and optional start."""
222 return cls(start=start, stop=start + size)
224 @property
225 def min(self) -> int:
226 """Inclusive minimum point in the interval (`int`)."""
227 return self.start
229 @property
230 def max(self) -> int:
231 """Inclusive maximum point in the interval (`int`)."""
232 return self.stop - 1
234 @property
235 def start(self) -> int:
236 """Inclusive minimum point in the interval (`int`)."""
237 return self._start
239 @property
240 def stop(self) -> int:
241 """One past the maximum point in the interval (`int`)."""
242 return self._stop
244 @property
245 def size(self) -> int:
246 """Size of the interval (`int`)."""
247 return self.stop - self.start
249 @property
250 def range(self) -> __builtins__.range:
251 """An iterable over all values in the interval
252 (`__builtins__.range`).
253 """
254 return range(self.start, self.stop)
256 @property
257 def arange(self) -> np.ndarray:
258 """An array of all the values in the interval (`numpy.ndarray`).
260 Array values are integers.
261 """
262 return np.arange(self.start, self.stop)
264 @property
265 def absolute(self) -> IntervalSliceFactory:
266 """A factory for constructing a contained `Interval` using slice
267 syntax and absolute coordinates.
269 Notes
270 -----
271 Slice bounds that are absent are replaced with the bounds of ``self``.
272 """
273 return IntervalSliceFactory(self, is_local=False)
275 @property
276 def local(self) -> IntervalSliceFactory:
277 """A factory for constructing a contained `Interval` using a slice
278 relative to the start of this one (`IntervalSliceFactory`).
280 Notes
281 -----
282 This factory interprets slices as "local" coordinates, in which ``0``
283 corresponds to ``self.start``. Negative bounds are relative to
284 ``self.stop``, as is usually the case for Python sequences.
285 """
286 return IntervalSliceFactory(self, is_local=True)
288 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
289 """Return an array of values that spans the interval.
291 Parameters
292 ----------
293 n
294 How many values to return. The default (if ``step`` is also not
295 provided) is the size of the interval, i.e. equivalent to the
296 `arange` property (but converted to `float`).
297 step
298 Set ``n`` such that the distance between points is equal to or
299 just less than this. Mutually exclusive with ``n``.
301 Returns
302 -------
303 numpy.ndarray
304 Array of `float` values.
306 See Also
307 --------
308 numpy.linspace
309 """
310 if n is None:
311 if step is None:
312 return self.arange.astype(np.float64)
313 n = math.ceil(self.size / step)
314 elif step is not None:
315 raise TypeError("'n' and 'step' cannot both be provided.")
316 return np.linspace(self.min, self.max, n, dtype=np.float64)
318 @property
319 def center(self) -> float:
320 """The center of the interval (`float`)."""
321 return 0.5 * (self.min + self.max)
323 def padded(self, padding: int) -> Interval:
324 """Return a new interval expanded by the given padding on
325 either side.
326 """
327 return Interval(self.start - padding, self.stop + padding)
329 def __str__(self) -> str:
330 return f"{self.start}:{self.stop}"
332 def __repr__(self) -> str:
333 return f"Interval(start={self.start}, stop={self.stop})"
335 def __eq__(self, other: object) -> bool:
336 if type(other) is Interval:
337 return self._start == other._start and self._stop == other._stop
338 return False
340 def __add__(self, other: int) -> Interval:
341 return Interval(start=self.start + other, stop=self.stop + other)
343 def __sub__(self, other: int) -> Interval:
344 return Interval(start=self.start - other, stop=self.stop - other)
346 def __contains__(self, x: int) -> bool:
347 return x >= self.start and x < self.stop
349 @overload
350 def contains(self, other: Interval | int | float) -> bool: ... 350 ↛ exitline 350 didn't return from function 'contains' because
352 @overload
353 def contains(self, other: np.ndarray) -> np.ndarray: ... 353 ↛ exitline 353 didn't return from function 'contains' because
355 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
356 """Test whether this interval fully contains another or one or more
357 points.
359 Parameters
360 ----------
361 other
362 Another interval to compare to, or one or more position values.
364 Returns
365 -------
366 `bool` | `numpy.ndarray`
367 If a single interval or value was passed, a single `bool`. If an
368 array was passed, an array with the same shape.
370 Notes
371 -----
372 In order to yield the desired behavior for floating-point arguments,
373 points are actually tested against an interval that is 0.5 larger on
374 both sides: this makes positions within the outer boundary of pixels
375 (but beyond the centers of those pixels, which have integer positions)
376 appear "on the image".
377 """
378 if isinstance(other, Interval):
379 return self.start <= other.start and self.stop >= other.stop
380 else:
381 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
382 if not result.shape:
383 return bool(result)
384 return result
386 def intersection(self, other: Interval) -> Interval:
387 """Return an interval that is contained by both ``self`` and ``other``.
389 When there is no overlap between the intervals, `NoOverlapError` is
390 raised.
391 """
392 new_start = max(self.start, other.start)
393 new_stop = min(self.stop, other.stop)
394 if new_start < new_stop:
395 return Interval(start=new_start, stop=new_stop)
396 raise NoOverlapError(f"No overlap between {self} and {other}.")
398 def dilated_by(self, padding: int) -> Interval:
399 """Return a new interval padded by the given amount on both sides."""
400 return Interval(start=self._start - padding, stop=self._stop + padding)
402 def slice_within(self, other: Interval) -> slice:
403 """Return the `slice` that corresponds to the values in this interval
404 when the items of the container being sliced correspond to ``other``.
406 This assumes ``other.contains(self)``.
407 """
408 if not other.contains(self):
409 raise IndexError(
410 f"Can not calculate a slice of {other} within {self} "
411 "since the given interval does not contain this one."
412 )
413 return slice(self.start - other.start, self.stop - other.start)
415 @classmethod
416 def from_legacy(cls, legacy: Any) -> Interval:
417 """Convert from an `lsst.geom.IntervalI` instance."""
418 return cls(legacy.begin, legacy.end)
420 def to_legacy(self) -> Any:
421 """Convert to an `lsst.geom.IntervalI` instance."""
422 from lsst.geom import IntervalI
424 return IntervalI(min=self.min, max=self.max)
426 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
427 return (
428 Interval,
429 (
430 self._start,
431 self._stop,
432 ),
433 )
435 @classmethod
436 def __get_pydantic_core_schema__(
437 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
438 ) -> pcs.CoreSchema:
439 from_typed_dict = pcs.chain_schema(
440 [
441 handler(_SerializedInterval),
442 pcs.no_info_plain_validator_function(cls._validate),
443 ]
444 )
445 return pcs.json_or_python_schema(
446 json_schema=from_typed_dict,
447 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
448 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
449 )
451 @classmethod
452 def __get_pydantic_json_schema__(
453 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
454 ) -> JsonSchemaValue:
455 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
457 @classmethod
458 def _validate(cls, data: _SerializedInterval) -> Interval:
459 return cls(**data)
461 def _serialize(self) -> _SerializedInterval:
462 return {"start": self._start, "stop": self._stop}
465class IntervalSliceFactory:
466 """A factory for `Interval` objects using array-slice syntax.
468 Notes
469 -----
470 When indexed with a single slice on the `Interval.factory` attribute, this
471 returns an `Interval` with exactly the given bounds::
473 assert Interval.factory[3:6] == Interval(start=3, stop=6)
475 A missing start bound is replaced by ``0``, but a missing stop bound is
476 not allowed.
478 When obtained from the `Interval.absolute` property, indices are absolute
479 coordinate values, but any omitted bounds are replaced with the parent
480 interval's bounds::
482 parent = Interval.factory[3:6]
483 assert Interval.factory[4:5] == parent.absolute[:5]
485 The final interval is also checked to be contained by the parent interval.
487 When obtained from the `Interval.local` property, indices are interpreted
488 as relative to the parent interval, and negative indices are relative to
489 the end (like `~collections.abc.Sequence` indexing)::
491 parent = Interval.factory[3:6]
492 assert Interval.factory[4:5] == parent.local[1:-1]
494 When the stop bound is greater than the size of the parent interval, the
495 returned interval is clipped to be contained by the parent (as in
496 `~collections.abc.Sequence` indexing).
497 """
499 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
500 self._parent = parent
501 self._is_local = is_local
503 def __getitem__(self, s: slice) -> Interval:
504 if s.step is not None and s.step != 1:
505 raise ValueError(f"Slice {s} has non-unit step.")
506 if self._is_local:
507 assert self._parent is not None, "is_local=True requires a parent interval"
508 start, stop, _ = s.indices(self._parent.size)
509 start += self._parent.start
510 stop += self._parent.start
511 else:
512 start = s.start
513 stop = s.stop
514 if start is None:
515 if self._parent is None:
516 start = 0
517 else:
518 start = self._parent.start
519 if stop is None:
520 if self._parent is None: 520 ↛ 521line 520 didn't jump to line 521 because the condition on line 520 was never true
521 raise IndexError("An Interval cannot have an empty upper bound.")
522 stop = self._parent.stop
523 if self._parent is not None:
524 if start < self._parent.start:
525 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
526 if stop > self._parent.stop:
527 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
528 return Interval(start=start, stop=stop)
531Interval.factory = IntervalSliceFactory()
534class _SerializedBox(TypedDict):
535 y: _SerializedInterval
536 x: _SerializedInterval
539class Box:
540 """An axis-aligned 2-d rectangular region.
542 Parameters
543 ----------
544 y
545 Interval for the y dimension.
546 x
547 Interval for the x dimension.
549 Notes
550 -----
551 `Box` implements the necessary hooks to be included directly in a
552 `pydantic.BaseModel`, even though it is neither a built-in type nor a
553 Pydantic model itself.
554 """
556 def __init__(self, y: Interval, x: Interval) -> None:
557 self._intervals = YX(y, x)
559 __slots__ = ("_intervals",)
561 factory: ClassVar[BoxSliceFactory]
562 """A factory for creating boxes using slice syntax.
564 For example::
566 box = Box.factory[2:5, 3:9]
567 """
569 @classmethod
570 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
571 """Construct a box from its shape and optional start.
573 Parameters
574 ----------
575 shape
576 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
577 start
578 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
579 """
580 if start is None:
581 start = (0,) * len(shape)
582 match shape:
583 case XY(x=x_size, y=y_size):
584 pass
585 case [y_size, x_size]:
586 pass
587 case _:
588 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
589 match start:
590 case XY(x=x_start, y=y_start):
591 pass
592 case [y_start, x_start]:
593 pass
594 case _:
595 raise ValueError(f"Invalid sequence for start: {start!r}.")
596 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
598 @classmethod
599 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
600 """Construct a box from floating-point bounds ensuring that all the
601 are contained in the new box.
603 Parameters
604 ----------
605 x_min
606 Minimum X value.
607 x_max
608 Maximum X value.
609 y_min
610 Minimum Y value.
611 y_max
612 Maximum Y value.
614 Notes
615 -----
616 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
617 pixels whose centers lie within the bounds are included.
618 """
619 return Box.factory[
620 round_half_up(y_min) : round_half_down(y_max) + 1,
621 round_half_up(x_min) : round_half_down(x_max) + 1,
622 ]
624 @property
625 def min(self) -> YX[int]:
626 """The inclusive minimum bounds of the box, ordered ``(y, x)``
627 (`YX` [`int`]).
628 """
629 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
631 @property
632 def max(self) -> YX[int]:
633 """The inclusive maximum bounds of the box, ordered ``(y, x)``
634 (`YX` [`int`]).
635 """
636 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
638 @property
639 def start(self) -> YX[int]:
640 """Tuple holding the inclusive `Interval.start` bvound, ordered
641 ``(y, x)`` (`YX` [`int`]).
643 This is an alias for `min`, typically paired with `stop` for
644 half-exclusive ranges.
645 """
646 return YX(self.y.start, self.x.start)
648 @property
649 def stop(self) -> YX[int]:
650 """Tuple holding the exclusive `Interval.stop` bound, ordered
651 ``(y, x)`` (`YX` [`int`]).
653 The values in this tuple are one greater than those in `max`. It is
654 typically paired with `start` for half-exclusive ranges.
655 """
656 return YX(self.y.stop, self.x.stop)
658 @property
659 def shape(self) -> YX[int]:
660 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
661 (`YX` [`int`]).
662 """
663 return YX(self.y.size, self.x.size)
665 @property
666 def x(self) -> Interval:
667 """The x-dimension interval (`int`)."""
668 return self._intervals[-1]
670 @property
671 def y(self) -> Interval:
672 """The y-dimension interval (`int`)."""
673 return self._intervals[-2]
675 @property
676 def absolute(self) -> BoxSliceFactory:
677 """A factory for constructing a contained `Box` using slice
678 syntax and absolute coordinates.
680 Notes
681 -----
682 Slice bounds that are absent are replaced with the bounds of ``self``.
683 """
684 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
686 @property
687 def local(self) -> BoxSliceFactory:
688 """A factory for constructing a contained `Interval` using a slice
689 relative to the start of this one (`BoxSliceFactory`).
691 Notes
692 -----
693 This factory interprets slices as "local" coordinates, in which ``0``
694 corresponds to ``self.start``. Negative bounds are relative to
695 ``self.stop``, as is usually the case for Python sequences.
696 """
697 return BoxSliceFactory(y=self.y.local, x=self.x.local)
699 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
700 """Return a pair of 2-d arrays of the coordinate values of the box.
702 Parameters
703 ----------
704 n
705 Number of points in each dimension. If a sequence, points are
706 assumed to be ordered ``(x, y)`` unless a `YX` instance is
707 provided.
708 step
709 Set ``n`` such that the distance between points is equal to or
710 just less than this in each dimension. Mutually exclusive with
711 ``n``.
713 Returns
714 -------
715 `XY` [`numpy.ndarray`]
716 A pair of arrays, each of which is 2-d with floating-point values.
718 See Also
719 --------
720 numpy.meshgrid
721 """
722 if n is not None and step is not None:
723 raise TypeError("'n' and 'step' cannot both be provided.")
724 match n:
725 case int():
726 ax = self.x.linspace(n)
727 ay = self.y.linspace(n)
728 case YX(y=ny, x=nx):
729 ax = self.x.linspace(nx)
730 ay = self.y.linspace(ny)
731 case [nx, ny]:
732 ax = self.x.linspace(nx)
733 ay = self.y.linspace(ny)
734 case None:
735 ax = self.x.linspace(step=step)
736 ay = self.y.linspace(step=step)
737 case _:
738 raise ValueError(f"Unexpected values for n ({n})")
739 return XY(*np.meshgrid(ax, ay))
741 def padded(self, padding: int) -> Box:
742 """Return a new box expanded by the given padding on
743 all sides.
744 """
745 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
747 def __eq__(self, other: object) -> bool:
748 if type(other) is Box:
749 return self._intervals == other._intervals
750 return False
752 def __str__(self) -> str:
753 return f"[y={self.y}, x={self.x}]"
755 def __repr__(self) -> str:
756 return f"Box(y={self.y!r}, x={self.x!r})"
758 @overload
759 def contains(self, other: Box, /) -> bool: ... 759 ↛ exitline 759 didn't return from function 'contains' because
761 @overload
762 def contains(self, *, y: int, x: int) -> bool: ... 762 ↛ exitline 762 didn't return from function 'contains' because
764 @overload
765 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 765 ↛ exitline 765 didn't return from function 'contains' because
767 def contains(
768 self,
769 other: Box | None = None,
770 *,
771 y: int | np.ndarray | None = None,
772 x: int | np.ndarray | None = None,
773 ) -> bool | np.ndarray:
774 """Test whether this box fully contains another or one or more points.
776 Parameters
777 ----------
778 other
779 Another box to compare to. Not compatible with the ``y`` and ``x``
780 arguments.
781 y
782 One or more integer Y coordinates to test for containment.
783 If an array, an array of results will be returned.
784 x
785 One or more integer X coordinates to test for containment.
786 If an array, an array of results will be returned.
788 Returns
789 -------
790 `bool` | `numpy.ndarray`
791 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
792 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
793 array with their broadcasted shape.
795 Notes
796 -----
797 In order to yield the desired behavior for floating-point arguments,
798 points are actually tested against an interval that is 0.5 larger on
799 both sides: this makes positions within the outer boundary of pixels
800 (but beyond the centers of those pixels, which have integer positions)
801 appear "on the image".
802 """
803 if other is not None:
804 if x is not None or y is not None:
805 raise TypeError("Too many arguments to 'Box.contains'.")
806 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
807 elif x is None or y is None:
808 raise TypeError("Not enough arguments to 'Box.contains'.")
809 else:
810 result = np.logical_and(self.x.contains(x), self.y.contains(y))
811 if not result.shape:
812 return bool(result)
813 return result
815 @overload
816 def intersection(self, other: Box) -> Box: ... 816 ↛ exitline 816 didn't return from function 'intersection' because
818 @overload
819 def intersection(self, other: Bounds) -> Bounds: ... 819 ↛ exitline 819 didn't return from function 'intersection' because
821 def intersection(self, other: Bounds) -> Bounds:
822 """Return a bounds object that is contained by both ``self`` and
823 ``other``.
825 When there is no overlap, `NoOverlapError` is raised.
826 """
827 from ._concrete_bounds import _intersect_box
829 return _intersect_box(self, other)
831 def dilated_by(self, padding: int) -> Box:
832 """Return a new box padded by the given amount on all sides."""
833 return Box(*[i.dilated_by(padding) for i in self._intervals])
835 def slice_within(self, other: Box) -> YX[slice]:
836 """Return a `tuple` of `slice` objects that correspond to the
837 positions in this box when the items of the container being sliced
838 correspond to ``other``.
840 This assumes ``other.contains(self)``.
841 """
842 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
844 @property
845 def bbox(self) -> Box:
846 """The box itself (`Box`).
848 This is provided for compatibility with the `Bounds` interface.
849 """
850 return self
852 def boundary(self) -> Iterator[YX[int]]:
853 """Iterate over the corners of the box as ``(y, x)`` tuples."""
854 if len(self._intervals) != 2: 854 ↛ 855line 854 didn't jump to line 855 because the condition on line 854 was never true
855 raise TypeError("Box is not 2-d.")
856 yield YX(self.y.min, self.x.min)
857 yield YX(self.y.min, self.x.max)
858 yield YX(self.y.max, self.x.max)
859 yield YX(self.y.max, self.x.min)
861 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
862 return (Box, self._intervals)
864 @classmethod
865 def from_legacy(cls, legacy: Any) -> Box:
866 """Convert from an `lsst.geom.Box2I` instance."""
867 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
869 def to_legacy(self) -> Any:
870 """Convert to an `lsst.geom.BoxI` instance."""
871 from lsst.geom import Box2I
873 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
875 @classmethod
876 def __get_pydantic_core_schema__(
877 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
878 ) -> pcs.CoreSchema:
879 from_typed_dict = pcs.chain_schema(
880 [
881 handler(_SerializedBox),
882 pcs.no_info_plain_validator_function(cls._validate),
883 ]
884 )
885 return pcs.json_or_python_schema(
886 json_schema=from_typed_dict,
887 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
888 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
889 )
891 @classmethod
892 def __get_pydantic_json_schema__(
893 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler
894 ) -> JsonSchemaValue:
895 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
897 @classmethod
898 def _validate(cls, data: _SerializedBox) -> Box:
899 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
901 def _serialize(self) -> _SerializedBox:
902 return {"y": self.y._serialize(), "x": self.x._serialize()}
904 def serialize(self) -> Box:
905 """Return a Pydantic-friendly representation of this object.
907 This method just returns the `Box` itself, since that already provides
908 Pydantic serialization hooks. It exists for compatibility with the
909 `Bounds` protocol.
910 """
911 return self
913 def deserialize(self) -> Box:
914 """Deserialize a bounds object on the assumption it is a `Box`.
916 This method just returns the `Box` itself, since that already provides
917 Pydantic serialization hooks. It exists for compatibility with the
918 `Bounds` protocol.
919 """
920 return self
923class BoxSliceFactory:
924 """A factory for `Box` objects using array-slice syntax.
926 Notes
927 -----
928 When `Box.factory` is indexed with a pair of slices, this returns a
929 `Box` with exactly those bounds::
931 assert (
932 Box.factory[3:6, -1:2]
933 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
934 )
936 A missing start bound is replaced by ``0``, but a missing stop bound is
937 not allowed.
939 When obtained from the `Box.absolute` property, indices are absolute
940 coordinate values, but any omitted bounds are replaced with the parent
941 box's bounds::
943 parent = Box.factory[3:6, -1:2]
944 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
946 The final box is also checked to be contained by the parent box.
948 When obtained from the `Box.local` property, indices are interpreted
949 as relative to the parent box, and negative indices are relative to
950 the end (like `~collections.abc.Sequence` indexing)::
952 parent = Box.factory[3:6, -1:2]
953 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
954 """
956 def __init__(
957 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
958 ) -> None:
959 self._y = y
960 self._x = x
962 def __getitem__(self, key: tuple[slice, slice]) -> Box:
963 match key:
964 case XY(x=x, y=y):
965 return Box(y=self._y[y], x=self._x[x])
966 case (y, x):
967 return Box(y=self._y[y], x=self._x[x])
968 case _:
969 raise TypeError("Expected exactly two slices.")
972Box.factory = BoxSliceFactory()
975class Bounds(Protocol):
976 """A protocol for objects that represent the validity region for a function
977 defined in 2-d pixel coordinates.
979 Notes
980 -----
981 Most objects natively have a simple 2-d bounding box as their bounds
982 (typically the boundary of a sensor), and the `Box` class is hence the
983 most common bounds implementation. But sometimes a large chunk of that
984 box may be missing due to vignetting or bad amplifiers, and we may want to
985 transform from one coordinate system to another. The Bounds interface is
986 intended to handle both of these cases as well.
987 """
989 @property
990 def bbox(self) -> Box: ... 990 ↛ exitline 990 didn't return from function 'bbox' because
992 @overload
993 def contains(self, *, x: int, y: int) -> bool: ... 993 ↛ exitline 993 didn't return from function 'contains' because
995 @overload
996 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 996 ↛ exitline 996 didn't return from function 'contains' because
998 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
999 """Test whether this box fully contains another or one or more points.
1001 Parameters
1002 ----------
1003 x
1004 One or more integer X coordinates to test for containment.
1005 If an array, an array of results will be returned.
1006 y
1007 One or more integer Y coordinates to test for containment.
1008 If an array, an array of results will be returned.
1010 Returns
1011 -------
1012 `bool` | `numpy.ndarray`
1013 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1014 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
1015 shape.
1016 """
1017 ...
1019 def intersection(self, other: Bounds) -> Bounds:
1020 """Compute the intersection of this bounds object with another."""
1021 ...
1023 def serialize(self) -> SerializableBounds:
1024 """Convert a bounds instance into a serializable object.
1026 Notes
1027 -----
1028 The returned object must support direct nesting with Pydantic models
1029 and have a ``deserialize`` method (taking no arguments) that converts
1030 back to this `Bounds` type. It is common for `serialize` and
1031 ``deserialize`` to just return ``self``, when the bounds object is
1032 natively serializable.
1033 """
1034 ...
1037class BoundsError(ValueError):
1038 """Exception raised when an object is evaluated outside its bounds."""
1041class NoOverlapError(ValueError):
1042 """Exception raised when intervals or bounds do not overlap."""