Coverage for python/lsst/images/_geom.py: 92%
397 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:35 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-09 09:35 +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 JsonSchemaValue
45from .utils import round_half_down, round_half_up
47if TYPE_CHECKING:
48 from ._concrete_bounds import BoundsSerializationModel
49 from ._polygon import Polygon, Region
50 from ._transforms import Transform
52 try:
53 from lsst.geom import Extent2D as LegacyExtent2D
54 from lsst.geom import Extent2I as LegacyExtent2I
55 from lsst.geom import Point2D as LegacyPoint2D
56 from lsst.geom import Point2I as LegacyPoint2I
57 except ImportError:
58 type LegacyExtent2I = Any # type: ignore[no-redef]
59 type LegacyPoint2I = Any # type: ignore[no-redef]
60 type LegacyExtent2D = Any # type: ignore[no-redef]
61 type LegacyPoint2D = Any # type: ignore[no-redef]
63# This pre-python-3.12 declaration is needed by Sphinx (probably the
64# autodoc-typehints plugin.
65T = TypeVar("T")
67# Interval and Box are defined as regular Python classes rather than
68# dataclasses or Pydantic models because we might want to implement them as
69# compiled-extension types in the future, and we want that to be transparent.
71# In a similar vein, we avoid declaring specific types for multidimensional
72# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
73# shapes) in order to leave room for more fully-featured types to be added
74# upstream of this package in the future.
77class YX[T](NamedTuple):
78 """A pair of per-dimension objects, ordered ``(y, x)``.
80 Notes
81 -----
82 `YX` is used for slices, shapes, and other 2-d pairs when the most
83 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
84 arithmetic operations behave as they would on a
85 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
86 concatenates).
88 See Also
89 --------
90 XY
91 """
93 y: T
94 """The y / row object."""
96 x: T
97 """The x / column object."""
99 @property
100 def xy(self) -> XY:
101 """A tuple of the same objects in the opposite order."""
102 return XY(x=self.x, y=self.y)
104 def map[U](self, func: Callable[[T], U]) -> YX[U]:
105 """Apply a function to both objects.
107 Parameters
108 ----------
109 func
110 Callable applied to each of the two objects in turn.
111 """
112 return YX(y=func(self.y), x=func(self.x))
114 def to_legacy_int_extent(self) -> LegacyExtent2I:
115 """Convert to a legacy `lsst.geom.Extent2I` object."""
116 from lsst.geom import Extent2I as LegacyExtent2I
118 return LegacyExtent2I(self.x, self.y)
120 def to_legacy_int_point(self) -> LegacyPoint2I:
121 """Convert to a legacy `lsst.geom.Point2I` object."""
122 from lsst.geom import Point2I as LegacyPoint2I
124 return LegacyPoint2I(self.x, self.y)
126 def to_legacy_float_extent(self) -> LegacyExtent2D:
127 """Convert to a legacy `lsst.geom.Extent2D` object."""
128 from lsst.geom import Extent2D as LegacyExtent2D
130 return LegacyExtent2D(self.x, self.y)
132 def to_legacy_float_point(self) -> LegacyPoint2D:
133 """Convert to a legacy `lsst.geom.Point2D` object."""
134 from lsst.geom import Point2D as LegacyPoint2D
136 return LegacyPoint2D(self.x, self.y)
139class XY[T](NamedTuple):
140 """A pair of per-dimension objects, ordered ``(x, y)``.
142 Notes
143 -----
144 `XY` is used for points and other 2-d pairs when the most natural ordering
145 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
146 behave as they would on a `collections.abc.Sequence`, not a mathematical
147 vector (e.g. adding concatenates).
149 See Also
150 --------
151 YX
152 """
154 x: T
155 """The x / column object."""
157 y: T
158 """The y / row object."""
160 @property
161 def yx(self) -> YX:
162 """A tuple of the same objects in the opposite order."""
163 return YX(y=self.y, x=self.x)
165 def map[U](self, func: Callable[[T], U]) -> XY[U]:
166 """Apply a function to both objects.
168 Parameters
169 ----------
170 func
171 Callable applied to each of the two objects in turn.
172 """
173 return XY(x=func(self.x), y=func(self.y))
175 def to_legacy_int_extent(self) -> LegacyExtent2I:
176 """Convert to a legacy `lsst.geom.Extent2I` object."""
177 from lsst.geom import Extent2I as LegacyExtent2I
179 return LegacyExtent2I(self.x, self.y)
181 def to_legacy_int_point(self) -> LegacyPoint2I:
182 """Convert to a legacy `lsst.geom.Point2I` object."""
183 from lsst.geom import Point2I as LegacyPoint2I
185 return LegacyPoint2I(self.x, self.y)
187 def to_legacy_float_extent(self) -> LegacyExtent2D:
188 """Convert to a legacy `lsst.geom.Extent2D` object."""
189 from lsst.geom import Extent2D as LegacyExtent2D
191 return LegacyExtent2D(self.x, self.y)
193 def to_legacy_float_point(self) -> LegacyPoint2D:
194 """Convert to a legacy `lsst.geom.Point2D` object."""
195 from lsst.geom import Point2D as LegacyPoint2D
197 return LegacyPoint2D(self.x, self.y)
200class _SerializedInterval(TypedDict):
201 start: int
202 stop: int
205@final
206class Interval:
207 """A 1-d integer interval with positive size.
209 Parameters
210 ----------
211 start
212 Inclusive minimum point in the interval.
213 stop
214 One past the maximum point in the interval.
216 Notes
217 -----
218 Adding or subtracting an `int` from an interval returns a shifted interval.
220 `Interval` implements the necessary hooks to be included directly in a
221 `pydantic.BaseModel`, even though it is neither a built-in type nor a
222 Pydantic model itself.
223 """
225 def __init__(self, start: int, stop: int) -> None:
226 # Coerce to be defensive against numpy int scalars.
227 self._start = int(start)
228 self._stop = int(stop)
229 if not (self._stop > self._start):
230 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
232 __slots__ = ("_start", "_stop")
234 factory: ClassVar[IntervalSliceFactory]
235 """A factory for creating intervals using slice syntax.
237 For example::
239 interval = Interval.factory[2:5]
240 """
242 @classmethod
243 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
244 """Construct an interval that includes all of the given points and/or
245 intervals.
247 Parameters
248 ----------
249 first
250 First point or interval to include in the hull.
251 *args
252 Additional points and/or intervals to include in the hull.
253 """
254 if type(first) is Interval:
255 rmin = first.min
256 rmax = first.max
257 else:
258 rmin = rmax = first
259 for arg in args:
260 if type(arg) is Interval:
261 rmin = min(rmin, arg.min)
262 rmax = max(rmax, arg.max)
263 else:
264 rmin = min(rmin, arg)
265 rmax = max(rmax, arg)
266 return Interval(start=rmin, stop=rmax + 1)
268 @classmethod
269 def from_size(cls, size: int, start: int = 0) -> Interval:
270 """Construct an interval from its size and optional start.
272 Parameters
273 ----------
274 size
275 Number of points in the interval.
276 start
277 Inclusive minimum point in the interval.
278 """
279 return cls(start=start, stop=start + size)
281 @property
282 def min(self) -> int:
283 """Inclusive minimum point in the interval (`int`)."""
284 return self.start
286 @property
287 def max(self) -> int:
288 """Inclusive maximum point in the interval (`int`)."""
289 return self.stop - 1
291 @property
292 def start(self) -> int:
293 """Inclusive minimum point in the interval (`int`)."""
294 return self._start
296 @property
297 def stop(self) -> int:
298 """One past the maximum point in the interval (`int`)."""
299 return self._stop
301 @property
302 def size(self) -> int:
303 """Size of the interval (`int`)."""
304 return self.stop - self.start
306 @property
307 def range(self) -> __builtins__.range:
308 """An iterable over all values in the interval
309 (`__builtins__.range`).
310 """
311 return range(self.start, self.stop)
313 @property
314 def arange(self) -> np.ndarray:
315 """An array of all the values in the interval (`numpy.ndarray`).
317 Array values are integers.
318 """
319 return np.arange(self.start, self.stop)
321 @property
322 def absolute(self) -> IntervalSliceFactory:
323 """A factory for constructing a contained `Interval` using slice
324 syntax and absolute coordinates.
326 Notes
327 -----
328 Slice bounds that are absent are replaced with the bounds of ``self``.
329 """
330 return IntervalSliceFactory(self, is_local=False)
332 @property
333 def local(self) -> IntervalSliceFactory:
334 """A factory for constructing a contained `Interval` using a slice
335 relative to the start of this one (`IntervalSliceFactory`).
337 Notes
338 -----
339 This factory interprets slices as "local" coordinates, in which ``0``
340 corresponds to ``self.start``. Negative bounds are relative to
341 ``self.stop``, as is usually the case for Python sequences.
342 """
343 return IntervalSliceFactory(self, is_local=True)
345 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
346 """Return an array of values that spans the interval.
348 Parameters
349 ----------
350 n
351 How many values to return. The default (if ``step`` is also not
352 provided) is the size of the interval, i.e. equivalent to the
353 `arange` property (but converted to `float`).
354 step
355 Set ``n`` such that the distance between points is equal to or
356 just less than this. Mutually exclusive with ``n``.
358 Returns
359 -------
360 numpy.ndarray
361 Array of `float` values.
363 See Also
364 --------
365 numpy.linspace
366 """
367 if n is None:
368 if step is None:
369 return self.arange.astype(np.float64)
370 n = math.ceil(self.size / step)
371 elif step is not None:
372 raise TypeError("'n' and 'step' cannot both be provided.")
373 return np.linspace(self.min, self.max, n, dtype=np.float64)
375 @property
376 def center(self) -> float:
377 """The center of the interval (`float`)."""
378 return 0.5 * (self.min + self.max)
380 def padded(self, padding: int) -> Interval:
381 """Return a new interval expanded by the given padding on
382 either side.
384 Parameters
385 ----------
386 padding
387 Number of points to add to each side of the interval.
388 """
389 return Interval(self.start - padding, self.stop + padding)
391 def __str__(self) -> str:
392 return f"{self.start}:{self.stop}"
394 def __repr__(self) -> str:
395 return f"Interval(start={self.start}, stop={self.stop})"
397 def __eq__(self, other: object) -> bool:
398 if type(other) is Interval:
399 return self._start == other._start and self._stop == other._stop
400 return False
402 def __add__(self, other: int) -> Interval:
403 return Interval(start=self.start + other, stop=self.stop + other)
405 def __sub__(self, other: int) -> Interval:
406 return Interval(start=self.start - other, stop=self.stop - other)
408 def __contains__(self, x: int) -> bool:
409 return x >= self.start and x < self.stop
411 @overload
412 def contains(self, other: Interval | int | float) -> bool: ... 412 ↛ exitline 412 didn't return from function 'contains' because
414 @overload
415 def contains(self, other: np.ndarray) -> np.ndarray: ... 415 ↛ exitline 415 didn't return from function 'contains' because
417 def contains(self, other: Interval | int | float | np.ndarray) -> bool | np.ndarray:
418 """Test whether this interval fully contains another or one or more
419 points.
421 Parameters
422 ----------
423 other
424 Another interval to compare to, or one or more position values.
426 Returns
427 -------
428 `bool` | `numpy.ndarray`
429 If a single interval or value was passed, a single `bool`. If an
430 array was passed, an array with the same shape.
432 Notes
433 -----
434 In order to yield the desired behavior for floating-point arguments,
435 points are actually tested against an interval that is 0.5 larger on
436 both sides: this makes positions within the outer boundary of pixels
437 (but beyond the centers of those pixels, which have integer positions)
438 appear "on the image".
439 """
440 if isinstance(other, Interval):
441 return self.start <= other.start and self.stop >= other.stop
442 else:
443 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
444 if not result.shape:
445 return bool(result)
446 return result
448 def intersection(self, other: Interval) -> Interval:
449 """Return an interval that is contained by both ``self`` and ``other``.
451 When there is no overlap between the intervals, `NoOverlapError` is
452 raised.
454 Parameters
455 ----------
456 other
457 Interval to intersect with this one.
458 """
459 new_start = max(self.start, other.start)
460 new_stop = min(self.stop, other.stop)
461 if new_start < new_stop:
462 return Interval(start=new_start, stop=new_stop)
463 raise NoOverlapError(f"No overlap between {self} and {other}.")
465 def dilated_by(self, padding: int) -> Interval:
466 """Return a new interval padded by the given amount on both sides.
468 Parameters
469 ----------
470 padding
471 Number of points to add to each side of the interval.
472 """
473 return Interval(start=self._start - padding, stop=self._stop + padding)
475 def slice_within(self, other: Interval) -> slice:
476 """Return the `slice` that corresponds to the values in this interval
477 when the items of the container being sliced correspond to ``other``.
479 This assumes ``other.contains(self)``.
481 Parameters
482 ----------
483 other
484 Interval whose values correspond to the container being sliced.
485 """
486 if not other.contains(self):
487 raise IndexError(
488 f"Can not calculate a slice of {other} within {self} "
489 "since the given interval does not contain this one."
490 )
491 return slice(self.start - other.start, self.stop - other.start)
493 @classmethod
494 def from_legacy(cls, legacy: Any) -> Interval:
495 """Convert from an `lsst.geom.IntervalI` instance.
497 Parameters
498 ----------
499 legacy
500 Legacy `lsst.geom.IntervalI` instance to convert.
501 """
502 return cls(legacy.begin, legacy.end)
504 def to_legacy(self) -> Any:
505 """Convert to an `lsst.geom.IntervalI` instance."""
506 from lsst.geom import IntervalI
508 return IntervalI(min=self.min, max=self.max)
510 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
511 return (
512 Interval,
513 (
514 self._start,
515 self._stop,
516 ),
517 )
519 @classmethod
520 def __get_pydantic_core_schema__(
521 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
522 ) -> pcs.CoreSchema:
523 from_typed_dict = pcs.chain_schema(
524 [
525 handler(_SerializedInterval),
526 pcs.no_info_plain_validator_function(cls._validate),
527 ]
528 )
529 return pcs.json_or_python_schema(
530 json_schema=from_typed_dict,
531 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
532 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
533 )
535 @classmethod
536 def __get_pydantic_json_schema__(
537 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
538 ) -> JsonSchemaValue:
539 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
541 @classmethod
542 def _validate(cls, data: _SerializedInterval) -> Interval:
543 return cls(**data)
545 def _serialize(self) -> _SerializedInterval:
546 return {"start": self._start, "stop": self._stop}
549class IntervalSliceFactory:
550 """A factory for `Interval` objects using array-slice syntax.
552 Parameters
553 ----------
554 parent
555 Interval that constructed intervals must be contained by, or `None`
556 to allow any bounds.
557 is_local
558 Whether slice bounds are interpreted relative to the start of
559 ``parent`` rather than as absolute coordinates.
561 Notes
562 -----
563 When indexed with a single slice on the `Interval.factory` attribute, this
564 returns an `Interval` with exactly the given bounds::
566 assert Interval.factory[3:6] == Interval(start=3, stop=6)
568 A missing start bound is replaced by ``0``, but a missing stop bound is
569 not allowed.
571 When obtained from the `Interval.absolute` property, indices are absolute
572 coordinate values, but any omitted bounds are replaced with the parent
573 interval's bounds::
575 parent = Interval.factory[3:6]
576 assert Interval.factory[4:5] == parent.absolute[:5]
578 The final interval is also checked to be contained by the parent interval.
580 When obtained from the `Interval.local` property, indices are interpreted
581 as relative to the parent interval, and negative indices are relative to
582 the end (like `~collections.abc.Sequence` indexing)::
584 parent = Interval.factory[3:6]
585 assert Interval.factory[4:5] == parent.local[1:-1]
587 When the stop bound is greater than the size of the parent interval, the
588 returned interval is clipped to be contained by the parent (as in
589 `~collections.abc.Sequence` indexing).
590 """
592 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
593 self._parent = parent
594 self._is_local = is_local
596 def __getitem__(self, s: slice) -> Interval:
597 if s.step is not None and s.step != 1:
598 raise ValueError(f"Slice {s} has non-unit step.")
599 if self._is_local:
600 assert self._parent is not None, "is_local=True requires a parent interval"
601 start, stop, _ = s.indices(self._parent.size)
602 start += self._parent.start
603 stop += self._parent.start
604 else:
605 start = s.start
606 stop = s.stop
607 if start is None:
608 if self._parent is None:
609 start = 0
610 else:
611 start = self._parent.start
612 if stop is None:
613 if self._parent is None: 613 ↛ 614line 613 didn't jump to line 614 because the condition on line 613 was never true
614 raise IndexError("An Interval cannot have an empty upper bound.")
615 stop = self._parent.stop
616 if self._parent is not None:
617 if start < self._parent.start:
618 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
619 if stop > self._parent.stop:
620 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
621 return Interval(start=start, stop=stop)
624Interval.factory = IntervalSliceFactory()
627class _SerializedBox(TypedDict):
628 y: _SerializedInterval
629 x: _SerializedInterval
632class Box:
633 """An axis-aligned 2-d rectangular region.
635 Parameters
636 ----------
637 y
638 Interval for the y dimension.
639 x
640 Interval for the x dimension.
642 Notes
643 -----
644 `Box` implements the necessary hooks to be included directly in a
645 `pydantic.BaseModel`, even though it is neither a built-in type nor a
646 Pydantic model itself.
647 """
649 def __init__(self, y: Interval, x: Interval) -> None:
650 self._intervals = YX(y, x)
652 __slots__ = ("_intervals",)
654 factory: ClassVar[BoxSliceFactory]
655 """A factory for creating boxes using slice syntax.
657 For example::
659 box = Box.factory[2:5, 3:9]
660 """
662 @classmethod
663 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
664 """Construct a box from its shape and optional start.
666 Parameters
667 ----------
668 shape
669 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
670 start
671 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
672 """
673 if start is None:
674 start = (0,) * len(shape)
675 match shape:
676 case XY(x=x_size, y=y_size):
677 pass
678 case [y_size, x_size]:
679 pass
680 case _:
681 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
682 match start:
683 case XY(x=x_start, y=y_start):
684 pass
685 case [y_start, x_start]:
686 pass
687 case _:
688 raise ValueError(f"Invalid sequence for start: {start!r}.")
689 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
691 @classmethod
692 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
693 """Construct a box from floating-point bounds ensuring that all the
694 are contained in the new box.
696 Parameters
697 ----------
698 x_min
699 Minimum X value.
700 x_max
701 Maximum X value.
702 y_min
703 Minimum Y value.
704 y_max
705 Maximum Y value.
707 Notes
708 -----
709 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
710 pixels whose centers lie within the bounds are included.
711 """
712 return Box.factory[
713 round_half_up(y_min) : round_half_down(y_max) + 1,
714 round_half_up(x_min) : round_half_down(x_max) + 1,
715 ]
717 @property
718 def min(self) -> YX[int]:
719 """The inclusive minimum bounds of the box, ordered ``(y, x)``
720 (`YX` [`int`]).
721 """
722 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
724 @property
725 def max(self) -> YX[int]:
726 """The inclusive maximum bounds of the box, ordered ``(y, x)``
727 (`YX` [`int`]).
728 """
729 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
731 @property
732 def start(self) -> YX[int]:
733 """Tuple holding the inclusive `Interval.start` bvound, ordered
734 ``(y, x)`` (`YX` [`int`]).
736 This is an alias for `min`, typically paired with `stop` for
737 half-exclusive ranges.
738 """
739 return YX(self.y.start, self.x.start)
741 @property
742 def stop(self) -> YX[int]:
743 """Tuple holding the exclusive `Interval.stop` bound, ordered
744 ``(y, x)`` (`YX` [`int`]).
746 The values in this tuple are one greater than those in `max`. It is
747 typically paired with `start` for half-exclusive ranges.
748 """
749 return YX(self.y.stop, self.x.stop)
751 @property
752 def shape(self) -> YX[int]:
753 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
754 (`YX` [`int`]).
755 """
756 return YX(self.y.size, self.x.size)
758 @property
759 def x(self) -> Interval:
760 """The x-dimension interval (`int`)."""
761 return self._intervals[-1]
763 @property
764 def y(self) -> Interval:
765 """The y-dimension interval (`int`)."""
766 return self._intervals[-2]
768 @property
769 def area(self) -> int:
770 """The number of pixels in the box (`int`)."""
771 return self.x.size * self.y.size
773 @property
774 def absolute(self) -> BoxSliceFactory:
775 """A factory for constructing a contained `Box` using slice
776 syntax and absolute coordinates.
778 Notes
779 -----
780 Slice bounds that are absent are replaced with the bounds of ``self``.
781 """
782 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
784 @property
785 def local(self) -> BoxSliceFactory:
786 """A factory for constructing a contained `Interval` using a slice
787 relative to the start of this one (`BoxSliceFactory`).
789 Notes
790 -----
791 This factory interprets slices as "local" coordinates, in which ``0``
792 corresponds to ``self.start``. Negative bounds are relative to
793 ``self.stop``, as is usually the case for Python sequences.
794 """
795 return BoxSliceFactory(y=self.y.local, x=self.x.local)
797 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
798 """Return a pair of 2-d arrays of the coordinate values of the box.
800 Parameters
801 ----------
802 n
803 Number of points in each dimension. If a sequence, points are
804 assumed to be ordered ``(x, y)`` unless a `YX` instance is
805 provided.
806 step
807 Set ``n`` such that the distance between points is equal to or
808 just less than this in each dimension. Mutually exclusive with
809 ``n``.
811 Returns
812 -------
813 `XY` [`numpy.ndarray`]
814 A pair of arrays, each of which is 2-d with floating-point values.
816 See Also
817 --------
818 numpy.meshgrid
819 """
820 if n is not None and step is not None:
821 raise TypeError("'n' and 'step' cannot both be provided.")
822 match n:
823 case int():
824 ax = self.x.linspace(n)
825 ay = self.y.linspace(n)
826 case YX(y=ny, x=nx):
827 ax = self.x.linspace(nx)
828 ay = self.y.linspace(ny)
829 case [nx, ny]:
830 ax = self.x.linspace(nx)
831 ay = self.y.linspace(ny)
832 case None:
833 ax = self.x.linspace(step=step)
834 ay = self.y.linspace(step=step)
835 case _:
836 raise ValueError(f"Unexpected values for n ({n})")
837 return XY(*np.meshgrid(ax, ay))
839 def padded(self, padding: int) -> Box:
840 """Return a new box expanded by the given padding on
841 all sides.
843 Parameters
844 ----------
845 padding
846 Number of pixels to expand the box by on every side.
847 """
848 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
850 def __eq__(self, other: object) -> bool:
851 if type(other) is Box:
852 return self._intervals == other._intervals
853 return False
855 def __str__(self) -> str:
856 return f"[y={self.y}, x={self.x}]"
858 def __repr__(self) -> str:
859 return f"Box(y={self.y!r}, x={self.x!r})"
861 @overload
862 def contains(self, other: Box, /) -> bool: ... 862 ↛ exitline 862 didn't return from function 'contains' because
864 @overload
865 def contains(self, *, y: int, x: int) -> bool: ... 865 ↛ exitline 865 didn't return from function 'contains' because
867 @overload
868 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 868 ↛ exitline 868 didn't return from function 'contains' because
870 def contains(
871 self,
872 other: Box | None = None,
873 *,
874 y: int | np.ndarray | None = None,
875 x: int | np.ndarray | None = None,
876 ) -> bool | np.ndarray:
877 """Test whether this box fully contains another or one or more points.
879 Parameters
880 ----------
881 other
882 Another box to compare to. Not compatible with the ``y`` and ``x``
883 arguments.
884 y
885 One or more integer Y coordinates to test for containment.
886 If an array, an array of results will be returned.
887 x
888 One or more integer X coordinates to test for containment.
889 If an array, an array of results will be returned.
891 Returns
892 -------
893 `bool` | `numpy.ndarray`
894 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
895 single `bool` value. If ``x`` and ``y`` are arrays, a boolean
896 array with their broadcasted shape.
898 Notes
899 -----
900 In order to yield the desired behavior for floating-point arguments,
901 points are actually tested against an interval that is 0.5 larger on
902 both sides: this makes positions within the outer boundary of pixels
903 (but beyond the centers of those pixels, which have integer positions)
904 appear "on the image".
905 """
906 if other is not None:
907 if x is not None or y is not None:
908 raise TypeError("Too many arguments to 'Box.contains'.")
909 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
910 elif x is None or y is None:
911 raise TypeError("Not enough arguments to 'Box.contains'.")
912 else:
913 result = np.logical_and(self.x.contains(x), self.y.contains(y))
914 if not result.shape:
915 return bool(result)
916 return result
918 @overload
919 def intersection(self, other: Box) -> Box: ... 919 ↛ exitline 919 didn't return from function 'intersection' because
921 @overload
922 def intersection(self, other: Region) -> Region | Box: ... 922 ↛ exitline 922 didn't return from function 'intersection' because
924 @overload
925 def intersection(self, other: Bounds) -> Bounds: ... 925 ↛ exitline 925 didn't return from function 'intersection' because
927 def intersection(self, other: Bounds) -> Bounds:
928 """Return a bounds object that is contained by both ``self`` and
929 ``other``.
931 When there is no overlap, `NoOverlapError` is raised.
933 Parameters
934 ----------
935 other
936 Bounds to intersect with this one.
937 """
938 from ._concrete_bounds import _intersect_box
940 return _intersect_box(self, other)
942 def dilated_by(self, padding: int) -> Box:
943 """Return a new box padded by the given amount on all sides.
945 Parameters
946 ----------
947 padding
948 Number of pixels to pad the box by on every side.
949 """
950 return Box(*[i.dilated_by(padding) for i in self._intervals])
952 def slice_within(self, other: Box) -> YX[slice]:
953 """Return a `tuple` of `slice` objects that correspond to the
954 positions in this box when the items of the container being sliced
955 correspond to ``other``.
957 This assumes ``other.contains(self)``.
959 Parameters
960 ----------
961 other
962 Box that the sliced container's items correspond to.
963 """
964 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
966 @property
967 def bbox(self) -> Box:
968 """The box itself (`Box`).
970 This is provided for compatibility with the `Bounds` interface.
971 """
972 return self
974 def boundary(self) -> Iterator[YX[int]]:
975 """Iterate over the corners of the box as ``(y, x)`` tuples.
977 Yields
978 ------
979 corner
980 Each corner in turn.
981 """
982 if len(self._intervals) != 2: 982 ↛ 983line 982 didn't jump to line 983 because the condition on line 982 was never true
983 raise TypeError("Box is not 2-d.")
984 yield YX(self.y.min, self.x.min)
985 yield YX(self.y.min, self.x.max)
986 yield YX(self.y.max, self.x.max)
987 yield YX(self.y.max, self.x.min)
989 def to_polygon(self) -> Polygon:
990 """Convert the box to a polygon with floating-point vertices.
992 Notes
993 -----
994 Because the integer min and max coordinates of a box are
995 interpreted as pixel centers, these are expanded by 0.5 on all sides
996 before using them to form the polygon vertices.
997 """
998 from ._polygon import Polygon
1000 return Polygon.from_box(self)
1002 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1003 """Apply a coordinate transform to the box, returning a polygon.
1005 Parameters
1006 ----------
1007 transform
1008 Coordinate transform to apply (in the forward direction).
1010 Notes
1011 -----
1012 This transforms the polygon representation of the box (see
1013 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1014 full pixels before transforming them.
1015 """
1016 return self.to_polygon().transform(transform)
1018 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1019 return (Box, self._intervals)
1021 @classmethod
1022 def from_legacy(cls, legacy: Any) -> Box:
1023 """Convert from an `lsst.geom.Box2I` instance.
1025 Parameters
1026 ----------
1027 legacy
1028 Legacy `lsst.geom.Box2I` to convert.
1029 """
1030 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1032 def to_legacy(self) -> Any:
1033 """Convert to an `lsst.geom.BoxI` instance."""
1034 from lsst.geom import Box2I
1036 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1038 @classmethod
1039 def __get_pydantic_core_schema__(
1040 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1041 ) -> pcs.CoreSchema:
1042 from_typed_dict = pcs.chain_schema(
1043 [
1044 handler(_SerializedBox),
1045 pcs.no_info_plain_validator_function(cls._validate),
1046 ]
1047 )
1048 return pcs.json_or_python_schema(
1049 json_schema=from_typed_dict,
1050 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1051 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1052 )
1054 @classmethod
1055 def __get_pydantic_json_schema__(
1056 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1057 ) -> JsonSchemaValue:
1058 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1060 @classmethod
1061 def _validate(cls, data: _SerializedBox) -> Box:
1062 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1064 def _serialize(self) -> _SerializedBox:
1065 return {"y": self.y._serialize(), "x": self.x._serialize()}
1067 def serialize(self) -> Box:
1068 """Return a Pydantic-friendly representation of this object.
1070 This method just returns the `Box` itself, since that already provides
1071 Pydantic serialization hooks. It exists for compatibility with the
1072 `Bounds` protocol.
1073 """
1074 return self
1076 def deserialize(self) -> Box:
1077 """Deserialize a bounds object on the assumption it is a `Box`.
1079 This method just returns the `Box` itself, since that already provides
1080 Pydantic serialization hooks. It exists for compatibility with the
1081 `Bounds` protocol.
1082 """
1083 return self
1086class BoxSliceFactory:
1087 """A factory for `Box` objects using array-slice syntax.
1089 Parameters
1090 ----------
1091 y
1092 Slice factory used for the y axis.
1093 x
1094 Slice factory used for the x axis.
1096 Notes
1097 -----
1098 When `Box.factory` is indexed with a pair of slices, this returns a
1099 `Box` with exactly those bounds::
1101 assert (
1102 Box.factory[3:6, -1:2]
1103 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1104 )
1106 A missing start bound is replaced by ``0``, but a missing stop bound is
1107 not allowed.
1109 When obtained from the `Box.absolute` property, indices are absolute
1110 coordinate values, but any omitted bounds are replaced with the parent
1111 box's bounds::
1113 parent = Box.factory[3:6, -1:2]
1114 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1116 The final box is also checked to be contained by the parent box.
1118 When obtained from the `Box.local` property, indices are interpreted
1119 as relative to the parent box, and negative indices are relative to
1120 the end (like `~collections.abc.Sequence` indexing)::
1122 parent = Box.factory[3:6, -1:2]
1123 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1124 """
1126 def __init__(
1127 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1128 ) -> None:
1129 self._y = y
1130 self._x = x
1132 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1133 match key:
1134 case XY(x=x, y=y):
1135 return Box(y=self._y[y], x=self._x[x])
1136 case (y, x):
1137 return Box(y=self._y[y], x=self._x[x])
1138 case _:
1139 raise TypeError("Expected exactly two slices.")
1142Box.factory = BoxSliceFactory()
1145class Bounds(Protocol):
1146 """A protocol for objects that represent the validity region for a function
1147 defined in 2-d pixel coordinates.
1149 Notes
1150 -----
1151 Most objects natively have a simple 2-d bounding box as their bounds
1152 (typically the boundary of a sensor), and the `Box` class is hence the
1153 most common bounds implementation. But sometimes a large chunk of that
1154 box may be missing due to vignetting or bad amplifiers, and we may want to
1155 transform from one coordinate system to another. The Bounds interface is
1156 intended to handle both of these cases as well.
1157 """
1159 @property
1160 def bbox(self) -> Box: ... 1160 ↛ exitline 1160 didn't return from function 'bbox' because
1162 @overload
1163 def contains(self, *, x: int, y: int) -> bool: ... 1163 ↛ exitline 1163 didn't return from function 'contains' because
1165 @overload
1166 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 1166 ↛ exitline 1166 didn't return from function 'contains' because
1168 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray:
1169 """Test whether this box fully contains another or one or more points.
1171 Parameters
1172 ----------
1173 x
1174 One or more integer X coordinates to test for containment.
1175 If an array, an array of results will be returned.
1176 y
1177 One or more integer Y coordinates to test for containment.
1178 If an array, an array of results will be returned.
1180 Returns
1181 -------
1182 `bool` | `numpy.ndarray`
1183 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1184 ``x`` and ``y`` are arrays, a boolean array with their broadcasted
1185 shape.
1186 """
1187 ...
1189 def intersection(self, other: Bounds) -> Bounds:
1190 """Compute the intersection of this bounds object with another.
1192 Parameters
1193 ----------
1194 other
1195 Bounds to intersect with this one.
1196 """
1197 ...
1199 def serialize(self) -> BoundsSerializationModel:
1200 """Convert a bounds instance into a serializable object.
1202 Notes
1203 -----
1204 The returned object must support direct nesting with Pydantic models
1205 and have a ``deserialize`` method (taking no arguments) that converts
1206 back to this `Bounds` type. It is common for `serialize` and
1207 ``deserialize`` to just return ``self``, when the bounds object is
1208 natively serializable.
1209 """
1210 ...
1213class BoundsError(ValueError):
1214 """Exception raised when an object is evaluated outside its bounds."""
1217class NoOverlapError(ValueError):
1218 """Exception raised when intervals or bounds do not overlap."""