Coverage for python/lsst/images/_geom.py: 53%

482 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-10 09:42 +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. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "XY", 

16 "YX", 

17 "Bounds", 

18 "BoundsError", 

19 "Box", 

20 "BoxSliceFactory", 

21 "Interval", 

22 "IntervalSliceFactory", 

23 "NoOverlapError", 

24 "NotContainedError", 

25 "SerializableXY", 

26 "SerializableYX", 

27) 

28 

29import math 

30from collections.abc import Callable, Iterator, Sequence 

31from typing import ( 

32 TYPE_CHECKING, 

33 Annotated, 

34 Any, 

35 ClassVar, 

36 NamedTuple, 

37 Protocol, 

38 TypedDict, 

39 TypeVar, 

40 assert_type, 

41 final, 

42 get_args, 

43 overload, 

44 runtime_checkable, 

45) 

46 

47import astropy.units as u 

48import numpy as np 

49import numpy.typing as npt 

50import pydantic 

51import pydantic_core.core_schema as pcs 

52from pydantic.json_schema import JsonSchemaValue 

53 

54import starlink.Ast as Ast 

55 

56from .utils import round_half_down, round_half_up 

57 

58if TYPE_CHECKING: 

59 import astropy.coordinates 

60 

61 from ._concrete_bounds import BoundsSerializationModel 

62 from ._polygon import Polygon, Region 

63 from ._transforms import SkyProjection, Transform 

64 

65 try: 

66 from lsst.geom import Extent2D as LegacyExtent2D 

67 from lsst.geom import Extent2I as LegacyExtent2I 

68 from lsst.geom import Point2D as LegacyPoint2D 

69 from lsst.geom import Point2I as LegacyPoint2I 

70 except ImportError: 

71 type LegacyExtent2I = Any # type: ignore[no-redef] 

72 type LegacyPoint2I = Any # type: ignore[no-redef] 

73 type LegacyExtent2D = Any # type: ignore[no-redef] 

74 type LegacyPoint2D = Any # type: ignore[no-redef] 

75 

76# This pre-python-3.12 declaration is needed by Sphinx (probably the 

77# autodoc-typehints plugin. 

78T = TypeVar("T") 

79 

80# Interval and Box are defined as regular Python classes rather than 

81# dataclasses or Pydantic models because we might want to implement them as 

82# compiled-extension types in the future, and we want that to be transparent. 

83 

84# In a similar vein, we avoid declaring specific types for multidimensional 

85# points or extents (other than ``tuple[int, ...]`` for numpy-compatible 

86# shapes) in order to leave room for more fully-featured types to be added 

87# upstream of this package in the future. 

88 

89 

90class _SerializedYX[T](TypedDict): 

91 y: T 

92 x: T 

93 

94 

95class _SerializedXY[T](TypedDict): 

96 x: T 

97 y: T 

98 

99 

100class YX[T](NamedTuple): 

101 """A pair of per-dimension objects, ordered ``(y, x)``. 

102 

103 Notes 

104 ----- 

105 `YX` is used for slices, shapes, and other 2-d pairs when the most 

106 natural ordering is ``(y, x)``. Because it is a `tuple`, however, 

107 arithmetic operations behave as they would on a 

108 `collections.abc.Sequence`, not a mathematical vector (e.g. adding 

109 concatenates). 

110 

111 In Pydantic models `YX` is serialized as a JSON object with ``y`` and 

112 ``x`` keys rather than an array, so the dimension order is explicit in 

113 the serialized form. Validation also accepts a two-element ``[y, x]`` 

114 array, the form emitted before the object form was introduced and still 

115 present in files that cannot be rewritten. Pydantic only invokes the 

116 schema hooks that 

117 provide this for bare ``YX`` annotations, however, because it handles 

118 parameterized named tuples specially; model fields that need a member 

119 type (e.g. ``YX[int]``) must be annotated with `SerializableYX` instead. 

120 

121 See Also 

122 -------- 

123 XY 

124 """ 

125 

126 y: T 

127 """The y / row object.""" 

128 

129 x: T 

130 """The x / column object.""" 

131 

132 @property 

133 def xy(self) -> XY: 

134 """A tuple of the same objects in the opposite order.""" 

135 return XY(x=self.x, y=self.y) 

136 

137 def map[U](self, func: Callable[[T], U]) -> YX[U]: 

138 """Apply a function to both objects. 

139 

140 Parameters 

141 ---------- 

142 func 

143 Callable applied to each of the two objects in turn. 

144 """ 

145 return YX(y=func(self.y), x=func(self.x)) 

146 

147 def to_legacy_int_extent(self) -> LegacyExtent2I: 

148 """Convert to a legacy `lsst.geom.Extent2I` object.""" 

149 from lsst.geom import Extent2I as LegacyExtent2I 

150 

151 return LegacyExtent2I(self.x, self.y) 

152 

153 def to_legacy_int_point(self) -> LegacyPoint2I: 

154 """Convert to a legacy `lsst.geom.Point2I` object.""" 

155 from lsst.geom import Point2I as LegacyPoint2I 

156 

157 return LegacyPoint2I(self.x, self.y) 

158 

159 def to_legacy_float_extent(self) -> LegacyExtent2D: 

160 """Convert to a legacy `lsst.geom.Extent2D` object.""" 

161 from lsst.geom import Extent2D as LegacyExtent2D 

162 

163 return LegacyExtent2D(self.x, self.y) 

164 

165 def to_legacy_float_point(self) -> LegacyPoint2D: 

166 """Convert to a legacy `lsst.geom.Point2D` object.""" 

167 from lsst.geom import Point2D as LegacyPoint2D 

168 

169 return LegacyPoint2D(self.x, self.y) 

170 

171 @classmethod 

172 def __get_pydantic_core_schema__( 

173 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

174 ) -> pcs.CoreSchema: 

175 item_type = args[0] if (args := get_args(source_type)) else Any 

176 typed_dict = handler(_SerializedYX[item_type]) # type: ignore[valid-type] 

177 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict) 

178 # Files written before the object form was introduced (including the 

179 # DP2 cell coadds, which cannot be rewritten) store pairs as ``[y, x]`` 

180 # arrays, so validation must accept that form for as long as those 

181 # files remain readable, even if a future schema version stops 

182 # documenting it. This is a strict list schema so that a stray `XY` 

183 # (a tuple) assigned to a `YX` field fails to validate instead of 

184 # being silently transposed. 

185 from_list = pcs.no_info_after_validator_function( 

186 cls._from_list, 

187 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True), 

188 ) 

189 validation = pcs.union_schema([from_typed_dict, from_list]) 

190 return pcs.json_or_python_schema( 

191 json_schema=validation, 

192 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]), 

193 serialization=pcs.plain_serializer_function_ser_schema( 

194 cls._serialize, info_arg=False, return_schema=typed_dict 

195 ), 

196 ) 

197 

198 @classmethod 

199 def _validate(cls, data: _SerializedYX[T]) -> YX[T]: 

200 return cls(**data) 

201 

202 @classmethod 

203 def _from_list(cls, data: list[T]) -> YX[T]: 

204 return cls(*data) 

205 

206 def _serialize(self) -> _SerializedYX[T]: 

207 return {"y": self.y, "x": self.x} 

208 

209 

210class XY[T](NamedTuple): 

211 """A pair of per-dimension objects, ordered ``(x, y)``. 

212 

213 Notes 

214 ----- 

215 `XY` is used for points and other 2-d pairs when the most natural ordering 

216 is ``(x, y)``. Because it is a `tuple`, however, arithmetic operations 

217 behave as they would on a `collections.abc.Sequence`, not a mathematical 

218 vector (e.g. adding concatenates). 

219 

220 In Pydantic models `XY` is serialized as a JSON object with ``x`` and 

221 ``y`` keys rather than an array, so the dimension order is explicit in 

222 the serialized form. Validation also accepts a two-element ``[x, y]`` 

223 array, the form emitted before the object form was introduced and still 

224 present in files that cannot be rewritten. Pydantic only invokes the 

225 schema hooks that 

226 provide this for bare ``XY`` annotations, however, because it handles 

227 parameterized named tuples specially; model fields that need a member 

228 type (e.g. ``XY[float]``) must be annotated with `SerializableXY` instead. 

229 

230 See Also 

231 -------- 

232 YX 

233 """ 

234 

235 x: T 

236 """The x / column object.""" 

237 

238 y: T 

239 """The y / row object.""" 

240 

241 @property 

242 def yx(self) -> YX: 

243 """A tuple of the same objects in the opposite order.""" 

244 return YX(y=self.y, x=self.x) 

245 

246 def map[U](self, func: Callable[[T], U]) -> XY[U]: 

247 """Apply a function to both objects. 

248 

249 Parameters 

250 ---------- 

251 func 

252 Callable applied to each of the two objects in turn. 

253 """ 

254 return XY(x=func(self.x), y=func(self.y)) 

255 

256 def to_legacy_int_extent(self) -> LegacyExtent2I: 

257 """Convert to a legacy `lsst.geom.Extent2I` object.""" 

258 from lsst.geom import Extent2I as LegacyExtent2I 

259 

260 return LegacyExtent2I(self.x, self.y) 

261 

262 def to_legacy_int_point(self) -> LegacyPoint2I: 

263 """Convert to a legacy `lsst.geom.Point2I` object.""" 

264 from lsst.geom import Point2I as LegacyPoint2I 

265 

266 return LegacyPoint2I(self.x, self.y) 

267 

268 def to_legacy_float_extent(self) -> LegacyExtent2D: 

269 """Convert to a legacy `lsst.geom.Extent2D` object.""" 

270 from lsst.geom import Extent2D as LegacyExtent2D 

271 

272 return LegacyExtent2D(self.x, self.y) 

273 

274 def to_legacy_float_point(self) -> LegacyPoint2D: 

275 """Convert to a legacy `lsst.geom.Point2D` object.""" 

276 from lsst.geom import Point2D as LegacyPoint2D 

277 

278 return LegacyPoint2D(self.x, self.y) 

279 

280 @classmethod 

281 def __get_pydantic_core_schema__( 

282 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

283 ) -> pcs.CoreSchema: 

284 item_type = args[0] if (args := get_args(source_type)) else Any 

285 typed_dict = handler(_SerializedXY[item_type]) # type: ignore[valid-type] 

286 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict) 

287 # Files written before the object form was introduced store pairs as 

288 # ``[x, y]`` arrays, so validation must accept that form for as long 

289 # as those files remain readable, even if a future schema version 

290 # stops documenting it. This is a strict list schema so that a stray 

291 # `YX` (a tuple) assigned to an `XY` field fails to validate instead 

292 # of being silently transposed. 

293 from_list = pcs.no_info_after_validator_function( 

294 cls._from_list, 

295 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True), 

296 ) 

297 validation = pcs.union_schema([from_typed_dict, from_list]) 

298 return pcs.json_or_python_schema( 

299 json_schema=validation, 

300 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]), 

301 serialization=pcs.plain_serializer_function_ser_schema( 

302 cls._serialize, info_arg=False, return_schema=typed_dict 

303 ), 

304 ) 

305 

306 @classmethod 

307 def _validate(cls, data: _SerializedXY[T]) -> XY[T]: 

308 return cls(**data) 

309 

310 @classmethod 

311 def _from_list(cls, data: list[T]) -> XY[T]: 

312 return cls(*data) 

313 

314 def _serialize(self) -> _SerializedXY[T]: 

315 return {"x": self.x, "y": self.y} 

316 

317 

318SerializableXY = Annotated[XY[T], pydantic.GetPydanticSchema(XY.__get_pydantic_core_schema__)] 

319"""An `XY` annotation for Pydantic model fields that serializes as a JSON 

320object with ``x`` and ``y`` keys (`XY` alone serializes as an array when 

321parameterized, because Pydantic handles named tuples specially). 

322""" 

323 

324SerializableYX = Annotated[YX[T], pydantic.GetPydanticSchema(YX.__get_pydantic_core_schema__)] 

325"""A `YX` annotation for Pydantic model fields that serializes as a JSON 

326object with ``y`` and ``x`` keys (`YX` alone serializes as an array when 

327parameterized, because Pydantic handles named tuples specially). 

328""" 

329 

330 

331class _SerializedInterval(TypedDict): 

332 start: int 

333 stop: int 

334 

335 

336@final 

337class Interval: 

338 """A 1-d integer interval with positive size. 

339 

340 Intervals should generally be constructed by a classmethod factory (e.g. 

341 `hull` or `from_size`) or the `factory` slicing proxy:: 

342 

343 interval = Interval.factory[a:b] 

344 

345 This can be more verbose than invoking __init__ directly, but since it 

346 uses Python's syntax for half-inclusive ranges it's easier for readers to 

347 immediately see how the endpoints are interpreted. 

348 

349 Parameters 

350 ---------- 

351 start 

352 Inclusive minimum point in the interval. 

353 stop 

354 One past the maximum point in the interval. 

355 

356 Notes 

357 ----- 

358 Adding or subtracting an `int` from an interval returns a shifted interval. 

359 

360 `Interval` implements the necessary hooks to be included directly in a 

361 `pydantic.BaseModel`, even though it is neither a built-in type nor a 

362 Pydantic model itself. 

363 """ 

364 

365 def __init__(self, start: int, stop: int) -> None: 

366 # Coerce to be defensive against numpy int scalars. 

367 self._start = int(start) 

368 self._stop = int(stop) 

369 if not (self._stop > self._start): 

370 raise IndexError(f"Interval must have positive size; got [{self._start}, {self._stop})") 

371 

372 __slots__ = ("_start", "_stop") 

373 

374 factory: ClassVar[IntervalSliceFactory] 

375 """A factory for creating intervals using slice syntax. 

376 

377 For example:: 

378 

379 interval = Interval.factory[2:5] 

380 """ 

381 

382 @classmethod 

383 def hull(cls, first: int | Interval, *args: int | Interval) -> Interval: 

384 """Construct an interval that includes all of the given points and/or 

385 intervals. 

386 

387 Parameters 

388 ---------- 

389 first 

390 First point or interval to include in the hull. 

391 *args 

392 Additional points and/or intervals to include in the hull. 

393 """ 

394 if type(first) is Interval: 

395 rmin = first.min 

396 rmax = first.max 

397 else: 

398 rmin = rmax = first 

399 for arg in args: 

400 if type(arg) is Interval: 

401 rmin = min(rmin, arg.min) 

402 rmax = max(rmax, arg.max) 

403 else: 

404 rmin = min(rmin, arg) 

405 rmax = max(rmax, arg) 

406 return Interval(start=rmin, stop=rmax + 1) 

407 

408 @classmethod 

409 def from_size(cls, size: int, start: int = 0) -> Interval: 

410 """Construct an interval from its size and optional start. 

411 

412 Parameters 

413 ---------- 

414 size 

415 Number of points in the interval. 

416 start 

417 Inclusive minimum point in the interval. 

418 """ 

419 return cls(start=start, stop=start + size) 

420 

421 @property 

422 def min(self) -> int: 

423 """Inclusive minimum point in the interval (`int`).""" 

424 return self.start 

425 

426 @property 

427 def max(self) -> int: 

428 """Inclusive maximum point in the interval (`int`).""" 

429 return self.stop - 1 

430 

431 @property 

432 def start(self) -> int: 

433 """Inclusive minimum point in the interval (`int`).""" 

434 return self._start 

435 

436 @property 

437 def stop(self) -> int: 

438 """One past the maximum point in the interval (`int`).""" 

439 return self._stop 

440 

441 @property 

442 def size(self) -> int: 

443 """Size of the interval (`int`).""" 

444 return self.stop - self.start 

445 

446 @property 

447 def range(self) -> __builtins__.range: 

448 """An iterable over all values in the interval 

449 (`__builtins__.range`). 

450 """ 

451 return range(self.start, self.stop) 

452 

453 @property 

454 def arange(self) -> np.ndarray: 

455 """An array of all the values in the interval (`numpy.ndarray`). 

456 

457 Array values are integers. 

458 """ 

459 return np.arange(self.start, self.stop) 

460 

461 @property 

462 def absolute(self) -> IntervalSliceFactory: 

463 """A factory for constructing a contained `Interval` using slice 

464 syntax and absolute coordinates. 

465 

466 Notes 

467 ----- 

468 Slice bounds that are absent are replaced with the bounds of ``self``. 

469 """ 

470 return IntervalSliceFactory(self, is_local=False) 

471 

472 @property 

473 def local(self) -> IntervalSliceFactory: 

474 """A factory for constructing a contained `Interval` using a slice 

475 relative to the start of this one (`IntervalSliceFactory`). 

476 

477 Notes 

478 ----- 

479 This factory interprets slices as "local" coordinates, in which ``0`` 

480 corresponds to ``self.start``. Negative bounds are relative to 

481 ``self.stop``, as is usually the case for Python sequences. 

482 """ 

483 return IntervalSliceFactory(self, is_local=True) 

484 

485 def linspace(self, n: int | None = None, *, step: float | None = None) -> np.ndarray: 

486 """Return an array of values that spans the interval. 

487 

488 Parameters 

489 ---------- 

490 n 

491 How many values to return. The default (if ``step`` is also not 

492 provided) is the size of the interval, i.e. equivalent to the 

493 `arange` property (but converted to `float`). 

494 step 

495 Set ``n`` such that the distance between points is equal to or 

496 just less than this. Mutually exclusive with ``n``. 

497 

498 Returns 

499 ------- 

500 numpy.ndarray 

501 Array of `float` values. 

502 

503 See Also 

504 -------- 

505 numpy.linspace 

506 """ 

507 if n is None: 

508 if step is None: 

509 return self.arange.astype(np.float64) 

510 n = math.ceil(self.size / step) 

511 elif step is not None: 

512 raise TypeError("'n' and 'step' cannot both be provided.") 

513 return np.linspace(self.min, self.max, n, dtype=np.float64) 

514 

515 @property 

516 def center(self) -> float: 

517 """The center of the interval (`float`).""" 

518 return 0.5 * (self.min + self.max) 

519 

520 def padded(self, padding: int) -> Interval: 

521 """Return a new interval expanded by the given padding on 

522 either side. 

523 

524 Parameters 

525 ---------- 

526 padding 

527 Number of points to add to each side of the interval. 

528 """ 

529 return Interval(self.start - padding, self.stop + padding) 

530 

531 def __repr__(self) -> str: 

532 return f"Interval(start={self.start}, stop={self.stop})" 

533 

534 def __str__(self) -> str: 

535 return f"{self.start}:{self.stop}" 

536 

537 def __eq__(self, other: object) -> bool: 

538 if type(other) is Interval: 

539 return self._start == other._start and self._stop == other._stop 

540 return False 

541 

542 def __add__(self, other: int) -> Interval: 

543 return Interval(start=self.start + other, stop=self.stop + other) 

544 

545 def __sub__(self, other: int) -> Interval: 

546 return Interval(start=self.start - other, stop=self.stop - other) 

547 

548 def __contains__(self, x: int) -> bool: 

549 return x >= self.start and x < self.stop 

550 

551 @overload 

552 def contains(self, other: Interval | int | float) -> bool: ... 

553 

554 @overload 

555 def contains(self, other: npt.ArrayLike) -> np.ndarray: ... 

556 

557 def contains(self, other: Interval | int | float | npt.ArrayLike) -> bool | np.ndarray: 

558 """Test whether this interval fully contains another or one or more 

559 points. 

560 

561 Parameters 

562 ---------- 

563 other 

564 Another interval to compare to, or one or more position values as 

565 a scalar or any array-like. 

566 

567 Returns 

568 ------- 

569 `bool` | `numpy.ndarray` 

570 If a single interval or value was passed, a single `bool`. If an 

571 array-like was passed, an array with the broadcasted shape. 

572 

573 Notes 

574 ----- 

575 In order to yield the desired behavior for floating-point arguments, 

576 points are actually tested against an interval that is 0.5 larger on 

577 both sides: this makes positions within the outer boundary of pixels 

578 (but beyond the centers of those pixels, which have integer positions) 

579 appear "on the image". 

580 """ 

581 if isinstance(other, Interval): 

582 return self.start <= other.start and self.stop >= other.stop 

583 else: 

584 other = np.asarray(other) 

585 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5) 

586 if not result.shape: 

587 return bool(result) 

588 return result 

589 

590 def intersection(self, other: Interval) -> Interval: 

591 """Return an interval that is contained by both ``self`` and ``other``. 

592 

593 When there is no overlap between the intervals, `NoOverlapError` is 

594 raised. 

595 

596 Parameters 

597 ---------- 

598 other 

599 Interval to intersect with this one. 

600 """ 

601 new_start = max(self.start, other.start) 

602 new_stop = min(self.stop, other.stop) 

603 if new_start < new_stop: 

604 return Interval(start=new_start, stop=new_stop) 

605 raise NoOverlapError(f"No overlap between {self} and {other}.") 

606 

607 def dilated_by(self, padding: int) -> Interval: 

608 """Return a new interval padded by the given amount on both sides. 

609 

610 Parameters 

611 ---------- 

612 padding 

613 Number of points to add to each side of the interval. 

614 """ 

615 return Interval(start=self._start - padding, stop=self._stop + padding) 

616 

617 def slice_within(self, other: Interval) -> slice: 

618 """Return the `slice` that corresponds to the values in this interval 

619 when the items of the container being sliced correspond to ``other``. 

620 

621 This assumes ``other.contains(self)``. 

622 

623 Parameters 

624 ---------- 

625 other 

626 Interval whose values correspond to the container being sliced. 

627 """ 

628 if not other.contains(self): 

629 raise IndexError( 

630 f"Can not calculate a slice of {other} within {self} " 

631 "since the given interval does not contain this one." 

632 ) 

633 return slice(self.start - other.start, self.stop - other.start) 

634 

635 @classmethod 

636 def from_legacy(cls, legacy: Any) -> Interval: 

637 """Convert from an `lsst.geom.IntervalI` instance. 

638 

639 Parameters 

640 ---------- 

641 legacy 

642 Legacy `lsst.geom.IntervalI` instance to convert. 

643 """ 

644 return cls(legacy.begin, legacy.end) 

645 

646 def to_legacy(self) -> Any: 

647 """Convert to an `lsst.geom.IntervalI` instance.""" 

648 from lsst.geom import IntervalI 

649 

650 return IntervalI(min=self.min, max=self.max) 

651 

652 def __reduce__(self) -> tuple[type[Interval], tuple[int, int]]: 

653 return ( 

654 Interval, 

655 ( 

656 self._start, 

657 self._stop, 

658 ), 

659 ) 

660 

661 @classmethod 

662 def __get_pydantic_core_schema__( 

663 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

664 ) -> pcs.CoreSchema: 

665 from_typed_dict = pcs.chain_schema( 

666 [ 

667 handler(_SerializedInterval), 

668 pcs.no_info_plain_validator_function(cls._validate), 

669 ] 

670 ) 

671 return pcs.json_or_python_schema( 

672 json_schema=from_typed_dict, 

673 python_schema=pcs.union_schema([pcs.is_instance_schema(Interval), from_typed_dict]), 

674 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False), 

675 ) 

676 

677 @classmethod 

678 def __get_pydantic_json_schema__( 

679 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler 

680 ) -> JsonSchemaValue: 

681 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema) 

682 

683 @classmethod 

684 def _validate(cls, data: _SerializedInterval) -> Interval: 

685 return cls(**data) 

686 

687 def _serialize(self) -> _SerializedInterval: 

688 return {"start": self._start, "stop": self._stop} 

689 

690 

691class IntervalSliceFactory: 

692 """A factory for `Interval` objects using array-slice syntax. 

693 

694 Parameters 

695 ---------- 

696 parent 

697 Interval that constructed intervals must be contained by, or `None` 

698 to allow any bounds. 

699 is_local 

700 Whether slice bounds are interpreted relative to the start of 

701 ``parent`` rather than as absolute coordinates. 

702 

703 Notes 

704 ----- 

705 When indexed with a single slice on the `Interval.factory` attribute, this 

706 returns an `Interval` with exactly the given bounds:: 

707 

708 assert Interval.factory[3:6] == Interval(start=3, stop=6) 

709 

710 A missing start bound is replaced by ``0``, but a missing stop bound is 

711 not allowed. 

712 

713 When obtained from the `Interval.absolute` property, indices are absolute 

714 coordinate values, but any omitted bounds are replaced with the parent 

715 interval's bounds:: 

716 

717 parent = Interval.factory[3:6] 

718 assert Interval.factory[4:5] == parent.absolute[:5] 

719 

720 The final interval is also checked to be contained by the parent interval. 

721 

722 When obtained from the `Interval.local` property, indices are interpreted 

723 as relative to the parent interval, and negative indices are relative to 

724 the end (like `~collections.abc.Sequence` indexing):: 

725 

726 parent = Interval.factory[3:6] 

727 assert Interval.factory[4:5] == parent.local[1:-1] 

728 

729 When the stop bound is greater than the size of the parent interval, the 

730 returned interval is clipped to be contained by the parent (as in 

731 `~collections.abc.Sequence` indexing). 

732 """ 

733 

734 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None: 

735 self._parent = parent 

736 self._is_local = is_local 

737 

738 def __getitem__(self, s: slice) -> Interval: 

739 if s.step is not None and s.step != 1: 

740 raise ValueError(f"Slice {s} has non-unit step.") 

741 if self._is_local: 

742 assert self._parent is not None, "is_local=True requires a parent interval" 

743 start, stop, _ = s.indices(self._parent.size) 

744 start += self._parent.start 

745 stop += self._parent.start 

746 else: 

747 start = s.start 

748 stop = s.stop 

749 if start is None: 

750 if self._parent is None: 

751 start = 0 

752 else: 

753 start = self._parent.start 

754 if stop is None: 

755 if self._parent is None: 755 ↛ 756line 755 didn't jump to line 756 because the condition on line 755 was never true

756 raise IndexError("An Interval cannot have an empty upper bound.") 

757 stop = self._parent.stop 

758 if self._parent is not None: 

759 if start < self._parent.start: 

760 raise IndexError(f"Absolute start {start} (passed as {s.start}) is < {self._parent.start}.") 

761 if stop > self._parent.stop: 

762 raise IndexError(f"Absolute stop {stop} (passed as {s.stop}) is > {self._parent.stop}.") 

763 return Interval(start=start, stop=stop) 

764 

765 

766Interval.factory = IntervalSliceFactory() 

767 

768 

769class _SerializedBox(TypedDict): 

770 y: _SerializedInterval 

771 x: _SerializedInterval 

772 

773 

774class Box: 

775 """An axis-aligned 2-d rectangular region. 

776 

777 Boxes should generally be constructed by a classmethod factory (e.g. 

778 `from_shape`, `from_float_bounds`, `from_sky_circle`) or the `factory` 

779 slicing proxy:: 

780 

781 box = Box.factory[y_a:y_b, x_a:x_b] 

782 

783 unless the ``y`` and ``x`` intervals are already available. 

784 

785 Parameters 

786 ---------- 

787 y 

788 Interval for the y dimension. 

789 x 

790 Interval for the x dimension. 

791 

792 Raises 

793 ------ 

794 TypeError 

795 Raised if ``y`` or ``x`` is not an `Interval`. 

796 

797 Notes 

798 ----- 

799 `Box` implements the necessary hooks to be included directly in a 

800 `pydantic.BaseModel`, even though it is neither a built-in type nor a 

801 Pydantic model itself. 

802 """ 

803 

804 def __init__(self, y: Interval, x: Interval) -> None: 

805 match (y, x): 

806 case (Interval(), Interval()): 

807 self._intervals = YX(y, x) 

808 case _: 

809 raise TypeError( 

810 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). " 

811 "See the Box.factory slice syntax and the from_* class methods for other ways " 

812 "to construct a Box." 

813 ) 

814 

815 __slots__ = ("_intervals",) 

816 

817 factory: ClassVar[BoxSliceFactory] 

818 """A factory for creating boxes using slice syntax. 

819 

820 For example:: 

821 

822 box = Box.factory[2:5, 3:9] 

823 """ 

824 

825 @classmethod 

826 def from_shape(cls, shape: Sequence[int], start: Sequence[int] | None = None) -> Box: 

827 """Construct a box from its shape and optional start. 

828 

829 Parameters 

830 ---------- 

831 shape 

832 Sequence of sizes, ordered ``(y, x)`` (except for `XY` instances). 

833 start 

834 Sequence of starts, ordered ``(y, x)`` (except for `XY` instances). 

835 """ 

836 if start is None: 

837 start = (0,) * len(shape) 

838 match shape: 

839 case XY(x=x_size, y=y_size): 

840 pass 

841 case [y_size, x_size]: 

842 pass 

843 case _: 

844 raise ValueError(f"Invalid sequence for shape: {shape!r}.") 

845 match start: 

846 case XY(x=x_start, y=y_start): 

847 pass 

848 case [y_start, x_start]: 

849 pass 

850 case _: 

851 raise ValueError(f"Invalid sequence for start: {start!r}.") 

852 return Box(y=Interval.from_size(y_size, start=y_start), x=Interval.from_size(x_size, start=x_start)) 

853 

854 @classmethod 

855 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box: 

856 """Construct a box from floating-point bounds ensuring that all the 

857 are contained in the new box. 

858 

859 Parameters 

860 ---------- 

861 x_min 

862 Minimum X value. 

863 x_max 

864 Maximum X value. 

865 y_min 

866 Minimum Y value. 

867 y_max 

868 Maximum Y value. 

869 

870 Notes 

871 ----- 

872 Uses the same rounding convention as `lsst.images.Region.bbox`, so that 

873 pixels whose centers lie within the bounds are included. 

874 """ 

875 return Box.factory[ 

876 round_half_up(y_min) : round_half_down(y_max) + 1, 

877 round_half_up(x_min) : round_half_down(x_max) + 1, 

878 ] 

879 

880 @classmethod 

881 def from_sky_circle( 

882 cls, 

883 sky_projection: SkyProjection, 

884 center: astropy.coordinates.SkyCoord, 

885 radius: astropy.coordinates.Angle, 

886 ) -> Box: 

887 """Calculate a bounding box from a sky projection and a circular 

888 sky region. 

889 

890 Parameters 

891 ---------- 

892 sky_projection 

893 The sky projection mapping pixels to sky. 

894 center 

895 The center of the circle, as a scalar 

896 `astropy.coordinates.SkyCoord` in any frame. 

897 radius 

898 Radius of the circle, as a scalar `astropy.coordinates.Angle`. 

899 

900 Returns 

901 ------- 

902 Box 

903 Bounding box enclosing the circle based on the sky projection. 

904 

905 Raises 

906 ------ 

907 ValueError 

908 Raised if ``center`` or ``radius`` is not scalar, or if this 

909 image has no sky projection. 

910 """ 

911 if not center.isscalar: 

912 raise ValueError("The center of the sky circle must be a scalar SkyCoord.") 

913 if not radius.isscalar: 

914 raise ValueError("The radius of the sky circle must be a scalar Angle.") 

915 center = center.transform_to("icrs") 

916 

917 # Use pyast directly for the region handling. 

918 sky_region = Ast.Circle( 

919 Ast.SkyFrame("System=ICRS"), 

920 1, 

921 [center.ra.rad, center.dec.rad], 

922 [radius.to_value(u.rad)], 

923 ) 

924 

925 # If it is not already a pyast mapping 

926 # (e.g., it is implemented with astshim), convert it by round-tripping 

927 # the AST textual serialization through a pyast Channel. 

928 sky_to_pixel: Any = sky_projection.sky_to_pixel_transform._ast_mapping 

929 if not isinstance(sky_to_pixel, Ast.Mapping): 929 ↛ 935line 929 didn't jump to line 935 because the condition on line 929 was always true

930 # Comments must be disabled for pyast to be able to parse the 

931 # astshim serialization. 

932 sky_to_pixel = Ast.Channel(sky_to_pixel.show(False).splitlines()).read() 

933 

934 # Calculate the Box around the region. 

935 pixel_region = sky_region.mapregion(sky_to_pixel, Ast.Frame(2)) 

936 lbnd, ubnd = pixel_region.getregionbounds() 

937 return Box.from_float_bounds( 

938 x_min=float(lbnd[0]), 

939 x_max=float(ubnd[0]), 

940 y_min=float(lbnd[1]), 

941 y_max=float(ubnd[1]), 

942 ) 

943 

944 @property 

945 def min(self) -> YX[int]: 

946 """The inclusive minimum bounds of the box, ordered ``(y, x)`` 

947 (`YX` [`int`]). 

948 """ 

949 return YX(y=self._intervals.y.min, x=self._intervals.x.min) 

950 

951 @property 

952 def max(self) -> YX[int]: 

953 """The inclusive maximum bounds of the box, ordered ``(y, x)`` 

954 (`YX` [`int`]). 

955 """ 

956 return YX(y=self._intervals.y.max, x=self._intervals.x.max) 

957 

958 @property 

959 def start(self) -> YX[int]: 

960 """Tuple holding the inclusive `Interval.start` bvound, ordered 

961 ``(y, x)`` (`YX` [`int`]). 

962 

963 This is an alias for `min`, typically paired with `stop` for 

964 half-exclusive ranges. 

965 """ 

966 return YX(self.y.start, self.x.start) 

967 

968 @property 

969 def stop(self) -> YX[int]: 

970 """Tuple holding the exclusive `Interval.stop` bound, ordered 

971 ``(y, x)`` (`YX` [`int`]). 

972 

973 The values in this tuple are one greater than those in `max`. It is 

974 typically paired with `start` for half-exclusive ranges. 

975 """ 

976 return YX(self.y.stop, self.x.stop) 

977 

978 @property 

979 def shape(self) -> YX[int]: 

980 """Tuple holding the sizes of the intervals, ordered ``(y, x)`` 

981 (`YX` [`int`]). 

982 """ 

983 return YX(self.y.size, self.x.size) 

984 

985 @property 

986 def x(self) -> Interval: 

987 """The x-dimension interval (`int`).""" 

988 return self._intervals[-1] 

989 

990 @property 

991 def y(self) -> Interval: 

992 """The y-dimension interval (`int`).""" 

993 return self._intervals[-2] 

994 

995 @property 

996 def center(self) -> XY[float]: 

997 """The center of the box (`XY` [`float`]).""" 

998 return XY(x=self.x.center, y=self.y.center) 

999 

1000 @property 

1001 def area(self) -> int: 

1002 """The number of pixels in the box (`int`).""" 

1003 return self.x.size * self.y.size 

1004 

1005 @property 

1006 def absolute(self) -> BoxSliceFactory: 

1007 """A factory for constructing a contained `Box` using slice 

1008 syntax and absolute coordinates. 

1009 

1010 Notes 

1011 ----- 

1012 Slice bounds that are absent are replaced with the bounds of ``self``. 

1013 """ 

1014 return BoxSliceFactory(y=self.y.absolute, x=self.x.absolute) 

1015 

1016 @property 

1017 def local(self) -> BoxSliceFactory: 

1018 """A factory for constructing a contained `Interval` using a slice 

1019 relative to the start of this one (`BoxSliceFactory`). 

1020 

1021 Notes 

1022 ----- 

1023 This factory interprets slices as "local" coordinates, in which ``0`` 

1024 corresponds to ``self.start``. Negative bounds are relative to 

1025 ``self.stop``, as is usually the case for Python sequences. 

1026 """ 

1027 return BoxSliceFactory(y=self.y.local, x=self.x.local) 

1028 

1029 def meshgrid(self, n: int | Sequence[int] | None = None, *, step: float | None = None) -> XY[np.ndarray]: 

1030 """Return a pair of 2-d arrays of the coordinate values of the box. 

1031 

1032 Parameters 

1033 ---------- 

1034 n 

1035 Number of points in each dimension. If a sequence, points are 

1036 assumed to be ordered ``(x, y)`` unless a `YX` instance is 

1037 provided. 

1038 step 

1039 Set ``n`` such that the distance between points is equal to or 

1040 just less than this in each dimension. Mutually exclusive with 

1041 ``n``. 

1042 

1043 Returns 

1044 ------- 

1045 `XY` [`numpy.ndarray`] 

1046 A pair of arrays, each of which is 2-d with floating-point values. 

1047 

1048 See Also 

1049 -------- 

1050 numpy.meshgrid 

1051 """ 

1052 if n is not None and step is not None: 

1053 raise TypeError("'n' and 'step' cannot both be provided.") 

1054 match n: 

1055 case int(): 

1056 ax = self.x.linspace(n) 

1057 ay = self.y.linspace(n) 

1058 case YX(y=ny, x=nx): 

1059 ax = self.x.linspace(nx) 

1060 ay = self.y.linspace(ny) 

1061 case [nx, ny]: 

1062 ax = self.x.linspace(nx) 

1063 ay = self.y.linspace(ny) 

1064 case None: 

1065 ax = self.x.linspace(step=step) 

1066 ay = self.y.linspace(step=step) 

1067 case _: 

1068 raise ValueError(f"Unexpected values for n ({n})") 

1069 return XY(*np.meshgrid(ax, ay)) 

1070 

1071 def padded(self, padding: int) -> Box: 

1072 """Return a new box expanded by the given padding on 

1073 all sides. 

1074 

1075 Parameters 

1076 ---------- 

1077 padding 

1078 Number of pixels to expand the box by on every side. 

1079 """ 

1080 return Box(y=self.y.padded(padding), x=self.x.padded(padding)) 

1081 

1082 def __eq__(self, other: object) -> bool: 

1083 if type(other) is Box: 

1084 return self._intervals == other._intervals 

1085 return False 

1086 

1087 def __repr__(self) -> str: 

1088 return f"Box(y={self.y!r}, x={self.x!r})" 

1089 

1090 def __str__(self) -> str: 

1091 return f"[y={self.y}, x={self.x}]" 

1092 

1093 @overload 

1094 def contains(self, other: Box, /) -> bool: ... 

1095 

1096 @overload 

1097 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 

1098 

1099 @overload 

1100 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 

1101 

1102 @overload 

1103 def contains(self, /, *, y: int | float, x: int | float) -> bool: ... 

1104 

1105 @overload 

1106 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ... 

1107 

1108 def contains( 

1109 self, 

1110 other: Box | XY[Any] | YX[Any] | None = None, 

1111 /, 

1112 *, 

1113 y: Any = None, 

1114 x: Any = None, 

1115 ) -> bool | np.ndarray: 

1116 """Test whether this box fully contains another or one or more points. 

1117 

1118 Parameters 

1119 ---------- 

1120 other 

1121 Another box, or an `XY` or `YX` coordinate pair, to compare to. 

1122 Mutually exclusive with ``x`` and ``y``. 

1123 y 

1124 One or more Y coordinates to test for containment, as a scalar or 

1125 any array-like. Results are broadcast against ``x``. 

1126 Mutually exclusive with ``other``. 

1127 x 

1128 One or more X coordinates to test for containment, as a scalar or 

1129 any array-like. Results are broadcast against ``y``. 

1130 Mutually exclusive with ``other``. 

1131 

1132 Returns 

1133 ------- 

1134 `bool` | `numpy.ndarray` 

1135 If ``other`` was passed or ``x`` and ``y`` are both scalars, a 

1136 single `bool` value. If ``x`` and ``y`` are array-like, a boolean 

1137 array with their broadcasted shape. 

1138 

1139 Notes 

1140 ----- 

1141 In order to yield the desired behavior for floating-point arguments, 

1142 points are actually tested against an interval that is 0.5 larger on 

1143 both sides: this makes positions within the outer boundary of pixels 

1144 (but beyond the centers of those pixels, which have integer positions) 

1145 appear "on the image". 

1146 """ 

1147 match other: 

1148 case None: 

1149 if x is None or y is None: 

1150 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.") 

1151 case Box(): 

1152 if x is not None or y is not None: 

1153 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.") 

1154 return all(a.contains(b) for a, b in zip(self._intervals, other._intervals, strict=True)) 

1155 case XY() | YX(): 1155 ↛ 1159line 1155 didn't jump to line 1159 because the pattern on line 1155 always matched

1156 if x is not None or y is not None: 

1157 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.") 

1158 x, y = other.x, other.y 

1159 case _: 

1160 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.") 

1161 result = np.logical_and(self.x.contains(x), self.y.contains(y)) 

1162 if not result.shape: 

1163 return bool(result) 

1164 return result 

1165 

1166 @overload 

1167 def intersection(self, other: Box) -> Box: ... 

1168 

1169 @overload 

1170 def intersection(self, other: Region) -> Region | Box: ... 

1171 

1172 @overload 

1173 def intersection(self, other: Bounds) -> Bounds: ... 

1174 

1175 def intersection(self, other: Bounds) -> Bounds: 

1176 """Return a bounds object that is contained by both ``self`` and 

1177 ``other``. 

1178 

1179 When there is no overlap, `NoOverlapError` is raised. 

1180 

1181 Parameters 

1182 ---------- 

1183 other 

1184 Bounds to intersect with this one. 

1185 """ 

1186 from ._concrete_bounds import _intersect_box 

1187 

1188 return _intersect_box(self, other) 

1189 

1190 def dilated_by(self, padding: int) -> Box: 

1191 """Return a new box padded by the given amount on all sides. 

1192 

1193 Parameters 

1194 ---------- 

1195 padding 

1196 Number of pixels to pad the box by on every side. 

1197 """ 

1198 return Box(*[i.dilated_by(padding) for i in self._intervals]) 

1199 

1200 def slice_within(self, other: Box) -> YX[slice]: 

1201 """Return a `tuple` of `slice` objects that correspond to the 

1202 positions in this box when the items of the container being sliced 

1203 correspond to ``other``. 

1204 

1205 This assumes ``other.contains(self)``. 

1206 

1207 Parameters 

1208 ---------- 

1209 other 

1210 Box that the sliced container's items correspond to. 

1211 """ 

1212 return YX(self.y.slice_within(other.y), self.x.slice_within(other.x)) 

1213 

1214 @property 

1215 def bbox(self) -> Box: 

1216 """The box itself (`Box`). 

1217 

1218 This is provided for compatibility with the `Bounds` interface. 

1219 """ 

1220 return self 

1221 

1222 def boundary(self) -> Iterator[YX[int]]: 

1223 """Iterate over the corners of the box as ``(y, x)`` tuples. 

1224 

1225 Yields 

1226 ------ 

1227 corner 

1228 Each corner in turn. 

1229 """ 

1230 if len(self._intervals) != 2: 1230 ↛ 1231line 1230 didn't jump to line 1231 because the condition on line 1230 was never true

1231 raise TypeError("Box is not 2-d.") 

1232 yield YX(self.y.min, self.x.min) 

1233 yield YX(self.y.min, self.x.max) 

1234 yield YX(self.y.max, self.x.max) 

1235 yield YX(self.y.max, self.x.min) 

1236 

1237 def to_polygon(self) -> Polygon: 

1238 """Convert the box to a polygon with floating-point vertices. 

1239 

1240 Notes 

1241 ----- 

1242 Because the integer min and max coordinates of a box are 

1243 interpreted as pixel centers, these are expanded by 0.5 on all sides 

1244 before using them to form the polygon vertices. 

1245 """ 

1246 from ._polygon import Polygon 

1247 

1248 return Polygon.from_box(self) 

1249 

1250 def transform(self, transform: Transform[Any, Any]) -> Polygon: 

1251 """Apply a coordinate transform to the box, returning a polygon. 

1252 

1253 Parameters 

1254 ---------- 

1255 transform 

1256 Coordinate transform to apply (in the forward direction). 

1257 

1258 Notes 

1259 ----- 

1260 This transforms the polygon representation of the box (see 

1261 `to_polygon`), which expands its vertices by 0.5 on all sides to cover 

1262 full pixels before transforming them. 

1263 """ 

1264 return self.to_polygon().transform(transform) 

1265 

1266 def __reduce__(self) -> tuple[type[Box], tuple[Interval, ...]]: 

1267 return (Box, self._intervals) 

1268 

1269 @classmethod 

1270 def from_legacy(cls, legacy: Any) -> Box: 

1271 """Convert from an `lsst.geom.Box2I` instance. 

1272 

1273 Parameters 

1274 ---------- 

1275 legacy 

1276 Legacy `lsst.geom.Box2I` to convert. 

1277 """ 

1278 return cls(y=Interval.from_legacy(legacy.y), x=Interval.from_legacy(legacy.x)) 

1279 

1280 def to_legacy(self) -> Any: 

1281 """Convert to an `lsst.geom.BoxI` instance.""" 

1282 from lsst.geom import Box2I 

1283 

1284 return Box2I(x=self.x.to_legacy(), y=self.y.to_legacy()) 

1285 

1286 @classmethod 

1287 def __get_pydantic_core_schema__( 

1288 cls, source_type: Any, handler: pydantic.GetCoreSchemaHandler 

1289 ) -> pcs.CoreSchema: 

1290 from_typed_dict = pcs.chain_schema( 

1291 [ 

1292 handler(_SerializedBox), 

1293 pcs.no_info_plain_validator_function(cls._validate), 

1294 ] 

1295 ) 

1296 return pcs.json_or_python_schema( 

1297 json_schema=from_typed_dict, 

1298 python_schema=pcs.union_schema([pcs.is_instance_schema(Box), from_typed_dict]), 

1299 serialization=pcs.plain_serializer_function_ser_schema(cls._serialize, info_arg=False), 

1300 ) 

1301 

1302 @classmethod 

1303 def __get_pydantic_json_schema__( 

1304 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler 

1305 ) -> JsonSchemaValue: 

1306 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema) 

1307 

1308 @classmethod 

1309 def _validate(cls, data: _SerializedBox) -> Box: 

1310 return cls(y=Interval._validate(data["y"]), x=Interval._validate(data["x"])) 

1311 

1312 def _serialize(self) -> _SerializedBox: 

1313 return {"y": self.y._serialize(), "x": self.x._serialize()} 

1314 

1315 def serialize(self) -> Box: 

1316 """Return a Pydantic-friendly representation of this object. 

1317 

1318 This method just returns the `Box` itself, since that already provides 

1319 Pydantic serialization hooks. It exists for compatibility with the 

1320 `Bounds` protocol. 

1321 """ 

1322 return self 

1323 

1324 def deserialize(self) -> Box: 

1325 """Deserialize a bounds object on the assumption it is a `Box`. 

1326 

1327 This method just returns the `Box` itself, since that already provides 

1328 Pydantic serialization hooks. It exists for compatibility with the 

1329 `Bounds` protocol. 

1330 """ 

1331 return self 

1332 

1333 

1334class BoxSliceFactory: 

1335 """A factory for `Box` objects using array-slice syntax. 

1336 

1337 Parameters 

1338 ---------- 

1339 y 

1340 Slice factory used for the y axis. 

1341 x 

1342 Slice factory used for the x axis. 

1343 

1344 Notes 

1345 ----- 

1346 When `Box.factory` is indexed with a pair of slices, this returns a 

1347 `Box` with exactly those bounds:: 

1348 

1349 assert ( 

1350 Box.factory[3:6, -1:2] 

1351 == Box(x=Interval(start=-1, stop=2), y=Interval(start=3, stop=6) 

1352 ) 

1353 

1354 A missing start bound is replaced by ``0``, but a missing stop bound is 

1355 not allowed. 

1356 

1357 When obtained from the `Box.absolute` property, indices are absolute 

1358 coordinate values, but any omitted bounds are replaced with the parent 

1359 box's bounds:: 

1360 

1361 parent = Box.factory[3:6, -1:2] 

1362 assert Box.factory[4:5, 0:2] == parent.absolute[:5, 0:] 

1363 

1364 The final box is also checked to be contained by the parent box. 

1365 

1366 When obtained from the `Box.local` property, indices are interpreted 

1367 as relative to the parent box, and negative indices are relative to 

1368 the end (like `~collections.abc.Sequence` indexing):: 

1369 

1370 parent = Box.factory[3:6, -1:2] 

1371 assert Box.factory[4:5, 0:2] == parent.local[1:-1, 1:] 

1372 """ 

1373 

1374 def __init__( 

1375 self, y: IntervalSliceFactory = Interval.factory, x: IntervalSliceFactory = Interval.factory 

1376 ) -> None: 

1377 self._y = y 

1378 self._x = x 

1379 

1380 def __getitem__(self, key: tuple[slice, slice]) -> Box: 

1381 match key: 

1382 case XY(x=x, y=y): 

1383 return Box(y=self._y[y], x=self._x[x]) 

1384 case (y, x): 

1385 return Box(y=self._y[y], x=self._x[x]) 

1386 case _: 

1387 raise TypeError("Expected exactly two slices.") 

1388 

1389 

1390Box.factory = BoxSliceFactory() 

1391 

1392 

1393# The @runtime_checkable decorator makes this work as a butler storage class 

1394@runtime_checkable 

1395class Bounds(Protocol): 

1396 """A protocol for objects that represent the validity region for a function 

1397 defined in 2-d pixel coordinates. 

1398 

1399 Notes 

1400 ----- 

1401 Most objects natively have a simple 2-d bounding box as their bounds 

1402 (typically the boundary of a sensor), and the `Box` class is hence the 

1403 most common bounds implementation. But sometimes a large chunk of that 

1404 box may be missing due to vignetting or bad amplifiers, and we may want to 

1405 transform from one coordinate system to another. The Bounds interface is 

1406 intended to handle both of these cases as well. 

1407 """ 

1408 

1409 @property 

1410 def bbox(self) -> Box: ... 

1411 

1412 @overload 

1413 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 

1414 

1415 @overload 

1416 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 

1417 

1418 @overload 

1419 def contains(self, /, *, x: int | float, y: int | float) -> bool: ... 

1420 

1421 @overload 

1422 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ... 

1423 

1424 def contains( 

1425 self, 

1426 point: XY[Any] | YX[Any] | None = None, 

1427 /, 

1428 *, 

1429 x: int | float | npt.ArrayLike | None = None, 

1430 y: int | float | npt.ArrayLike | None = None, 

1431 ) -> bool | np.ndarray: 

1432 """Test whether one or more points fall within these bounds. 

1433 

1434 Parameters 

1435 ---------- 

1436 point 

1437 An `XY` or `YX` coordinate pair to test for containment. 

1438 Mutually exclusive with ``x`` and ``y``. 

1439 x 

1440 One or more X coordinates to test for containment, as a scalar or 

1441 any array-like. Results are broadcast against ``y``. 

1442 Mutually exclusive with ``point``. 

1443 y 

1444 One or more Y coordinates to test for containment, as a scalar or 

1445 any array-like. Results are broadcast against ``x``. 

1446 Mutually exclusive with ``point``. 

1447 

1448 Returns 

1449 ------- 

1450 `bool` | `numpy.ndarray` 

1451 If ``x`` and ``y`` are both scalars, a single `bool` value. If 

1452 ``x`` and ``y`` are array-like, a boolean array with their 

1453 broadcasted shape. 

1454 """ 

1455 ... 

1456 

1457 def intersection(self, other: Bounds) -> Bounds: 

1458 """Compute the intersection of this bounds object with another. 

1459 

1460 Parameters 

1461 ---------- 

1462 other 

1463 Bounds to intersect with this one. 

1464 """ 

1465 ... 

1466 

1467 def serialize(self) -> BoundsSerializationModel: 

1468 """Convert a bounds instance into a serializable object. 

1469 

1470 Notes 

1471 ----- 

1472 The returned object must support direct nesting with Pydantic models 

1473 and have a ``deserialize`` method (taking no arguments) that converts 

1474 back to this `Bounds` type. It is common for `serialize` and 

1475 ``deserialize`` to just return ``self``, when the bounds object is 

1476 natively serializable. 

1477 """ 

1478 ... 

1479 

1480 

1481class BoundsError(ValueError): 

1482 """Exception raised when an object is evaluated outside its bounds.""" 

1483 

1484 

1485class NoOverlapError(ValueError): 

1486 """Exception raised when intervals or bounds do not overlap.""" 

1487 

1488 

1489class NotContainedError(RuntimeError): 

1490 """Exception raised when intervals or bounds are not fully contained by 

1491 another. 

1492 """ 

1493 

1494 

1495if TYPE_CHECKING: 

1496 

1497 def _test_types() -> None: 

1498 interval = Interval(0, 10) 

1499 box = Box(y=Interval(0, 10), x=Interval(0, 10)) 

1500 arr = np.zeros(3) 

1501 

1502 # Interval.contains: scalar → bool, array-like → np.ndarray 

1503 assert_type(interval.contains(5), bool) 

1504 assert_type(interval.contains(arr), np.ndarray) 

1505 

1506 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray 

1507 assert_type(box.contains(box), bool) 

1508 assert_type(box.contains(y=3, x=4), bool) 

1509 assert_type(box.contains(y=3.0, x=4.0), bool) 

1510 assert_type(box.contains(y=arr, x=arr), np.ndarray) 

1511 assert_type(box.contains(XY(3, 4)), bool) 

1512 assert_type(box.contains(YX(4, 3)), bool) 

1513 assert_type(box.contains(XY(arr, arr)), np.ndarray) 

1514 assert_type(box.contains(YX(arr, arr)), np.ndarray) 

1515 

1516 # Bounds.contains (Protocol): XY/YX, scalar, array-like 

1517 bounds: Bounds = box 

1518 assert_type(bounds.contains(x=1, y=1), bool) 

1519 assert_type(bounds.contains(x=1.0, y=1.0), bool) 

1520 assert_type(bounds.contains(x=arr, y=arr), np.ndarray) 

1521 assert_type(bounds.contains(XY(1, 1)), bool) 

1522 assert_type(bounds.contains(YX(1, 1)), bool) 

1523 assert_type(bounds.contains(XY(arr, arr)), np.ndarray) 

1524 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)