Coverage for python/lsst/images/_geom.py: 91%
415 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-15 02:03 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-15 02:03 -0700
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 assert_type,
37 final,
38 overload,
39)
41import numpy as np
42import numpy.typing as npt
43import pydantic
44import pydantic_core.core_schema as pcs
45from pydantic.json_schema import JsonSchemaValue
47from .utils import round_half_down, round_half_up
49if TYPE_CHECKING:
50 from ._concrete_bounds import BoundsSerializationModel
51 from ._polygon import Polygon, Region
52 from ._transforms import Transform
54 try:
55 from lsst.geom import Extent2D as LegacyExtent2D
56 from lsst.geom import Extent2I as LegacyExtent2I
57 from lsst.geom import Point2D as LegacyPoint2D
58 from lsst.geom import Point2I as LegacyPoint2I
59 except ImportError:
60 type LegacyExtent2I = Any # type: ignore[no-redef]
61 type LegacyPoint2I = Any # type: ignore[no-redef]
62 type LegacyExtent2D = Any # type: ignore[no-redef]
63 type LegacyPoint2D = Any # type: ignore[no-redef]
65# This pre-python-3.12 declaration is needed by Sphinx (probably the
66# autodoc-typehints plugin.
67T = TypeVar("T")
69# Interval and Box are defined as regular Python classes rather than
70# dataclasses or Pydantic models because we might want to implement them as
71# compiled-extension types in the future, and we want that to be transparent.
73# In a similar vein, we avoid declaring specific types for multidimensional
74# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
75# shapes) in order to leave room for more fully-featured types to be added
76# upstream of this package in the future.
79class YX[T](NamedTuple):
80 """A pair of per-dimension objects, ordered ``(y, x)``.
82 Notes
83 -----
84 `YX` is used for slices, shapes, and other 2-d pairs when the most
85 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
86 arithmetic operations behave as they would on a
87 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
88 concatenates).
90 See Also
91 --------
92 XY
93 """
95 y: T
96 """The y / row object."""
98 x: T
99 """The x / column object."""
101 @property
102 def xy(self) -> XY:
103 """A tuple of the same objects in the opposite order."""
104 return XY(x=self.x, y=self.y)
106 def map[U](self, func: Callable[[T], U]) -> YX[U]:
107 """Apply a function to both objects.
109 Parameters
110 ----------
111 func
112 Callable applied to each of the two objects in turn.
113 """
114 return YX(y=func(self.y), x=func(self.x))
116 def to_legacy_int_extent(self) -> LegacyExtent2I:
117 """Convert to a legacy `lsst.geom.Extent2I` object."""
118 from lsst.geom import Extent2I as LegacyExtent2I
120 return LegacyExtent2I(self.x, self.y)
122 def to_legacy_int_point(self) -> LegacyPoint2I:
123 """Convert to a legacy `lsst.geom.Point2I` object."""
124 from lsst.geom import Point2I as LegacyPoint2I
126 return LegacyPoint2I(self.x, self.y)
128 def to_legacy_float_extent(self) -> LegacyExtent2D:
129 """Convert to a legacy `lsst.geom.Extent2D` object."""
130 from lsst.geom import Extent2D as LegacyExtent2D
132 return LegacyExtent2D(self.x, self.y)
134 def to_legacy_float_point(self) -> LegacyPoint2D:
135 """Convert to a legacy `lsst.geom.Point2D` object."""
136 from lsst.geom import Point2D as LegacyPoint2D
138 return LegacyPoint2D(self.x, self.y)
141class XY[T](NamedTuple):
142 """A pair of per-dimension objects, ordered ``(x, y)``.
144 Notes
145 -----
146 `XY` is used for points and other 2-d pairs when the most natural ordering
147 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
148 behave as they would on a `collections.abc.Sequence`, not a mathematical
149 vector (e.g. adding concatenates).
151 See Also
152 --------
153 YX
154 """
156 x: T
157 """The x / column object."""
159 y: T
160 """The y / row object."""
162 @property
163 def yx(self) -> YX:
164 """A tuple of the same objects in the opposite order."""
165 return YX(y=self.y, x=self.x)
167 def map[U](self, func: Callable[[T], U]) -> XY[U]:
168 """Apply a function to both objects.
170 Parameters
171 ----------
172 func
173 Callable applied to each of the two objects in turn.
174 """
175 return XY(x=func(self.x), y=func(self.y))
177 def to_legacy_int_extent(self) -> LegacyExtent2I:
178 """Convert to a legacy `lsst.geom.Extent2I` object."""
179 from lsst.geom import Extent2I as LegacyExtent2I
181 return LegacyExtent2I(self.x, self.y)
183 def to_legacy_int_point(self) -> LegacyPoint2I:
184 """Convert to a legacy `lsst.geom.Point2I` object."""
185 from lsst.geom import Point2I as LegacyPoint2I
187 return LegacyPoint2I(self.x, self.y)
189 def to_legacy_float_extent(self) -> LegacyExtent2D:
190 """Convert to a legacy `lsst.geom.Extent2D` object."""
191 from lsst.geom import Extent2D as LegacyExtent2D
193 return LegacyExtent2D(self.x, self.y)
195 def to_legacy_float_point(self) -> LegacyPoint2D:
196 """Convert to a legacy `lsst.geom.Point2D` object."""
197 from lsst.geom import Point2D as LegacyPoint2D
199 return LegacyPoint2D(self.x, self.y)
202class _SerializedInterval(TypedDict):
203 start: int
204 stop: int
207@final
208class Interval:
209 """A 1-d integer interval with positive size.
211 Parameters
212 ----------
213 start
214 Inclusive minimum point in the interval.
215 stop
216 One past the maximum point in the interval.
218 Notes
219 -----
220 Adding or subtracting an `int` from an interval returns a shifted interval.
222 `Interval` implements the necessary hooks to be included directly in a
223 `pydantic.BaseModel`, even though it is neither a built-in type nor a
224 Pydantic model itself.
225 """
227 def __init__(self, start: int, stop: int) -> None:
228 # Coerce to be defensive against numpy int scalars.
229 self._start = int(start)
230 self._stop = int(stop)
231 if not (self._stop > self._start):
232 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
234 __slots__ = ("_start", "_stop")
236 factory: ClassVar[IntervalSliceFactory]
237 """A factory for creating intervals using slice syntax.
239 For example::
241 interval = Interval.factory[2:5]
242 """
244 @classmethod
245 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
246 """Construct an interval that includes all of the given points and/or
247 intervals.
249 Parameters
250 ----------
251 first
252 First point or interval to include in the hull.
253 *args
254 Additional points and/or intervals to include in the hull.
255 """
256 if type(first) is Interval:
257 rmin = first.min
258 rmax = first.max
259 else:
260 rmin = rmax = first
261 for arg in args:
262 if type(arg) is Interval:
263 rmin = min(rmin, arg.min)
264 rmax = max(rmax, arg.max)
265 else:
266 rmin = min(rmin, arg)
267 rmax = max(rmax, arg)
268 return Interval(start=rmin, stop=rmax + 1)
270 @classmethod
271 def from_size(cls, size: int, start: int = 0) -> Interval:
272 """Construct an interval from its size and optional start.
274 Parameters
275 ----------
276 size
277 Number of points in the interval.
278 start
279 Inclusive minimum point in the interval.
280 """
281 return cls(start=start, stop=start + size)
283 @property
284 def min(self) -> int:
285 """Inclusive minimum point in the interval (`int`)."""
286 return self.start
288 @property
289 def max(self) -> int:
290 """Inclusive maximum point in the interval (`int`)."""
291 return self.stop - 1
293 @property
294 def start(self) -> int:
295 """Inclusive minimum point in the interval (`int`)."""
296 return self._start
298 @property
299 def stop(self) -> int:
300 """One past the maximum point in the interval (`int`)."""
301 return self._stop
303 @property
304 def size(self) -> int:
305 """Size of the interval (`int`)."""
306 return self.stop - self.start
308 @property
309 def range(self) -> __builtins__.range:
310 """An iterable over all values in the interval
311 (`__builtins__.range`).
312 """
313 return range(self.start, self.stop)
315 @property
316 def arange(self) -> np.ndarray:
317 """An array of all the values in the interval (`numpy.ndarray`).
319 Array values are integers.
320 """
321 return np.arange(self.start, self.stop)
323 @property
324 def absolute(self) -> IntervalSliceFactory:
325 """A factory for constructing a contained `Interval` using slice
326 syntax and absolute coordinates.
328 Notes
329 -----
330 Slice bounds that are absent are replaced with the bounds of ``self``.
331 """
332 return IntervalSliceFactory(self, is_local=False)
334 @property
335 def local(self) -> IntervalSliceFactory:
336 """A factory for constructing a contained `Interval` using a slice
337 relative to the start of this one (`IntervalSliceFactory`).
339 Notes
340 -----
341 This factory interprets slices as "local" coordinates, in which ``0``
342 corresponds to ``self.start``. Negative bounds are relative to
343 ``self.stop``, as is usually the case for Python sequences.
344 """
345 return IntervalSliceFactory(self, is_local=True)
347 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
348 """Return an array of values that spans the interval.
350 Parameters
351 ----------
352 n
353 How many values to return. The default (if ``step`` is also not
354 provided) is the size of the interval, i.e. equivalent to the
355 `arange` property (but converted to `float`).
356 step
357 Set ``n`` such that the distance between points is equal to or
358 just less than this. Mutually exclusive with ``n``.
360 Returns
361 -------
362 numpy.ndarray
363 Array of `float` values.
365 See Also
366 --------
367 numpy.linspace
368 """
369 if n is None:
370 if step is None:
371 return self.arange.astype(np.float64)
372 n = math.ceil(self.size / step)
373 elif step is not None:
374 raise TypeError("'n' and 'step' cannot both be provided.")
375 return np.linspace(self.min, self.max, n, dtype=np.float64)
377 @property
378 def center(self) -> float:
379 """The center of the interval (`float`)."""
380 return 0.5 * (self.min + self.max)
382 def padded(self, padding: int) -> Interval:
383 """Return a new interval expanded by the given padding on
384 either side.
386 Parameters
387 ----------
388 padding
389 Number of points to add to each side of the interval.
390 """
391 return Interval(self.start - padding, self.stop + padding)
393 def __str__(self) -> str:
394 return f"{self.start}:{self.stop}"
396 def __repr__(self) -> str:
397 return f"Interval(start={self.start}, stop={self.stop})"
399 def __eq__(self, other: object) -> bool:
400 if type(other) is Interval:
401 return self._start == other._start and self._stop == other._stop
402 return False
404 def __add__(self, other: int) -> Interval:
405 return Interval(start=self.start + other, stop=self.stop + other)
407 def __sub__(self, other: int) -> Interval:
408 return Interval(start=self.start - other, stop=self.stop - other)
410 def __contains__(self, x: int) -> bool:
411 return x >= self.start and x < self.stop
413 @overload
414 def contains(self, other: Interval | int | float) -> bool: ... 414 ↛ exitline 414 didn't return from function 'contains' because
416 @overload
417 def contains(self, other: npt.ArrayLike) -> np.ndarray: ... 417 ↛ exitline 417 didn't return from function 'contains' because
419 def contains(self, other: Interval | int | float | npt.ArrayLike) -> bool | np.ndarray:
420 """Test whether this interval fully contains another or one or more
421 points.
423 Parameters
424 ----------
425 other
426 Another interval to compare to, or one or more position values as
427 a scalar or any array-like.
429 Returns
430 -------
431 `bool` | `numpy.ndarray`
432 If a single interval or value was passed, a single `bool`. If an
433 array-like was passed, an array with the broadcasted shape.
435 Notes
436 -----
437 In order to yield the desired behavior for floating-point arguments,
438 points are actually tested against an interval that is 0.5 larger on
439 both sides: this makes positions within the outer boundary of pixels
440 (but beyond the centers of those pixels, which have integer positions)
441 appear "on the image".
442 """
443 if isinstance(other, Interval):
444 return self.start <= other.start and self.stop >= other.stop
445 else:
446 other = np.asarray(other)
447 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
448 if not result.shape:
449 return bool(result)
450 return result
452 def intersection(self, other: Interval) -> Interval:
453 """Return an interval that is contained by both ``self`` and ``other``.
455 When there is no overlap between the intervals, `NoOverlapError` is
456 raised.
458 Parameters
459 ----------
460 other
461 Interval to intersect with this one.
462 """
463 new_start = max(self.start, other.start)
464 new_stop = min(self.stop, other.stop)
465 if new_start < new_stop:
466 return Interval(start=new_start, stop=new_stop)
467 raise NoOverlapError(f"No overlap between {self} and {other}.")
469 def dilated_by(self, padding: int) -> Interval:
470 """Return a new interval padded by the given amount on both sides.
472 Parameters
473 ----------
474 padding
475 Number of points to add to each side of the interval.
476 """
477 return Interval(start=self._start - padding, stop=self._stop + padding)
479 def slice_within(self, other: Interval) -> slice:
480 """Return the `slice` that corresponds to the values in this interval
481 when the items of the container being sliced correspond to ``other``.
483 This assumes ``other.contains(self)``.
485 Parameters
486 ----------
487 other
488 Interval whose values correspond to the container being sliced.
489 """
490 if not other.contains(self):
491 raise IndexError(
492 f"Can not calculate a slice of {other} within {self} "
493 "since the given interval does not contain this one."
494 )
495 return slice(self.start - other.start, self.stop - other.start)
497 @classmethod
498 def from_legacy(cls, legacy: Any) -> Interval:
499 """Convert from an `lsst.geom.IntervalI` instance.
501 Parameters
502 ----------
503 legacy
504 Legacy `lsst.geom.IntervalI` instance to convert.
505 """
506 return cls(legacy.begin, legacy.end)
508 def to_legacy(self) -> Any:
509 """Convert to an `lsst.geom.IntervalI` instance."""
510 from lsst.geom import IntervalI
512 return IntervalI(min=self.min, max=self.max)
514 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
515 return (
516 Interval,
517 (
518 self._start,
519 self._stop,
520 ),
521 )
523 @classmethod
524 def __get_pydantic_core_schema__(
525 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
526 ) -> pcs.CoreSchema:
527 from_typed_dict = pcs.chain_schema(
528 [
529 handler(_SerializedInterval),
530 pcs.no_info_plain_validator_function(cls._validate),
531 ]
532 )
533 return pcs.json_or_python_schema(
534 json_schema=from_typed_dict,
535 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
536 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
537 )
539 @classmethod
540 def __get_pydantic_json_schema__(
541 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
542 ) -> JsonSchemaValue:
543 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
545 @classmethod
546 def _validate(cls, data: _SerializedInterval) -> Interval:
547 return cls(**data)
549 def _serialize(self) -> _SerializedInterval:
550 return {"start": self._start, "stop": self._stop}
553class IntervalSliceFactory:
554 """A factory for `Interval` objects using array-slice syntax.
556 Parameters
557 ----------
558 parent
559 Interval that constructed intervals must be contained by, or `None`
560 to allow any bounds.
561 is_local
562 Whether slice bounds are interpreted relative to the start of
563 ``parent`` rather than as absolute coordinates.
565 Notes
566 -----
567 When indexed with a single slice on the `Interval.factory` attribute, this
568 returns an `Interval` with exactly the given bounds::
570 assert Interval.factory[3:6] == Interval(start=3, stop=6)
572 A missing start bound is replaced by ``0``, but a missing stop bound is
573 not allowed.
575 When obtained from the `Interval.absolute` property, indices are absolute
576 coordinate values, but any omitted bounds are replaced with the parent
577 interval's bounds::
579 parent = Interval.factory[3:6]
580 assert Interval.factory[4:5] == parent.absolute[:5]
582 The final interval is also checked to be contained by the parent interval.
584 When obtained from the `Interval.local` property, indices are interpreted
585 as relative to the parent interval, and negative indices are relative to
586 the end (like `~collections.abc.Sequence` indexing)::
588 parent = Interval.factory[3:6]
589 assert Interval.factory[4:5] == parent.local[1:-1]
591 When the stop bound is greater than the size of the parent interval, the
592 returned interval is clipped to be contained by the parent (as in
593 `~collections.abc.Sequence` indexing).
594 """
596 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
597 self._parent = parent
598 self._is_local = is_local
600 def __getitem__(self, s: slice) -> Interval:
601 if s.step is not None and s.step != 1:
602 raise ValueError(f"Slice {s} has non-unit step.")
603 if self._is_local:
604 assert self._parent is not None, "is_local=True requires a parent interval"
605 start, stop, _ = s.indices(self._parent.size)
606 start += self._parent.start
607 stop += self._parent.start
608 else:
609 start = s.start
610 stop = s.stop
611 if start is None:
612 if self._parent is None:
613 start = 0
614 else:
615 start = self._parent.start
616 if stop is None:
617 if self._parent is None: 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true
618 raise IndexError("An Interval cannot have an empty upper bound.")
619 stop = self._parent.stop
620 if self._parent is not None:
621 if start < self._parent.start:
622 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
623 if stop > self._parent.stop:
624 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
625 return Interval(start=start, stop=stop)
628Interval.factory = IntervalSliceFactory()
631class _SerializedBox(TypedDict):
632 y: _SerializedInterval
633 x: _SerializedInterval
636class Box:
637 """An axis-aligned 2-d rectangular region.
639 Parameters
640 ----------
641 y
642 Interval for the y dimension.
643 x
644 Interval for the x dimension.
646 Notes
647 -----
648 `Box` implements the necessary hooks to be included directly in a
649 `pydantic.BaseModel`, even though it is neither a built-in type nor a
650 Pydantic model itself.
651 """
653 def __init__(self, y: Interval, x: Interval) -> None:
654 self._intervals = YX(y, x)
656 __slots__ = ("_intervals",)
658 factory: ClassVar[BoxSliceFactory]
659 """A factory for creating boxes using slice syntax.
661 For example::
663 box = Box.factory[2:5, 3:9]
664 """
666 @classmethod
667 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
668 """Construct a box from its shape and optional start.
670 Parameters
671 ----------
672 shape
673 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
674 start
675 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
676 """
677 if start is None:
678 start = (0,) * len(shape)
679 match shape:
680 case XY(x=x_size, y=y_size):
681 pass
682 case [y_size, x_size]:
683 pass
684 case _:
685 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
686 match start:
687 case XY(x=x_start, y=y_start):
688 pass
689 case [y_start, x_start]:
690 pass
691 case _:
692 raise ValueError(f"Invalid sequence for start: {start!r}.")
693 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
695 @classmethod
696 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
697 """Construct a box from floating-point bounds ensuring that all the
698 are contained in the new box.
700 Parameters
701 ----------
702 x_min
703 Minimum X value.
704 x_max
705 Maximum X value.
706 y_min
707 Minimum Y value.
708 y_max
709 Maximum Y value.
711 Notes
712 -----
713 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
714 pixels whose centers lie within the bounds are included.
715 """
716 return Box.factory[
717 round_half_up(y_min) : round_half_down(y_max) + 1,
718 round_half_up(x_min) : round_half_down(x_max) + 1,
719 ]
721 @property
722 def min(self) -> YX[int]:
723 """The inclusive minimum bounds of the box, ordered ``(y, x)``
724 (`YX` [`int`]).
725 """
726 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
728 @property
729 def max(self) -> YX[int]:
730 """The inclusive maximum bounds of the box, ordered ``(y, x)``
731 (`YX` [`int`]).
732 """
733 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
735 @property
736 def start(self) -> YX[int]:
737 """Tuple holding the inclusive `Interval.start` bvound, ordered
738 ``(y, x)`` (`YX` [`int`]).
740 This is an alias for `min`, typically paired with `stop` for
741 half-exclusive ranges.
742 """
743 return YX(self.y.start, self.x.start)
745 @property
746 def stop(self) -> YX[int]:
747 """Tuple holding the exclusive `Interval.stop` bound, ordered
748 ``(y, x)`` (`YX` [`int`]).
750 The values in this tuple are one greater than those in `max`. It is
751 typically paired with `start` for half-exclusive ranges.
752 """
753 return YX(self.y.stop, self.x.stop)
755 @property
756 def shape(self) -> YX[int]:
757 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
758 (`YX` [`int`]).
759 """
760 return YX(self.y.size, self.x.size)
762 @property
763 def x(self) -> Interval:
764 """The x-dimension interval (`int`)."""
765 return self._intervals[-1]
767 @property
768 def y(self) -> Interval:
769 """The y-dimension interval (`int`)."""
770 return self._intervals[-2]
772 @property
773 def area(self) -> int:
774 """The number of pixels in the box (`int`)."""
775 return self.x.size * self.y.size
777 @property
778 def absolute(self) -> BoxSliceFactory:
779 """A factory for constructing a contained `Box` using slice
780 syntax and absolute coordinates.
782 Notes
783 -----
784 Slice bounds that are absent are replaced with the bounds of ``self``.
785 """
786 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
788 @property
789 def local(self) -> BoxSliceFactory:
790 """A factory for constructing a contained `Interval` using a slice
791 relative to the start of this one (`BoxSliceFactory`).
793 Notes
794 -----
795 This factory interprets slices as "local" coordinates, in which ``0``
796 corresponds to ``self.start``. Negative bounds are relative to
797 ``self.stop``, as is usually the case for Python sequences.
798 """
799 return BoxSliceFactory(y=self.y.local, x=self.x.local)
801 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
802 """Return a pair of 2-d arrays of the coordinate values of the box.
804 Parameters
805 ----------
806 n
807 Number of points in each dimension. If a sequence, points are
808 assumed to be ordered ``(x, y)`` unless a `YX` instance is
809 provided.
810 step
811 Set ``n`` such that the distance between points is equal to or
812 just less than this in each dimension. Mutually exclusive with
813 ``n``.
815 Returns
816 -------
817 `XY` [`numpy.ndarray`]
818 A pair of arrays, each of which is 2-d with floating-point values.
820 See Also
821 --------
822 numpy.meshgrid
823 """
824 if n is not None and step is not None:
825 raise TypeError("'n' and 'step' cannot both be provided.")
826 match n:
827 case int():
828 ax = self.x.linspace(n)
829 ay = self.y.linspace(n)
830 case YX(y=ny, x=nx):
831 ax = self.x.linspace(nx)
832 ay = self.y.linspace(ny)
833 case [nx, ny]:
834 ax = self.x.linspace(nx)
835 ay = self.y.linspace(ny)
836 case None:
837 ax = self.x.linspace(step=step)
838 ay = self.y.linspace(step=step)
839 case _:
840 raise ValueError(f"Unexpected values for n ({n})")
841 return XY(*np.meshgrid(ax, ay))
843 def padded(self, padding: int) -> Box:
844 """Return a new box expanded by the given padding on
845 all sides.
847 Parameters
848 ----------
849 padding
850 Number of pixels to expand the box by on every side.
851 """
852 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
854 def __eq__(self, other: object) -> bool:
855 if type(other) is Box:
856 return self._intervals == other._intervals
857 return False
859 def __str__(self) -> str:
860 return f"[y={self.y}, x={self.x}]"
862 def __repr__(self) -> str:
863 return f"Box(y={self.y!r}, x={self.x!r})"
865 @overload
866 def contains(self, other: Box, /) -> bool: ... 866 ↛ exitline 866 didn't return from function 'contains' because
868 @overload
869 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 869 ↛ exitline 869 didn't return from function 'contains' because
871 @overload
872 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 872 ↛ exitline 872 didn't return from function 'contains' because
874 @overload
875 def contains(self, /, *, y: int | float, x: int | float) -> bool: ... 875 ↛ exitline 875 didn't return from function 'contains' because
877 @overload
878 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ... 878 ↛ exitline 878 didn't return from function 'contains' because
880 def contains(
881 self,
882 other: Box | XY[Any] | YX[Any] | None = None,
883 /,
884 *,
885 y: Any = None,
886 x: Any = None,
887 ) -> bool | np.ndarray:
888 """Test whether this box fully contains another or one or more points.
890 Parameters
891 ----------
892 other
893 Another box, or an `XY` or `YX` coordinate pair, to compare to.
894 Mutually exclusive with ``x`` and ``y``.
895 y
896 One or more Y coordinates to test for containment, as a scalar or
897 any array-like. Results are broadcast against ``x``.
898 Mutually exclusive with ``other``.
899 x
900 One or more X coordinates to test for containment, as a scalar or
901 any array-like. Results are broadcast against ``y``.
902 Mutually exclusive with ``other``.
904 Returns
905 -------
906 `bool` | `numpy.ndarray`
907 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
908 single `bool` value. If ``x`` and ``y`` are array-like, a boolean
909 array with their broadcasted shape.
911 Notes
912 -----
913 In order to yield the desired behavior for floating-point arguments,
914 points are actually tested against an interval that is 0.5 larger on
915 both sides: this makes positions within the outer boundary of pixels
916 (but beyond the centers of those pixels, which have integer positions)
917 appear "on the image".
918 """
919 match other:
920 case None:
921 if x is None or y is None:
922 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.")
923 case Box():
924 if x is not None or y is not None:
925 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
926 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
927 case XY() | YX(): 927 ↛ 931line 927 didn't jump to line 931 because the pattern on line 927 always matched
928 if x is not None or y is not None:
929 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
930 x, y = other.x, other.y
931 case _:
932 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
933 result = np.logical_and(self.x.contains(x), self.y.contains(y))
934 if not result.shape:
935 return bool(result)
936 return result
938 @overload
939 def intersection(self, other: Box) -> Box: ... 939 ↛ exitline 939 didn't return from function 'intersection' because
941 @overload
942 def intersection(self, other: Region) -> Region | Box: ... 942 ↛ exitline 942 didn't return from function 'intersection' because
944 @overload
945 def intersection(self, other: Bounds) -> Bounds: ... 945 ↛ exitline 945 didn't return from function 'intersection' because
947 def intersection(self, other: Bounds) -> Bounds:
948 """Return a bounds object that is contained by both ``self`` and
949 ``other``.
951 When there is no overlap, `NoOverlapError` is raised.
953 Parameters
954 ----------
955 other
956 Bounds to intersect with this one.
957 """
958 from ._concrete_bounds import _intersect_box
960 return _intersect_box(self, other)
962 def dilated_by(self, padding: int) -> Box:
963 """Return a new box padded by the given amount on all sides.
965 Parameters
966 ----------
967 padding
968 Number of pixels to pad the box by on every side.
969 """
970 return Box(*[i.dilated_by(padding) for i in self._intervals])
972 def slice_within(self, other: Box) -> YX[slice]:
973 """Return a `tuple` of `slice` objects that correspond to the
974 positions in this box when the items of the container being sliced
975 correspond to ``other``.
977 This assumes ``other.contains(self)``.
979 Parameters
980 ----------
981 other
982 Box that the sliced container's items correspond to.
983 """
984 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
986 @property
987 def bbox(self) -> Box:
988 """The box itself (`Box`).
990 This is provided for compatibility with the `Bounds` interface.
991 """
992 return self
994 def boundary(self) -> Iterator[YX[int]]:
995 """Iterate over the corners of the box as ``(y, x)`` tuples.
997 Yields
998 ------
999 corner
1000 Each corner in turn.
1001 """
1002 if len(self._intervals) != 2: 1002 ↛ 1003line 1002 didn't jump to line 1003 because the condition on line 1002 was never true
1003 raise TypeError("Box is not 2-d.")
1004 yield YX(self.y.min, self.x.min)
1005 yield YX(self.y.min, self.x.max)
1006 yield YX(self.y.max, self.x.max)
1007 yield YX(self.y.max, self.x.min)
1009 def to_polygon(self) -> Polygon:
1010 """Convert the box to a polygon with floating-point vertices.
1012 Notes
1013 -----
1014 Because the integer min and max coordinates of a box are
1015 interpreted as pixel centers, these are expanded by 0.5 on all sides
1016 before using them to form the polygon vertices.
1017 """
1018 from ._polygon import Polygon
1020 return Polygon.from_box(self)
1022 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1023 """Apply a coordinate transform to the box, returning a polygon.
1025 Parameters
1026 ----------
1027 transform
1028 Coordinate transform to apply (in the forward direction).
1030 Notes
1031 -----
1032 This transforms the polygon representation of the box (see
1033 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1034 full pixels before transforming them.
1035 """
1036 return self.to_polygon().transform(transform)
1038 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1039 return (Box, self._intervals)
1041 @classmethod
1042 def from_legacy(cls, legacy: Any) -> Box:
1043 """Convert from an `lsst.geom.Box2I` instance.
1045 Parameters
1046 ----------
1047 legacy
1048 Legacy `lsst.geom.Box2I` to convert.
1049 """
1050 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1052 def to_legacy(self) -> Any:
1053 """Convert to an `lsst.geom.BoxI` instance."""
1054 from lsst.geom import Box2I
1056 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1058 @classmethod
1059 def __get_pydantic_core_schema__(
1060 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1061 ) -> pcs.CoreSchema:
1062 from_typed_dict = pcs.chain_schema(
1063 [
1064 handler(_SerializedBox),
1065 pcs.no_info_plain_validator_function(cls._validate),
1066 ]
1067 )
1068 return pcs.json_or_python_schema(
1069 json_schema=from_typed_dict,
1070 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1071 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1072 )
1074 @classmethod
1075 def __get_pydantic_json_schema__(
1076 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1077 ) -> JsonSchemaValue:
1078 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1080 @classmethod
1081 def _validate(cls, data: _SerializedBox) -> Box:
1082 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1084 def _serialize(self) -> _SerializedBox:
1085 return {"y": self.y._serialize(), "x": self.x._serialize()}
1087 def serialize(self) -> Box:
1088 """Return a Pydantic-friendly representation of this object.
1090 This method just returns the `Box` itself, since that already provides
1091 Pydantic serialization hooks. It exists for compatibility with the
1092 `Bounds` protocol.
1093 """
1094 return self
1096 def deserialize(self) -> Box:
1097 """Deserialize a bounds object on the assumption it is a `Box`.
1099 This method just returns the `Box` itself, since that already provides
1100 Pydantic serialization hooks. It exists for compatibility with the
1101 `Bounds` protocol.
1102 """
1103 return self
1106class BoxSliceFactory:
1107 """A factory for `Box` objects using array-slice syntax.
1109 Parameters
1110 ----------
1111 y
1112 Slice factory used for the y axis.
1113 x
1114 Slice factory used for the x axis.
1116 Notes
1117 -----
1118 When `Box.factory` is indexed with a pair of slices, this returns a
1119 `Box` with exactly those bounds::
1121 assert (
1122 Box.factory[3:6, -1:2]
1123 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1124 )
1126 A missing start bound is replaced by ``0``, but a missing stop bound is
1127 not allowed.
1129 When obtained from the `Box.absolute` property, indices are absolute
1130 coordinate values, but any omitted bounds are replaced with the parent
1131 box's bounds::
1133 parent = Box.factory[3:6, -1:2]
1134 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1136 The final box is also checked to be contained by the parent box.
1138 When obtained from the `Box.local` property, indices are interpreted
1139 as relative to the parent box, and negative indices are relative to
1140 the end (like `~collections.abc.Sequence` indexing)::
1142 parent = Box.factory[3:6, -1:2]
1143 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1144 """
1146 def __init__(
1147 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1148 ) -> None:
1149 self._y = y
1150 self._x = x
1152 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1153 match key:
1154 case XY(x=x, y=y):
1155 return Box(y=self._y[y], x=self._x[x])
1156 case (y, x):
1157 return Box(y=self._y[y], x=self._x[x])
1158 case _:
1159 raise TypeError("Expected exactly two slices.")
1162Box.factory = BoxSliceFactory()
1165class Bounds(Protocol):
1166 """A protocol for objects that represent the validity region for a function
1167 defined in 2-d pixel coordinates.
1169 Notes
1170 -----
1171 Most objects natively have a simple 2-d bounding box as their bounds
1172 (typically the boundary of a sensor), and the `Box` class is hence the
1173 most common bounds implementation. But sometimes a large chunk of that
1174 box may be missing due to vignetting or bad amplifiers, and we may want to
1175 transform from one coordinate system to another. The Bounds interface is
1176 intended to handle both of these cases as well.
1177 """
1179 @property
1180 def bbox(self) -> Box: ... 1180 ↛ exitline 1180 didn't return from function 'bbox' because
1182 @overload
1183 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 1183 ↛ exitline 1183 didn't return from function 'contains' because
1185 @overload
1186 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 1186 ↛ exitline 1186 didn't return from function 'contains' because
1188 @overload
1189 def contains(self, /, *, x: int | float, y: int | float) -> bool: ... 1189 ↛ exitline 1189 didn't return from function 'contains' because
1191 @overload
1192 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ... 1192 ↛ exitline 1192 didn't return from function 'contains' because
1194 def contains(
1195 self,
1196 point: XY[Any] | YX[Any] | None = None,
1197 /,
1198 *,
1199 x: int | float | npt.ArrayLike | None = None,
1200 y: int | float | npt.ArrayLike | None = None,
1201 ) -> bool | np.ndarray:
1202 """Test whether one or more points fall within these bounds.
1204 Parameters
1205 ----------
1206 point
1207 An `XY` or `YX` coordinate pair to test for containment.
1208 Mutually exclusive with ``x`` and ``y``.
1209 x
1210 One or more X coordinates to test for containment, as a scalar or
1211 any array-like. Results are broadcast against ``y``.
1212 Mutually exclusive with ``point``.
1213 y
1214 One or more Y coordinates to test for containment, as a scalar or
1215 any array-like. Results are broadcast against ``x``.
1216 Mutually exclusive with ``point``.
1218 Returns
1219 -------
1220 `bool` | `numpy.ndarray`
1221 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1222 ``x`` and ``y`` are array-like, a boolean array with their
1223 broadcasted shape.
1224 """
1225 ...
1227 def intersection(self, other: Bounds) -> Bounds:
1228 """Compute the intersection of this bounds object with another.
1230 Parameters
1231 ----------
1232 other
1233 Bounds to intersect with this one.
1234 """
1235 ...
1237 def serialize(self) -> BoundsSerializationModel:
1238 """Convert a bounds instance into a serializable object.
1240 Notes
1241 -----
1242 The returned object must support direct nesting with Pydantic models
1243 and have a ``deserialize`` method (taking no arguments) that converts
1244 back to this `Bounds` type. It is common for `serialize` and
1245 ``deserialize`` to just return ``self``, when the bounds object is
1246 natively serializable.
1247 """
1248 ...
1251class BoundsError(ValueError):
1252 """Exception raised when an object is evaluated outside its bounds."""
1255class NoOverlapError(ValueError):
1256 """Exception raised when intervals or bounds do not overlap."""
1259if TYPE_CHECKING:
1261 def _test_types() -> None:
1262 interval = Interval(0, 10)
1263 box = Box(y=Interval(0, 10), x=Interval(0, 10))
1264 arr = np.zeros(3)
1266 # Interval.contains: scalar → bool, array-like → np.ndarray
1267 assert_type(interval.contains(5), bool)
1268 assert_type(interval.contains(arr), np.ndarray)
1270 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray
1271 assert_type(box.contains(box), bool)
1272 assert_type(box.contains(y=3, x=4), bool)
1273 assert_type(box.contains(y=3.0, x=4.0), bool)
1274 assert_type(box.contains(y=arr, x=arr), np.ndarray)
1275 assert_type(box.contains(XY(3, 4)), bool)
1276 assert_type(box.contains(YX(4, 3)), bool)
1277 assert_type(box.contains(XY(arr, arr)), np.ndarray)
1278 assert_type(box.contains(YX(arr, arr)), np.ndarray)
1280 # Bounds.contains (Protocol): XY/YX, scalar, array-like
1281 bounds: Bounds = box
1282 assert_type(bounds.contains(x=1, y=1), bool)
1283 assert_type(bounds.contains(x=1.0, y=1.0), bool)
1284 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
1285 assert_type(bounds.contains(XY(1, 1)), bool)
1286 assert_type(bounds.contains(YX(1, 1)), bool)
1287 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
1288 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)