Coverage for python/lsst/images/_geom.py: 51%
461 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-18 02:07 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-18 02:07 -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 "SerializableXY",
25 "SerializableYX",
26)
28import math
29from collections.abc import Callable, Iterator, Sequence
30from typing import (
31 TYPE_CHECKING,
32 Annotated,
33 Any,
34 ClassVar,
35 NamedTuple,
36 Protocol,
37 TypedDict,
38 TypeVar,
39 assert_type,
40 final,
41 get_args,
42 overload,
43)
45import numpy as np
46import numpy.typing as npt
47import pydantic
48import pydantic_core.core_schema as pcs
49from pydantic.json_schema import JsonSchemaValue
51from .utils import round_half_down, round_half_up
53if TYPE_CHECKING:
54 from ._concrete_bounds import BoundsSerializationModel
55 from ._polygon import Polygon, Region
56 from ._transforms import Transform
58 try:
59 from lsst.geom import Extent2D as LegacyExtent2D
60 from lsst.geom import Extent2I as LegacyExtent2I
61 from lsst.geom import Point2D as LegacyPoint2D
62 from lsst.geom import Point2I as LegacyPoint2I
63 except ImportError:
64 type LegacyExtent2I = Any # type: ignore[no-redef]
65 type LegacyPoint2I = Any # type: ignore[no-redef]
66 type LegacyExtent2D = Any # type: ignore[no-redef]
67 type LegacyPoint2D = Any # type: ignore[no-redef]
69# This pre-python-3.12 declaration is needed by Sphinx (probably the
70# autodoc-typehints plugin.
71T = TypeVar("T")
73# Interval and Box are defined as regular Python classes rather than
74# dataclasses or Pydantic models because we might want to implement them as
75# compiled-extension types in the future, and we want that to be transparent.
77# In a similar vein, we avoid declaring specific types for multidimensional
78# points or extents (other than ``tuple[int, ...]`` for numpy-compatible
79# shapes) in order to leave room for more fully-featured types to be added
80# upstream of this package in the future.
83class _SerializedYX[T](TypedDict):
84 y: T
85 x: T
88class _SerializedXY[T](TypedDict):
89 x: T
90 y: T
93class YX[T](NamedTuple):
94 """A pair of per-dimension objects, ordered ``(y, x)``.
96 Notes
97 -----
98 `YX` is used for slices, shapes, and other 2-d pairs when the most
99 natural ordering is ``(y, x)``. Because it is a `tuple`, however,
100 arithmetic operations behave as they would on a
101 `collections.abc.Sequence`, not a mathematical vector (e.g. adding
102 concatenates).
104 In Pydantic models `YX` is serialized as a JSON object with ``y`` and
105 ``x`` keys rather than an array, so the dimension order is explicit in
106 the serialized form. Validation also accepts a two-element ``[y, x]``
107 array, the form emitted before the object form was introduced and still
108 present in files that cannot be rewritten. Pydantic only invokes the
109 schema hooks that
110 provide this for bare ``YX`` annotations, however, because it handles
111 parameterized named tuples specially; model fields that need a member
112 type (e.g. ``YX[int]``) must be annotated with `SerializableYX` instead.
114 See Also
115 --------
116 XY
117 """
119 y: T
120 """The y / row object."""
122 x: T
123 """The x / column object."""
125 @property
126 def xy(self) -> XY:
127 """A tuple of the same objects in the opposite order."""
128 return XY(x=self.x, y=self.y)
130 def map[U](self, func: Callable[[T], U]) -> YX[U]:
131 """Apply a function to both objects.
133 Parameters
134 ----------
135 func
136 Callable applied to each of the two objects in turn.
137 """
138 return YX(y=func(self.y), x=func(self.x))
140 def to_legacy_int_extent(self) -> LegacyExtent2I:
141 """Convert to a legacy `lsst.geom.Extent2I` object."""
142 from lsst.geom import Extent2I as LegacyExtent2I
144 return LegacyExtent2I(self.x, self.y)
146 def to_legacy_int_point(self) -> LegacyPoint2I:
147 """Convert to a legacy `lsst.geom.Point2I` object."""
148 from lsst.geom import Point2I as LegacyPoint2I
150 return LegacyPoint2I(self.x, self.y)
152 def to_legacy_float_extent(self) -> LegacyExtent2D:
153 """Convert to a legacy `lsst.geom.Extent2D` object."""
154 from lsst.geom import Extent2D as LegacyExtent2D
156 return LegacyExtent2D(self.x, self.y)
158 def to_legacy_float_point(self) -> LegacyPoint2D:
159 """Convert to a legacy `lsst.geom.Point2D` object."""
160 from lsst.geom import Point2D as LegacyPoint2D
162 return LegacyPoint2D(self.x, self.y)
164 @classmethod
165 def __get_pydantic_core_schema__(
166 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
167 ) -> pcs.CoreSchema:
168 item_type = args[0] if (args := get_args(source_type)) else Any
169 typed_dict = handler(_SerializedYX[item_type]) # type: ignore[valid-type]
170 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict)
171 # Files written before the object form was introduced (including the
172 # DP2 cell coadds, which cannot be rewritten) store pairs as ``[y, x]``
173 # arrays, so validation must accept that form for as long as those
174 # files remain readable, even if a future schema version stops
175 # documenting it. This is a strict list schema so that a stray `XY`
176 # (a tuple) assigned to a `YX` field fails to validate instead of
177 # being silently transposed.
178 from_list = pcs.no_info_after_validator_function(
179 cls._from_list,
180 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True),
181 )
182 validation = pcs.union_schema([from_typed_dict, from_list])
183 return pcs.json_or_python_schema(
184 json_schema=validation,
185 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]),
186 serialization=pcs.plain_serializer_function_ser_schema(
187 cls._serialize, info_arg=False, return_schema=typed_dict
188 ),
189 )
191 @classmethod
192 def _validate(cls, data: _SerializedYX[T]) -> YX[T]:
193 return cls(**data)
195 @classmethod
196 def _from_list(cls, data: list[T]) -> YX[T]:
197 return cls(*data)
199 def _serialize(self) -> _SerializedYX[T]:
200 return {"y": self.y, "x": self.x}
203class XY[T](NamedTuple):
204 """A pair of per-dimension objects, ordered ``(x, y)``.
206 Notes
207 -----
208 `XY` is used for points and other 2-d pairs when the most natural ordering
209 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations
210 behave as they would on a `collections.abc.Sequence`, not a mathematical
211 vector (e.g. adding concatenates).
213 In Pydantic models `XY` is serialized as a JSON object with ``x`` and
214 ``y`` keys rather than an array, so the dimension order is explicit in
215 the serialized form. Validation also accepts a two-element ``[x, y]``
216 array, the form emitted before the object form was introduced and still
217 present in files that cannot be rewritten. Pydantic only invokes the
218 schema hooks that
219 provide this for bare ``XY`` annotations, however, because it handles
220 parameterized named tuples specially; model fields that need a member
221 type (e.g. ``XY[float]``) must be annotated with `SerializableXY` instead.
223 See Also
224 --------
225 YX
226 """
228 x: T
229 """The x / column object."""
231 y: T
232 """The y / row object."""
234 @property
235 def yx(self) -> YX:
236 """A tuple of the same objects in the opposite order."""
237 return YX(y=self.y, x=self.x)
239 def map[U](self, func: Callable[[T], U]) -> XY[U]:
240 """Apply a function to both objects.
242 Parameters
243 ----------
244 func
245 Callable applied to each of the two objects in turn.
246 """
247 return XY(x=func(self.x), y=func(self.y))
249 def to_legacy_int_extent(self) -> LegacyExtent2I:
250 """Convert to a legacy `lsst.geom.Extent2I` object."""
251 from lsst.geom import Extent2I as LegacyExtent2I
253 return LegacyExtent2I(self.x, self.y)
255 def to_legacy_int_point(self) -> LegacyPoint2I:
256 """Convert to a legacy `lsst.geom.Point2I` object."""
257 from lsst.geom import Point2I as LegacyPoint2I
259 return LegacyPoint2I(self.x, self.y)
261 def to_legacy_float_extent(self) -> LegacyExtent2D:
262 """Convert to a legacy `lsst.geom.Extent2D` object."""
263 from lsst.geom import Extent2D as LegacyExtent2D
265 return LegacyExtent2D(self.x, self.y)
267 def to_legacy_float_point(self) -> LegacyPoint2D:
268 """Convert to a legacy `lsst.geom.Point2D` object."""
269 from lsst.geom import Point2D as LegacyPoint2D
271 return LegacyPoint2D(self.x, self.y)
273 @classmethod
274 def __get_pydantic_core_schema__(
275 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
276 ) -> pcs.CoreSchema:
277 item_type = args[0] if (args := get_args(source_type)) else Any
278 typed_dict = handler(_SerializedXY[item_type]) # type: ignore[valid-type]
279 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict)
280 # Files written before the object form was introduced store pairs as
281 # ``[x, y]`` arrays, so validation must accept that form for as long
282 # as those files remain readable, even if a future schema version
283 # stops documenting it. This is a strict list schema so that a stray
284 # `YX` (a tuple) assigned to an `XY` field fails to validate instead
285 # of being silently transposed.
286 from_list = pcs.no_info_after_validator_function(
287 cls._from_list,
288 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True),
289 )
290 validation = pcs.union_schema([from_typed_dict, from_list])
291 return pcs.json_or_python_schema(
292 json_schema=validation,
293 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]),
294 serialization=pcs.plain_serializer_function_ser_schema(
295 cls._serialize, info_arg=False, return_schema=typed_dict
296 ),
297 )
299 @classmethod
300 def _validate(cls, data: _SerializedXY[T]) -> XY[T]:
301 return cls(**data)
303 @classmethod
304 def _from_list(cls, data: list[T]) -> XY[T]:
305 return cls(*data)
307 def _serialize(self) -> _SerializedXY[T]:
308 return {"x": self.x, "y": self.y}
311SerializableXY = Annotated[XY[T], pydantic.GetPydanticSchema(XY.__get_pydantic_core_schema__)]
312"""An `XY` annotation for Pydantic model fields that serializes as a JSON
313object with ``x`` and ``y`` keys (`XY` alone serializes as an array when
314parameterized, because Pydantic handles named tuples specially).
315"""
317SerializableYX = Annotated[YX[T], pydantic.GetPydanticSchema(YX.__get_pydantic_core_schema__)]
318"""A `YX` annotation for Pydantic model fields that serializes as a JSON
319object with ``y`` and ``x`` keys (`YX` alone serializes as an array when
320parameterized, because Pydantic handles named tuples specially).
321"""
324class _SerializedInterval(TypedDict):
325 start: int
326 stop: int
329@final
330class Interval:
331 """A 1-d integer interval with positive size.
333 Parameters
334 ----------
335 start
336 Inclusive minimum point in the interval.
337 stop
338 One past the maximum point in the interval.
340 Notes
341 -----
342 Adding or subtracting an `int` from an interval returns a shifted interval.
344 `Interval` implements the necessary hooks to be included directly in a
345 `pydantic.BaseModel`, even though it is neither a built-in type nor a
346 Pydantic model itself.
347 """
349 def __init__(self, start: int, stop: int) -> None:
350 # Coerce to be defensive against numpy int scalars.
351 self._start = int(start)
352 self._stop = int(stop)
353 if not (self._stop > self._start):
354 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})")
356 __slots__ = ("_start", "_stop")
358 factory: ClassVar[IntervalSliceFactory]
359 """A factory for creating intervals using slice syntax.
361 For example::
363 interval = Interval.factory[2:5]
364 """
366 @classmethod
367 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval:
368 """Construct an interval that includes all of the given points and/or
369 intervals.
371 Parameters
372 ----------
373 first
374 First point or interval to include in the hull.
375 *args
376 Additional points and/or intervals to include in the hull.
377 """
378 if type(first) is Interval:
379 rmin = first.min
380 rmax = first.max
381 else:
382 rmin = rmax = first
383 for arg in args:
384 if type(arg) is Interval:
385 rmin = min(rmin, arg.min)
386 rmax = max(rmax, arg.max)
387 else:
388 rmin = min(rmin, arg)
389 rmax = max(rmax, arg)
390 return Interval(start=rmin, stop=rmax + 1)
392 @classmethod
393 def from_size(cls, size: int, start: int = 0) -> Interval:
394 """Construct an interval from its size and optional start.
396 Parameters
397 ----------
398 size
399 Number of points in the interval.
400 start
401 Inclusive minimum point in the interval.
402 """
403 return cls(start=start, stop=start + size)
405 @property
406 def min(self) -> int:
407 """Inclusive minimum point in the interval (`int`)."""
408 return self.start
410 @property
411 def max(self) -> int:
412 """Inclusive maximum point in the interval (`int`)."""
413 return self.stop - 1
415 @property
416 def start(self) -> int:
417 """Inclusive minimum point in the interval (`int`)."""
418 return self._start
420 @property
421 def stop(self) -> int:
422 """One past the maximum point in the interval (`int`)."""
423 return self._stop
425 @property
426 def size(self) -> int:
427 """Size of the interval (`int`)."""
428 return self.stop - self.start
430 @property
431 def range(self) -> __builtins__.range:
432 """An iterable over all values in the interval
433 (`__builtins__.range`).
434 """
435 return range(self.start, self.stop)
437 @property
438 def arange(self) -> np.ndarray:
439 """An array of all the values in the interval (`numpy.ndarray`).
441 Array values are integers.
442 """
443 return np.arange(self.start, self.stop)
445 @property
446 def absolute(self) -> IntervalSliceFactory:
447 """A factory for constructing a contained `Interval` using slice
448 syntax and absolute coordinates.
450 Notes
451 -----
452 Slice bounds that are absent are replaced with the bounds of ``self``.
453 """
454 return IntervalSliceFactory(self, is_local=False)
456 @property
457 def local(self) -> IntervalSliceFactory:
458 """A factory for constructing a contained `Interval` using a slice
459 relative to the start of this one (`IntervalSliceFactory`).
461 Notes
462 -----
463 This factory interprets slices as "local" coordinates, in which ``0``
464 corresponds to ``self.start``. Negative bounds are relative to
465 ``self.stop``, as is usually the case for Python sequences.
466 """
467 return IntervalSliceFactory(self, is_local=True)
469 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray:
470 """Return an array of values that spans the interval.
472 Parameters
473 ----------
474 n
475 How many values to return. The default (if ``step`` is also not
476 provided) is the size of the interval, i.e. equivalent to the
477 `arange` property (but converted to `float`).
478 step
479 Set ``n`` such that the distance between points is equal to or
480 just less than this. Mutually exclusive with ``n``.
482 Returns
483 -------
484 numpy.ndarray
485 Array of `float` values.
487 See Also
488 --------
489 numpy.linspace
490 """
491 if n is None:
492 if step is None:
493 return self.arange.astype(np.float64)
494 n = math.ceil(self.size / step)
495 elif step is not None:
496 raise TypeError("'n' and 'step' cannot both be provided.")
497 return np.linspace(self.min, self.max, n, dtype=np.float64)
499 @property
500 def center(self) -> float:
501 """The center of the interval (`float`)."""
502 return 0.5 * (self.min + self.max)
504 def padded(self, padding: int) -> Interval:
505 """Return a new interval expanded by the given padding on
506 either side.
508 Parameters
509 ----------
510 padding
511 Number of points to add to each side of the interval.
512 """
513 return Interval(self.start - padding, self.stop + padding)
515 def __str__(self) -> str:
516 return f"{self.start}:{self.stop}"
518 def __repr__(self) -> str:
519 return f"Interval(start={self.start}, stop={self.stop})"
521 def __eq__(self, other: object) -> bool:
522 if type(other) is Interval:
523 return self._start == other._start and self._stop == other._stop
524 return False
526 def __add__(self, other: int) -> Interval:
527 return Interval(start=self.start + other, stop=self.stop + other)
529 def __sub__(self, other: int) -> Interval:
530 return Interval(start=self.start - other, stop=self.stop - other)
532 def __contains__(self, x: int) -> bool:
533 return x >= self.start and x < self.stop
535 @overload
536 def contains(self, other: Interval | int | float) -> bool: ...
538 @overload
539 def contains(self, other: npt.ArrayLike) -> np.ndarray: ...
541 def contains(self, other: Interval | int | float | npt.ArrayLike) -> bool | np.ndarray:
542 """Test whether this interval fully contains another or one or more
543 points.
545 Parameters
546 ----------
547 other
548 Another interval to compare to, or one or more position values as
549 a scalar or any array-like.
551 Returns
552 -------
553 `bool` | `numpy.ndarray`
554 If a single interval or value was passed, a single `bool`. If an
555 array-like was passed, an array with the broadcasted shape.
557 Notes
558 -----
559 In order to yield the desired behavior for floating-point arguments,
560 points are actually tested against an interval that is 0.5 larger on
561 both sides: this makes positions within the outer boundary of pixels
562 (but beyond the centers of those pixels, which have integer positions)
563 appear "on the image".
564 """
565 if isinstance(other, Interval):
566 return self.start <= other.start and self.stop >= other.stop
567 else:
568 other = np.asarray(other)
569 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5)
570 if not result.shape:
571 return bool(result)
572 return result
574 def intersection(self, other: Interval) -> Interval:
575 """Return an interval that is contained by both ``self`` and ``other``.
577 When there is no overlap between the intervals, `NoOverlapError` is
578 raised.
580 Parameters
581 ----------
582 other
583 Interval to intersect with this one.
584 """
585 new_start = max(self.start, other.start)
586 new_stop = min(self.stop, other.stop)
587 if new_start < new_stop:
588 return Interval(start=new_start, stop=new_stop)
589 raise NoOverlapError(f"No overlap between {self} and {other}.")
591 def dilated_by(self, padding: int) -> Interval:
592 """Return a new interval padded by the given amount on both sides.
594 Parameters
595 ----------
596 padding
597 Number of points to add to each side of the interval.
598 """
599 return Interval(start=self._start - padding, stop=self._stop + padding)
601 def slice_within(self, other: Interval) -> slice:
602 """Return the `slice` that corresponds to the values in this interval
603 when the items of the container being sliced correspond to ``other``.
605 This assumes ``other.contains(self)``.
607 Parameters
608 ----------
609 other
610 Interval whose values correspond to the container being sliced.
611 """
612 if not other.contains(self):
613 raise IndexError(
614 f"Can not calculate a slice of {other} within {self} "
615 "since the given interval does not contain this one."
616 )
617 return slice(self.start - other.start, self.stop - other.start)
619 @classmethod
620 def from_legacy(cls, legacy: Any) -> Interval:
621 """Convert from an `lsst.geom.IntervalI` instance.
623 Parameters
624 ----------
625 legacy
626 Legacy `lsst.geom.IntervalI` instance to convert.
627 """
628 return cls(legacy.begin, legacy.end)
630 def to_legacy(self) -> Any:
631 """Convert to an `lsst.geom.IntervalI` instance."""
632 from lsst.geom import IntervalI
634 return IntervalI(min=self.min, max=self.max)
636 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]:
637 return (
638 Interval,
639 (
640 self._start,
641 self._stop,
642 ),
643 )
645 @classmethod
646 def __get_pydantic_core_schema__(
647 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
648 ) -> pcs.CoreSchema:
649 from_typed_dict = pcs.chain_schema(
650 [
651 handler(_SerializedInterval),
652 pcs.no_info_plain_validator_function(cls._validate),
653 ]
654 )
655 return pcs.json_or_python_schema(
656 json_schema=from_typed_dict,
657 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]),
658 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
659 )
661 @classmethod
662 def __get_pydantic_json_schema__(
663 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
664 ) -> JsonSchemaValue:
665 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema)
667 @classmethod
668 def _validate(cls, data: _SerializedInterval) -> Interval:
669 return cls(**data)
671 def _serialize(self) -> _SerializedInterval:
672 return {"start": self._start, "stop": self._stop}
675class IntervalSliceFactory:
676 """A factory for `Interval` objects using array-slice syntax.
678 Parameters
679 ----------
680 parent
681 Interval that constructed intervals must be contained by, or `None`
682 to allow any bounds.
683 is_local
684 Whether slice bounds are interpreted relative to the start of
685 ``parent`` rather than as absolute coordinates.
687 Notes
688 -----
689 When indexed with a single slice on the `Interval.factory` attribute, this
690 returns an `Interval` with exactly the given bounds::
692 assert Interval.factory[3:6] == Interval(start=3, stop=6)
694 A missing start bound is replaced by ``0``, but a missing stop bound is
695 not allowed.
697 When obtained from the `Interval.absolute` property, indices are absolute
698 coordinate values, but any omitted bounds are replaced with the parent
699 interval's bounds::
701 parent = Interval.factory[3:6]
702 assert Interval.factory[4:5] == parent.absolute[:5]
704 The final interval is also checked to be contained by the parent interval.
706 When obtained from the `Interval.local` property, indices are interpreted
707 as relative to the parent interval, and negative indices are relative to
708 the end (like `~collections.abc.Sequence` indexing)::
710 parent = Interval.factory[3:6]
711 assert Interval.factory[4:5] == parent.local[1:-1]
713 When the stop bound is greater than the size of the parent interval, the
714 returned interval is clipped to be contained by the parent (as in
715 `~collections.abc.Sequence` indexing).
716 """
718 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None:
719 self._parent = parent
720 self._is_local = is_local
722 def __getitem__(self, s: slice) -> Interval:
723 if s.step is not None and s.step != 1:
724 raise ValueError(f"Slice {s} has non-unit step.")
725 if self._is_local:
726 assert self._parent is not None, "is_local=True requires a parent interval"
727 start, stop, _ = s.indices(self._parent.size)
728 start += self._parent.start
729 stop += self._parent.start
730 else:
731 start = s.start
732 stop = s.stop
733 if start is None:
734 if self._parent is None:
735 start = 0
736 else:
737 start = self._parent.start
738 if stop is None:
739 if self._parent is None: 739 ↛ 740line 739 didn't jump to line 740 because the condition on line 739 was never true
740 raise IndexError("An Interval cannot have an empty upper bound.")
741 stop = self._parent.stop
742 if self._parent is not None:
743 if start < self._parent.start:
744 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.")
745 if stop > self._parent.stop:
746 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.")
747 return Interval(start=start, stop=stop)
750Interval.factory = IntervalSliceFactory()
753class _SerializedBox(TypedDict):
754 y: _SerializedInterval
755 x: _SerializedInterval
758class Box:
759 """An axis-aligned 2-d rectangular region.
761 Parameters
762 ----------
763 y
764 Interval for the y dimension.
765 x
766 Interval for the x dimension.
768 Raises
769 ------
770 TypeError
771 Raised if ``y`` or ``x`` is not an `Interval`.
773 Notes
774 -----
775 `Box` implements the necessary hooks to be included directly in a
776 `pydantic.BaseModel`, even though it is neither a built-in type nor a
777 Pydantic model itself.
778 """
780 def __init__(self, y: Interval, x: Interval) -> None:
781 match (y, x):
782 case (Interval(), Interval()):
783 self._intervals = YX(y, x)
784 case _:
785 raise TypeError(
786 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). "
787 "See the Box.factory slice syntax and the from_* class methods for other ways "
788 "to construct a Box."
789 )
791 __slots__ = ("_intervals",)
793 factory: ClassVar[BoxSliceFactory]
794 """A factory for creating boxes using slice syntax.
796 For example::
798 box = Box.factory[2:5, 3:9]
799 """
801 @classmethod
802 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box:
803 """Construct a box from its shape and optional start.
805 Parameters
806 ----------
807 shape
808 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances).
809 start
810 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances).
811 """
812 if start is None:
813 start = (0,) * len(shape)
814 match shape:
815 case XY(x=x_size, y=y_size):
816 pass
817 case [y_size, x_size]:
818 pass
819 case _:
820 raise ValueError(f"Invalid sequence for shape: {shape!r}.")
821 match start:
822 case XY(x=x_start, y=y_start):
823 pass
824 case [y_start, x_start]:
825 pass
826 case _:
827 raise ValueError(f"Invalid sequence for start: {start!r}.")
828 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start))
830 @classmethod
831 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box:
832 """Construct a box from floating-point bounds ensuring that all the
833 are contained in the new box.
835 Parameters
836 ----------
837 x_min
838 Minimum X value.
839 x_max
840 Maximum X value.
841 y_min
842 Minimum Y value.
843 y_max
844 Maximum Y value.
846 Notes
847 -----
848 Uses the same rounding convention as `lsst.images.Region.bbox`, so that
849 pixels whose centers lie within the bounds are included.
850 """
851 return Box.factory[
852 round_half_up(y_min) : round_half_down(y_max) + 1,
853 round_half_up(x_min) : round_half_down(x_max) + 1,
854 ]
856 @property
857 def min(self) -> YX[int]:
858 """The inclusive minimum bounds of the box, ordered ``(y, x)``
859 (`YX` [`int`]).
860 """
861 return YX(y=self._intervals.y.min, x=self._intervals.x.min)
863 @property
864 def max(self) -> YX[int]:
865 """The inclusive maximum bounds of the box, ordered ``(y, x)``
866 (`YX` [`int`]).
867 """
868 return YX(y=self._intervals.y.max, x=self._intervals.x.max)
870 @property
871 def start(self) -> YX[int]:
872 """Tuple holding the inclusive `Interval.start` bvound, ordered
873 ``(y, x)`` (`YX` [`int`]).
875 This is an alias for `min`, typically paired with `stop` for
876 half-exclusive ranges.
877 """
878 return YX(self.y.start, self.x.start)
880 @property
881 def stop(self) -> YX[int]:
882 """Tuple holding the exclusive `Interval.stop` bound, ordered
883 ``(y, x)`` (`YX` [`int`]).
885 The values in this tuple are one greater than those in `max`. It is
886 typically paired with `start` for half-exclusive ranges.
887 """
888 return YX(self.y.stop, self.x.stop)
890 @property
891 def shape(self) -> YX[int]:
892 """Tuple holding the sizes of the intervals, ordered ``(y, x)``
893 (`YX` [`int`]).
894 """
895 return YX(self.y.size, self.x.size)
897 @property
898 def x(self) -> Interval:
899 """The x-dimension interval (`int`)."""
900 return self._intervals[-1]
902 @property
903 def y(self) -> Interval:
904 """The y-dimension interval (`int`)."""
905 return self._intervals[-2]
907 @property
908 def area(self) -> int:
909 """The number of pixels in the box (`int`)."""
910 return self.x.size * self.y.size
912 @property
913 def absolute(self) -> BoxSliceFactory:
914 """A factory for constructing a contained `Box` using slice
915 syntax and absolute coordinates.
917 Notes
918 -----
919 Slice bounds that are absent are replaced with the bounds of ``self``.
920 """
921 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute)
923 @property
924 def local(self) -> BoxSliceFactory:
925 """A factory for constructing a contained `Interval` using a slice
926 relative to the start of this one (`BoxSliceFactory`).
928 Notes
929 -----
930 This factory interprets slices as "local" coordinates, in which ``0``
931 corresponds to ``self.start``. Negative bounds are relative to
932 ``self.stop``, as is usually the case for Python sequences.
933 """
934 return BoxSliceFactory(y=self.y.local, x=self.x.local)
936 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]:
937 """Return a pair of 2-d arrays of the coordinate values of the box.
939 Parameters
940 ----------
941 n
942 Number of points in each dimension. If a sequence, points are
943 assumed to be ordered ``(x, y)`` unless a `YX` instance is
944 provided.
945 step
946 Set ``n`` such that the distance between points is equal to or
947 just less than this in each dimension. Mutually exclusive with
948 ``n``.
950 Returns
951 -------
952 `XY` [`numpy.ndarray`]
953 A pair of arrays, each of which is 2-d with floating-point values.
955 See Also
956 --------
957 numpy.meshgrid
958 """
959 if n is not None and step is not None:
960 raise TypeError("'n' and 'step' cannot both be provided.")
961 match n:
962 case int():
963 ax = self.x.linspace(n)
964 ay = self.y.linspace(n)
965 case YX(y=ny, x=nx):
966 ax = self.x.linspace(nx)
967 ay = self.y.linspace(ny)
968 case [nx, ny]:
969 ax = self.x.linspace(nx)
970 ay = self.y.linspace(ny)
971 case None:
972 ax = self.x.linspace(step=step)
973 ay = self.y.linspace(step=step)
974 case _:
975 raise ValueError(f"Unexpected values for n ({n})")
976 return XY(*np.meshgrid(ax, ay))
978 def padded(self, padding: int) -> Box:
979 """Return a new box expanded by the given padding on
980 all sides.
982 Parameters
983 ----------
984 padding
985 Number of pixels to expand the box by on every side.
986 """
987 return Box(y=self.y.padded(padding), x=self.x.padded(padding))
989 def __eq__(self, other: object) -> bool:
990 if type(other) is Box:
991 return self._intervals == other._intervals
992 return False
994 def __str__(self) -> str:
995 return f"[y={self.y}, x={self.x}]"
997 def __repr__(self) -> str:
998 return f"Box(y={self.y!r}, x={self.x!r})"
1000 @overload
1001 def contains(self, other: Box, /) -> bool: ...
1003 @overload
1004 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1006 @overload
1007 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1009 @overload
1010 def contains(self, /, *, y: int | float, x: int | float) -> bool: ...
1012 @overload
1013 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ...
1015 def contains(
1016 self,
1017 other: Box | XY[Any] | YX[Any] | None = None,
1018 /,
1019 *,
1020 y: Any = None,
1021 x: Any = None,
1022 ) -> bool | np.ndarray:
1023 """Test whether this box fully contains another or one or more points.
1025 Parameters
1026 ----------
1027 other
1028 Another box, or an `XY` or `YX` coordinate pair, to compare to.
1029 Mutually exclusive with ``x`` and ``y``.
1030 y
1031 One or more Y coordinates to test for containment, as a scalar or
1032 any array-like. Results are broadcast against ``x``.
1033 Mutually exclusive with ``other``.
1034 x
1035 One or more X coordinates to test for containment, as a scalar or
1036 any array-like. Results are broadcast against ``y``.
1037 Mutually exclusive with ``other``.
1039 Returns
1040 -------
1041 `bool` | `numpy.ndarray`
1042 If ``other`` was passed or ``x`` and ``y`` are both scalars, a
1043 single `bool` value. If ``x`` and ``y`` are array-like, a boolean
1044 array with their broadcasted shape.
1046 Notes
1047 -----
1048 In order to yield the desired behavior for floating-point arguments,
1049 points are actually tested against an interval that is 0.5 larger on
1050 both sides: this makes positions within the outer boundary of pixels
1051 (but beyond the centers of those pixels, which have integer positions)
1052 appear "on the image".
1053 """
1054 match other:
1055 case None:
1056 if x is None or y is None:
1057 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.")
1058 case Box():
1059 if x is not None or y is not None:
1060 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1061 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True))
1062 case XY() | YX(): 1062 ↛ 1066line 1062 didn't jump to line 1066 because the pattern on line 1062 always matched
1063 if x is not None or y is not None:
1064 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.")
1065 x, y = other.x, other.y
1066 case _:
1067 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.")
1068 result = np.logical_and(self.x.contains(x), self.y.contains(y))
1069 if not result.shape:
1070 return bool(result)
1071 return result
1073 @overload
1074 def intersection(self, other: Box) -> Box: ...
1076 @overload
1077 def intersection(self, other: Region) -> Region | Box: ...
1079 @overload
1080 def intersection(self, other: Bounds) -> Bounds: ...
1082 def intersection(self, other: Bounds) -> Bounds:
1083 """Return a bounds object that is contained by both ``self`` and
1084 ``other``.
1086 When there is no overlap, `NoOverlapError` is raised.
1088 Parameters
1089 ----------
1090 other
1091 Bounds to intersect with this one.
1092 """
1093 from ._concrete_bounds import _intersect_box
1095 return _intersect_box(self, other)
1097 def dilated_by(self, padding: int) -> Box:
1098 """Return a new box padded by the given amount on all sides.
1100 Parameters
1101 ----------
1102 padding
1103 Number of pixels to pad the box by on every side.
1104 """
1105 return Box(*[i.dilated_by(padding) for i in self._intervals])
1107 def slice_within(self, other: Box) -> YX[slice]:
1108 """Return a `tuple` of `slice` objects that correspond to the
1109 positions in this box when the items of the container being sliced
1110 correspond to ``other``.
1112 This assumes ``other.contains(self)``.
1114 Parameters
1115 ----------
1116 other
1117 Box that the sliced container's items correspond to.
1118 """
1119 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x))
1121 @property
1122 def bbox(self) -> Box:
1123 """The box itself (`Box`).
1125 This is provided for compatibility with the `Bounds` interface.
1126 """
1127 return self
1129 def boundary(self) -> Iterator[YX[int]]:
1130 """Iterate over the corners of the box as ``(y, x)`` tuples.
1132 Yields
1133 ------
1134 corner
1135 Each corner in turn.
1136 """
1137 if len(self._intervals) != 2: 1137 ↛ 1138line 1137 didn't jump to line 1138 because the condition on line 1137 was never true
1138 raise TypeError("Box is not 2-d.")
1139 yield YX(self.y.min, self.x.min)
1140 yield YX(self.y.min, self.x.max)
1141 yield YX(self.y.max, self.x.max)
1142 yield YX(self.y.max, self.x.min)
1144 def to_polygon(self) -> Polygon:
1145 """Convert the box to a polygon with floating-point vertices.
1147 Notes
1148 -----
1149 Because the integer min and max coordinates of a box are
1150 interpreted as pixel centers, these are expanded by 0.5 on all sides
1151 before using them to form the polygon vertices.
1152 """
1153 from ._polygon import Polygon
1155 return Polygon.from_box(self)
1157 def transform(self, transform: Transform[Any, Any]) -> Polygon:
1158 """Apply a coordinate transform to the box, returning a polygon.
1160 Parameters
1161 ----------
1162 transform
1163 Coordinate transform to apply (in the forward direction).
1165 Notes
1166 -----
1167 This transforms the polygon representation of the box (see
1168 `to_polygon`), which expands its vertices by 0.5 on all sides to cover
1169 full pixels before transforming them.
1170 """
1171 return self.to_polygon().transform(transform)
1173 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]:
1174 return (Box, self._intervals)
1176 @classmethod
1177 def from_legacy(cls, legacy: Any) -> Box:
1178 """Convert from an `lsst.geom.Box2I` instance.
1180 Parameters
1181 ----------
1182 legacy
1183 Legacy `lsst.geom.Box2I` to convert.
1184 """
1185 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x))
1187 def to_legacy(self) -> Any:
1188 """Convert to an `lsst.geom.BoxI` instance."""
1189 from lsst.geom import Box2I
1191 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy())
1193 @classmethod
1194 def __get_pydantic_core_schema__(
1195 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler
1196 ) -> pcs.CoreSchema:
1197 from_typed_dict = pcs.chain_schema(
1198 [
1199 handler(_SerializedBox),
1200 pcs.no_info_plain_validator_function(cls._validate),
1201 ]
1202 )
1203 return pcs.json_or_python_schema(
1204 json_schema=from_typed_dict,
1205 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]),
1206 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False),
1207 )
1209 @classmethod
1210 def __get_pydantic_json_schema__(
1211 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler
1212 ) -> JsonSchemaValue:
1213 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema)
1215 @classmethod
1216 def _validate(cls, data: _SerializedBox) -> Box:
1217 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"]))
1219 def _serialize(self) -> _SerializedBox:
1220 return {"y": self.y._serialize(), "x": self.x._serialize()}
1222 def serialize(self) -> Box:
1223 """Return a Pydantic-friendly representation of this object.
1225 This method just returns the `Box` itself, since that already provides
1226 Pydantic serialization hooks. It exists for compatibility with the
1227 `Bounds` protocol.
1228 """
1229 return self
1231 def deserialize(self) -> Box:
1232 """Deserialize a bounds object on the assumption it is a `Box`.
1234 This method just returns the `Box` itself, since that already provides
1235 Pydantic serialization hooks. It exists for compatibility with the
1236 `Bounds` protocol.
1237 """
1238 return self
1241class BoxSliceFactory:
1242 """A factory for `Box` objects using array-slice syntax.
1244 Parameters
1245 ----------
1246 y
1247 Slice factory used for the y axis.
1248 x
1249 Slice factory used for the x axis.
1251 Notes
1252 -----
1253 When `Box.factory` is indexed with a pair of slices, this returns a
1254 `Box` with exactly those bounds::
1256 assert (
1257 Box.factory[3:6, -1:2]
1258 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6)
1259 )
1261 A missing start bound is replaced by ``0``, but a missing stop bound is
1262 not allowed.
1264 When obtained from the `Box.absolute` property, indices are absolute
1265 coordinate values, but any omitted bounds are replaced with the parent
1266 box's bounds::
1268 parent = Box.factory[3:6, -1:2]
1269 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:]
1271 The final box is also checked to be contained by the parent box.
1273 When obtained from the `Box.local` property, indices are interpreted
1274 as relative to the parent box, and negative indices are relative to
1275 the end (like `~collections.abc.Sequence` indexing)::
1277 parent = Box.factory[3:6, -1:2]
1278 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:]
1279 """
1281 def __init__(
1282 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory
1283 ) -> None:
1284 self._y = y
1285 self._x = x
1287 def __getitem__(self, key: tuple[slice, slice]) -> Box:
1288 match key:
1289 case XY(x=x, y=y):
1290 return Box(y=self._y[y], x=self._x[x])
1291 case (y, x):
1292 return Box(y=self._y[y], x=self._x[x])
1293 case _:
1294 raise TypeError("Expected exactly two slices.")
1297Box.factory = BoxSliceFactory()
1300class Bounds(Protocol):
1301 """A protocol for objects that represent the validity region for a function
1302 defined in 2-d pixel coordinates.
1304 Notes
1305 -----
1306 Most objects natively have a simple 2-d bounding box as their bounds
1307 (typically the boundary of a sensor), and the `Box` class is hence the
1308 most common bounds implementation. But sometimes a large chunk of that
1309 box may be missing due to vignetting or bad amplifiers, and we may want to
1310 transform from one coordinate system to another. The Bounds interface is
1311 intended to handle both of these cases as well.
1312 """
1314 @property
1315 def bbox(self) -> Box: ...
1317 @overload
1318 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ...
1320 @overload
1321 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ...
1323 @overload
1324 def contains(self, /, *, x: int | float, y: int | float) -> bool: ...
1326 @overload
1327 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ...
1329 def contains(
1330 self,
1331 point: XY[Any] | YX[Any] | None = None,
1332 /,
1333 *,
1334 x: int | float | npt.ArrayLike | None = None,
1335 y: int | float | npt.ArrayLike | None = None,
1336 ) -> bool | np.ndarray:
1337 """Test whether one or more points fall within these bounds.
1339 Parameters
1340 ----------
1341 point
1342 An `XY` or `YX` coordinate pair to test for containment.
1343 Mutually exclusive with ``x`` and ``y``.
1344 x
1345 One or more X coordinates to test for containment, as a scalar or
1346 any array-like. Results are broadcast against ``y``.
1347 Mutually exclusive with ``point``.
1348 y
1349 One or more Y coordinates to test for containment, as a scalar or
1350 any array-like. Results are broadcast against ``x``.
1351 Mutually exclusive with ``point``.
1353 Returns
1354 -------
1355 `bool` | `numpy.ndarray`
1356 If ``x`` and ``y`` are both scalars, a single `bool` value. If
1357 ``x`` and ``y`` are array-like, a boolean array with their
1358 broadcasted shape.
1359 """
1360 ...
1362 def intersection(self, other: Bounds) -> Bounds:
1363 """Compute the intersection of this bounds object with another.
1365 Parameters
1366 ----------
1367 other
1368 Bounds to intersect with this one.
1369 """
1370 ...
1372 def serialize(self) -> BoundsSerializationModel:
1373 """Convert a bounds instance into a serializable object.
1375 Notes
1376 -----
1377 The returned object must support direct nesting with Pydantic models
1378 and have a ``deserialize`` method (taking no arguments) that converts
1379 back to this `Bounds` type. It is common for `serialize` and
1380 ``deserialize`` to just return ``self``, when the bounds object is
1381 natively serializable.
1382 """
1383 ...
1386class BoundsError(ValueError):
1387 """Exception raised when an object is evaluated outside its bounds."""
1390class NoOverlapError(ValueError):
1391 """Exception raised when intervals or bounds do not overlap."""
1394if TYPE_CHECKING:
1396 def _test_types() -> None:
1397 interval = Interval(0, 10)
1398 box = Box(y=Interval(0, 10), x=Interval(0, 10))
1399 arr = np.zeros(3)
1401 # Interval.contains: scalar → bool, array-like → np.ndarray
1402 assert_type(interval.contains(5), bool)
1403 assert_type(interval.contains(arr), np.ndarray)
1405 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray
1406 assert_type(box.contains(box), bool)
1407 assert_type(box.contains(y=3, x=4), bool)
1408 assert_type(box.contains(y=3.0, x=4.0), bool)
1409 assert_type(box.contains(y=arr, x=arr), np.ndarray)
1410 assert_type(box.contains(XY(3, 4)), bool)
1411 assert_type(box.contains(YX(4, 3)), bool)
1412 assert_type(box.contains(XY(arr, arr)), np.ndarray)
1413 assert_type(box.contains(YX(arr, arr)), np.ndarray)
1415 # Bounds.contains (Protocol): XY/YX, scalar, array-like
1416 bounds: Bounds = box
1417 assert_type(bounds.contains(x=1, y=1), bool)
1418 assert_type(bounds.contains(x=1.0, y=1.0), bool)
1419 assert_type(bounds.contains(x=arr, y=arr), np.ndarray)
1420 assert_type(bounds.contains(XY(1, 1)), bool)
1421 assert_type(bounds.contains(YX(1, 1)), bool)
1422 assert_type(bounds.contains(XY(arr, arr)), np.ndarray)
1423 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)