Coverage for python/lsst/images/_geom.py: 91%
419 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -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 Raises
647 ------
648 TypeError
649 Raised if ``y`` or ``x`` is not an `Interval`.
651 Notes
652 -----
653 `Box` implements the necessary hooks to be included directly in a
654 `pydantic.BaseModel`, even though it is neither a built-in type nor a
655 Pydantic model itself.
656 """
658 def __init__(self, y: Interval, x: Interval) -> None:
659 match (y, x):
660 case (Interval(), Interval()):
661 self._intervals = YX(y, x)
662 case _:
663 raise TypeError(
664 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). "
665 "See the Box.factory slice syntax and the from_* class methods for other ways "
666 "to construct a Box."
667 )
669 __slots__ = ("_intervals",)
671 factory: ClassVar[BoxSliceFactory]
672 """A factory for creating boxes using slice syntax.
674 For example::
676 box = Box.factory[2:5, 3:9]
677 """
679 @classmethod
680 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
681 """Construct a box from its shape and optional start.
683 Parameters
684 ----------
685 shape
686 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
687 start
688 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
689 """
690 if start is None:
691 start = (0,) * len(shape)
692 match shape:
693 case XY(x=x_size, y=y_size):
694 pass
695 case [y_size, x_size]:
696 pass
697 case _:
698 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
699 match start:
700 case XY(x=x_start, y=y_start):
701 pass
702 case [y_start, x_start]:
703 pass
704 case _:
705 raise ValueError(f"Invalid sequence for start: {start!r}.")
706 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
708 @classmethod
709 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
710 """Construct a box from floating-point bounds ensuring that all the
711 are contained in the new box.
713 Parameters
714 ----------
715 x_min
716 Minimum X value.
717 x_max
718 Maximum X value.
719 y_min
720 Minimum Y value.
721 y_max
722 Maximum Y value.
724 Notes
725 -----
726 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
727 pixels whose centers lie within the bounds are included.
728 """
729 return Box.factory[
730 round_half_up(y_min) : round_half_down(y_max) + 1,
731 round_half_up(x_min) : round_half_down(x_max) + 1,
732 ]
734 @property
735 def min(self) -> YX[int]:
736 """The inclusive minimum bounds of the box, ordered ``(y, x)``
737 (`YX` [`int`]).
738 """
739 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
741 @property
742 def max(self) -> YX[int]:
743 """The inclusive maximum bounds of the box, ordered ``(y, x)``
744 (`YX` [`int`]).
745 """
746 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
748 @property
749 def start(self) -> YX[int]:
750 """Tuple holding the inclusive `Interval.start` bvound, ordered
751 ``(y, x)`` (`YX` [`int`]).
753 This is an alias for `min`, typically paired with `stop` for
754 half-exclusive ranges.
755 """
756 return YX(self.y.start, self.x.start)
758 @property
759 def stop(self) -> YX[int]:
760 """Tuple holding the exclusive `Interval.stop` bound, ordered
761 ``(y, x)`` (`YX` [`int`]).
763 The values in this tuple are one greater than those in `max`. It is
764 typically paired with `start` for half-exclusive ranges.
765 """
766 return YX(self.y.stop, self.x.stop)
768 @property
769 def shape(self) -> YX[int]:
770 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
771 (`YX` [`int`]).
772 """
773 return YX(self.y.size, self.x.size)
775 @property
776 def x(self) -> Interval:
777 """The x-dimension interval (`int`)."""
778 return self._intervals[-1]
780 @property
781 def y(self) -> Interval:
782 """The y-dimension interval (`int`)."""
783 return self._intervals[-2]
785 @property
786 def area(self) -> int:
787 """The number of pixels in the box (`int`)."""
788 return self.x.size * self.y.size
790 @property
791 def absolute(self) -> BoxSliceFactory:
792 """A factory for constructing a contained `Box` using slice
793 syntax and absolute coordinates.
795 Notes
796 -----
797 Slice bounds that are absent are replaced with the bounds of ``self``.
798 """
799 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
801 @property
802 def local(self) -> BoxSliceFactory:
803 """A factory for constructing a contained `Interval` using a slice
804 relative to the start of this one (`BoxSliceFactory`).
806 Notes
807 -----
808 This factory interprets slices as "local" coordinates, in which ``0``
809 corresponds to ``self.start``. Negative bounds are relative to
810 ``self.stop``, as is usually the case for Python sequences.
811 """
812 return BoxSliceFactory(y=self.y.local, x=self.x.local)
814 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
815 """Return a pair of 2-d arrays of the coordinate values of the box.
817 Parameters
818 ----------
819 n
820 Number of points in each dimension. If a sequence, points are
821 assumed to be ordered ``(x, y)`` unless a `YX` instance is
822 provided.
823 step
824 Set ``n`` such that the distance between points is equal to or
825 just less than this in each dimension. Mutually exclusive with
826 ``n``.
828 Returns
829 -------
830 `XY` [`numpy.ndarray`]
831 A pair of arrays, each of which is 2-d with floating-point values.
833 See Also
834 --------
835 numpy.meshgrid
836 """
837 if n is not None and step is not None:
838 raise TypeError("'n' and 'step' cannot both be provided.")
839 match n:
840 case int():
841 ax = self.x.linspace(n)
842 ay = self.y.linspace(n)
843 case YX(y=ny, x=nx):
844 ax = self.x.linspace(nx)
845 ay = self.y.linspace(ny)
846 case [nx, ny]:
847 ax = self.x.linspace(nx)
848 ay = self.y.linspace(ny)
849 case None:
850 ax = self.x.linspace(step=step)
851 ay = self.y.linspace(step=step)
852 case _:
853 raise ValueError(f"Unexpected values for n ({n})")
854 return XY(*np.meshgrid(ax, ay))
856 def padded(self, padding: int) -> Box:
857 """Return a new box expanded by the given padding on
858 all sides.
860 Parameters
861 ----------
862 padding
863 Number of pixels to expand the box by on every side.
864 """
865 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
867 def __eq__(self, other: object) -> bool:
868 if type(other) is Box:
869 return self._intervals == other._intervals
870 return False
872 def __str__(self) -> str:
873 return f"[y={self.y}, x={self.x}]"
875 def __repr__(self) -> str:
876 return f"Box(y={self.y!r}, x={self.x!r})"
878 @overload
879 def contains(self, other: Box, /) -> bool: ... 879 ↛ exitline 879 didn't return from function 'contains' because
881 @overload
882 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 882 ↛ exitline 882 didn't return from function 'contains' because
884 @overload
885 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 885 ↛ exitline 885 didn't return from function 'contains' because
887 @overload
888 def contains(self, /, *, y: int | float, x: int | float) -> bool: ... 888 ↛ exitline 888 didn't return from function 'contains' because
890 @overload
891 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ... 891 ↛ exitline 891 didn't return from function 'contains' because
893 def contains(
894 self,
895 other: Box | XY[Any] | YX[Any] | None = None,
896 /,
897 *,
898 y: Any = None,
899 x: Any = None,
900 ) -> bool | np.ndarray:
901 """Test whether this box fully contains another or one or more points.
903 Parameters
904 ----------
905 other
906 Another box, or an `XY` or `YX` coordinate pair, to compare to.
907 Mutually exclusive with ``x`` and ``y``.
908 y
909 One or more Y coordinates to test for containment, as a scalar or
910 any array-like. Results are broadcast against ``x``.
911 Mutually exclusive with ``other``.
912 x
913 One or more X coordinates to test for containment, as a scalar or
914 any array-like. Results are broadcast against ``y``.
915 Mutually exclusive with ``other``.
917 Returns
918 -------
919 `bool` | `numpy.ndarray`
920 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
921 single `bool` value. If ``x`` and ``y`` are array-like, a boolean
922 array with their broadcasted shape.
924 Notes
925 -----
926 In order to yield the desired behavior for floating-point arguments,
927 points are actually tested against an interval that is 0.5 larger on
928 both sides: this makes positions within the outer boundary of pixels
929 (but beyond the centers of those pixels, which have integer positions)
930 appear "on the image".
931 """
932 match other:
933 case None:
934 if x is None or y is None:
935 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.")
936 case Box():
937 if x is not None or y is not None:
938 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
939 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
940 case XY() | YX(): 940 ↛ 944line 940 didn't jump to line 944 because the pattern on line 940 always matched
941 if x is not None or y is not None:
942 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
943 x, y = other.x, other.y
944 case _:
945 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
946 result = np.logical_and(self.x.contains(x), self.y.contains(y))
947 if not result.shape:
948 return bool(result)
949 return result
951 @overload
952 def intersection(self, other: Box) -> Box: ... 952 ↛ exitline 952 didn't return from function 'intersection' because
954 @overload
955 def intersection(self, other: Region) -> Region | Box: ... 955 ↛ exitline 955 didn't return from function 'intersection' because
957 @overload
958 def intersection(self, other: Bounds) -> Bounds: ... 958 ↛ exitline 958 didn't return from function 'intersection' because
960 def intersection(self, other: Bounds) -> Bounds:
961 """Return a bounds object that is contained by both ``self`` and
962 ``other``.
964 When there is no overlap, `NoOverlapError` is raised.
966 Parameters
967 ----------
968 other
969 Bounds to intersect with this one.
970 """
971 from ._concrete_bounds import _intersect_box
973 return _intersect_box(self, other)
975 def dilated_by(self, padding: int) -> Box:
976 """Return a new box padded by the given amount on all sides.
978 Parameters
979 ----------
980 padding
981 Number of pixels to pad the box by on every side.
982 """
983 return Box(*[i.dilated_by(padding) for i in self._intervals])
985 def slice_within(self, other: Box) -> YX[slice]:
986 """Return a `tuple` of `slice` objects that correspond to the
987 positions in this box when the items of the container being sliced
988 correspond to ``other``.
990 This assumes ``other.contains(self)``.
992 Parameters
993 ----------
994 other
995 Box that the sliced container's items correspond to.
996 """
997 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
999 @property
1000 def bbox(self) -> Box:
1001 """The box itself (`Box`).
1003 This is provided for compatibility with the `Bounds` interface.
1004 """
1005 return self
1007 def boundary(self) -> Iterator[YX[int]]:
1008 """Iterate over the corners of the box as ``(y, x)`` tuples.
1010 Yields
1011 ------
1012 corner
1013 Each corner in turn.
1014 """
1015 if len(self._intervals) != 2: 1015 ↛ 1016line 1015 didn't jump to line 1016 because the condition on line 1015 was never true
1016 raise TypeError("Box is not 2-d.")
1017 yield YX(self.y.min, self.x.min)
1018 yield YX(self.y.min, self.x.max)
1019 yield YX(self.y.max, self.x.max)
1020 yield YX(self.y.max, self.x.min)
1022 def to_polygon(self) -> Polygon:
1023 """Convert the box to a polygon with floating-point vertices.
1025 Notes
1026 -----
1027 Because the integer min and max coordinates of a box are
1028 interpreted as pixel centers, these are expanded by 0.5 on all sides
1029 before using them to form the polygon vertices.
1030 """
1031 from ._polygon import Polygon
1033 return Polygon.from_box(self)
1035 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1036 """Apply a coordinate transform to the box, returning a polygon.
1038 Parameters
1039 ----------
1040 transform
1041 Coordinate transform to apply (in the forward direction).
1043 Notes
1044 -----
1045 This transforms the polygon representation of the box (see
1046 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1047 full pixels before transforming them.
1048 """
1049 return self.to_polygon().transform(transform)
1051 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1052 return (Box, self._intervals)
1054 @classmethod
1055 def from_legacy(cls, legacy: Any) -> Box:
1056 """Convert from an `lsst.geom.Box2I` instance.
1058 Parameters
1059 ----------
1060 legacy
1061 Legacy `lsst.geom.Box2I` to convert.
1062 """
1063 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1065 def to_legacy(self) -> Any:
1066 """Convert to an `lsst.geom.BoxI` instance."""
1067 from lsst.geom import Box2I
1069 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1071 @classmethod
1072 def __get_pydantic_core_schema__(
1073 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1074 ) -> pcs.CoreSchema:
1075 from_typed_dict = pcs.chain_schema(
1076 [
1077 handler(_SerializedBox),
1078 pcs.no_info_plain_validator_function(cls._validate),
1079 ]
1080 )
1081 return pcs.json_or_python_schema(
1082 json_schema=from_typed_dict,
1083 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1084 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1085 )
1087 @classmethod
1088 def __get_pydantic_json_schema__(
1089 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1090 ) -> JsonSchemaValue:
1091 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1093 @classmethod
1094 def _validate(cls, data: _SerializedBox) -> Box:
1095 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1097 def _serialize(self) -> _SerializedBox:
1098 return {"y": self.y._serialize(), "x": self.x._serialize()}
1100 def serialize(self) -> Box:
1101 """Return a Pydantic-friendly representation of this object.
1103 This method just returns the `Box` itself, since that already provides
1104 Pydantic serialization hooks. It exists for compatibility with the
1105 `Bounds` protocol.
1106 """
1107 return self
1109 def deserialize(self) -> Box:
1110 """Deserialize a bounds object on the assumption it is a `Box`.
1112 This method just returns the `Box` itself, since that already provides
1113 Pydantic serialization hooks. It exists for compatibility with the
1114 `Bounds` protocol.
1115 """
1116 return self
1119class BoxSliceFactory:
1120 """A factory for `Box` objects using array-slice syntax.
1122 Parameters
1123 ----------
1124 y
1125 Slice factory used for the y axis.
1126 x
1127 Slice factory used for the x axis.
1129 Notes
1130 -----
1131 When `Box.factory` is indexed with a pair of slices, this returns a
1132 `Box` with exactly those bounds::
1134 assert (
1135 Box.factory[3:6, -1:2]
1136 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1137 )
1139 A missing start bound is replaced by ``0``, but a missing stop bound is
1140 not allowed.
1142 When obtained from the `Box.absolute` property, indices are absolute
1143 coordinate values, but any omitted bounds are replaced with the parent
1144 box's bounds::
1146 parent = Box.factory[3:6, -1:2]
1147 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1149 The final box is also checked to be contained by the parent box.
1151 When obtained from the `Box.local` property, indices are interpreted
1152 as relative to the parent box, and negative indices are relative to
1153 the end (like `~collections.abc.Sequence` indexing)::
1155 parent = Box.factory[3:6, -1:2]
1156 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1157 """
1159 def __init__(
1160 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1161 ) -> None:
1162 self._y = y
1163 self._x = x
1165 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1166 match key:
1167 case XY(x=x, y=y):
1168 return Box(y=self._y[y], x=self._x[x])
1169 case (y, x):
1170 return Box(y=self._y[y], x=self._x[x])
1171 case _:
1172 raise TypeError("Expected exactly two slices.")
1175Box.factory = BoxSliceFactory()
1178class Bounds(Protocol):
1179 """A protocol for objects that represent the validity region for a function
1180 defined in 2-d pixel coordinates.
1182 Notes
1183 -----
1184 Most objects natively have a simple 2-d bounding box as their bounds
1185 (typically the boundary of a sensor), and the `Box` class is hence the
1186 most common bounds implementation. But sometimes a large chunk of that
1187 box may be missing due to vignetting or bad amplifiers, and we may want to
1188 transform from one coordinate system to another. The Bounds interface is
1189 intended to handle both of these cases as well.
1190 """
1192 @property
1193 def bbox(self) -> Box: ... 1193 ↛ exitline 1193 didn't return from function 'bbox' because
1195 @overload
1196 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 1196 ↛ exitline 1196 didn't return from function 'contains' because
1198 @overload
1199 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 1199 ↛ exitline 1199 didn't return from function 'contains' because
1201 @overload
1202 def contains(self, /, *, x: int | float, y: int | float) -> bool: ... 1202 ↛ exitline 1202 didn't return from function 'contains' because
1204 @overload
1205 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ... 1205 ↛ exitline 1205 didn't return from function 'contains' because
1207 def contains(
1208 self,
1209 point: XY[Any] | YX[Any] | None = None,
1210 /,
1211 *,
1212 x: int | float | npt.ArrayLike | None = None,
1213 y: int | float | npt.ArrayLike | None = None,
1214 ) -> bool | np.ndarray:
1215 """Test whether one or more points fall within these bounds.
1217 Parameters
1218 ----------
1219 point
1220 An `XY` or `YX` coordinate pair to test for containment.
1221 Mutually exclusive with ``x`` and ``y``.
1222 x
1223 One or more X coordinates to test for containment, as a scalar or
1224 any array-like. Results are broadcast against ``y``.
1225 Mutually exclusive with ``point``.
1226 y
1227 One or more Y coordinates to test for containment, as a scalar or
1228 any array-like. Results are broadcast against ``x``.
1229 Mutually exclusive with ``point``.
1231 Returns
1232 -------
1233 `bool` | `numpy.ndarray`
1234 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1235 ``x`` and ``y`` are array-like, a boolean array with their
1236 broadcasted shape.
1237 """
1238 ...
1240 def intersection(self, other: Bounds) -> Bounds:
1241 """Compute the intersection of this bounds object with another.
1243 Parameters
1244 ----------
1245 other
1246 Bounds to intersect with this one.
1247 """
1248 ...
1250 def serialize(self) -> BoundsSerializationModel:
1251 """Convert a bounds instance into a serializable object.
1253 Notes
1254 -----
1255 The returned object must support direct nesting with Pydantic models
1256 and have a ``deserialize`` method (taking no arguments) that converts
1257 back to this `Bounds` type. It is common for `serialize` and
1258 ``deserialize`` to just return ``self``, when the bounds object is
1259 natively serializable.
1260 """
1261 ...
1264class BoundsError(ValueError):
1265 """Exception raised when an object is evaluated outside its bounds."""
1268class NoOverlapError(ValueError):
1269 """Exception raised when intervals or bounds do not overlap."""
1272if TYPE_CHECKING:
1274 def _test_types() -> None:
1275 interval = Interval(0, 10)
1276 box = Box(y=Interval(0, 10), x=Interval(0, 10))
1277 arr = np.zeros(3)
1279 # Interval.contains: scalar → bool, array-like → np.ndarray
1280 assert_type(interval.contains(5), bool)
1281 assert_type(interval.contains(arr), np.ndarray)
1283 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray
1284 assert_type(box.contains(box), bool)
1285 assert_type(box.contains(y=3, x=4), bool)
1286 assert_type(box.contains(y=3.0, x=4.0), bool)
1287 assert_type(box.contains(y=arr, x=arr), np.ndarray)
1288 assert_type(box.contains(XY(3, 4)), bool)
1289 assert_type(box.contains(YX(4, 3)), bool)
1290 assert_type(box.contains(XY(arr, arr)), np.ndarray)
1291 assert_type(box.contains(YX(arr, arr)), np.ndarray)
1293 # Bounds.contains (Protocol): XY/YX, scalar, array-like
1294 bounds: Bounds = box
1295 assert_type(bounds.contains(x=1, y=1), bool)
1296 assert_type(bounds.contains(x=1.0, y=1.0), bool)
1297 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
1298 assert_type(bounds.contains(XY(1, 1)), bool)
1299 assert_type(bounds.contains(YX(1, 1)), bool)
1300 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
1301 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)