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

478 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-22 17:43 +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) 

45 

46import astropy.units as u 

47import numpy as np 

48import numpy.typing as npt 

49import pydantic 

50import pydantic_core.core_schema as pcs 

51from pydantic.json_schema import JsonSchemaValue 

52 

53import starlink.Ast as Ast 

54 

55from .utils import round_half_down, round_half_up 

56 

57if TYPE_CHECKING: 

58 import astropy.coordinates 

59 

60 from ._concrete_bounds import BoundsSerializationModel 

61 from ._polygon import Polygon, Region 

62 from ._transforms import SkyProjection, Transform 

63 

64 try: 

65 from lsst.geom import Extent2D as LegacyExtent2D 

66 from lsst.geom import Extent2I as LegacyExtent2I 

67 from lsst.geom import Point2D as LegacyPoint2D 

68 from lsst.geom import Point2I as LegacyPoint2I 

69 except ImportError: 

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

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

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

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

74 

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

76# autodoc-typehints plugin. 

77T = TypeVar("T") 

78 

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

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

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

82 

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

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

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

86# upstream of this package in the future. 

87 

88 

89class _SerializedYX[T](TypedDict): 

90 y: T 

91 x: T 

92 

93 

94class _SerializedXY[T](TypedDict): 

95 x: T 

96 y: T 

97 

98 

99class YX[T](NamedTuple): 

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

101 

102 Notes 

103 ----- 

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

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

106 arithmetic operations behave as they would on a 

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

108 concatenates). 

109 

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

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

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

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

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

115 schema hooks that 

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

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

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

119 

120 See Also 

121 -------- 

122 XY 

123 """ 

124 

125 y: T 

126 """The y / row object.""" 

127 

128 x: T 

129 """The x / column object.""" 

130 

131 @property 

132 def xy(self) -> XY: 

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

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

135 

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

137 """Apply a function to both objects. 

138 

139 Parameters 

140 ---------- 

141 func 

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

143 """ 

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

145 

146 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

148 from lsst.geom import Extent2I as LegacyExtent2I 

149 

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

151 

152 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

154 from lsst.geom import Point2I as LegacyPoint2I 

155 

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

157 

158 def to_legacy_float_extent(self) -> LegacyExtent2D: 

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

160 from lsst.geom import Extent2D as LegacyExtent2D 

161 

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

163 

164 def to_legacy_float_point(self) -> LegacyPoint2D: 

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

166 from lsst.geom import Point2D as LegacyPoint2D 

167 

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

169 

170 @classmethod 

171 def __get_pydantic_core_schema__( 

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

173 ) -> pcs.CoreSchema: 

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

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

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

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

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

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

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

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

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

183 # being silently transposed. 

184 from_list = pcs.no_info_after_validator_function( 

185 cls._from_list, 

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

187 ) 

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

189 return pcs.json_or_python_schema( 

190 json_schema=validation, 

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

192 serialization=pcs.plain_serializer_function_ser_schema( 

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

194 ), 

195 ) 

196 

197 @classmethod 

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

199 return cls(**data) 

200 

201 @classmethod 

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

203 return cls(*data) 

204 

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

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

207 

208 

209class XY[T](NamedTuple): 

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

211 

212 Notes 

213 ----- 

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

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

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

217 vector (e.g. adding concatenates). 

218 

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

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

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

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

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

224 schema hooks that 

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

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

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

228 

229 See Also 

230 -------- 

231 YX 

232 """ 

233 

234 x: T 

235 """The x / column object.""" 

236 

237 y: T 

238 """The y / row object.""" 

239 

240 @property 

241 def yx(self) -> YX: 

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

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

244 

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

246 """Apply a function to both objects. 

247 

248 Parameters 

249 ---------- 

250 func 

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

252 """ 

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

254 

255 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

257 from lsst.geom import Extent2I as LegacyExtent2I 

258 

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

260 

261 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

263 from lsst.geom import Point2I as LegacyPoint2I 

264 

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

266 

267 def to_legacy_float_extent(self) -> LegacyExtent2D: 

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

269 from lsst.geom import Extent2D as LegacyExtent2D 

270 

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

272 

273 def to_legacy_float_point(self) -> LegacyPoint2D: 

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

275 from lsst.geom import Point2D as LegacyPoint2D 

276 

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

278 

279 @classmethod 

280 def __get_pydantic_core_schema__( 

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

282 ) -> pcs.CoreSchema: 

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

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

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

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

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

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

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

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

291 # of being silently transposed. 

292 from_list = pcs.no_info_after_validator_function( 

293 cls._from_list, 

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

295 ) 

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

297 return pcs.json_or_python_schema( 

298 json_schema=validation, 

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

300 serialization=pcs.plain_serializer_function_ser_schema( 

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

302 ), 

303 ) 

304 

305 @classmethod 

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

307 return cls(**data) 

308 

309 @classmethod 

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

311 return cls(*data) 

312 

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

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

315 

316 

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

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

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

320parameterized, because Pydantic handles named tuples specially). 

321""" 

322 

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

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

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

326parameterized, because Pydantic handles named tuples specially). 

327""" 

328 

329 

330class _SerializedInterval(TypedDict): 

331 start: int 

332 stop: int 

333 

334 

335@final 

336class Interval: 

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

338 

339 Parameters 

340 ---------- 

341 start 

342 Inclusive minimum point in the interval. 

343 stop 

344 One past the maximum point in the interval. 

345 

346 Notes 

347 ----- 

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

349 

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

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

352 Pydantic model itself. 

353 """ 

354 

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

356 # Coerce to be defensive against numpy int scalars. 

357 self._start = int(start) 

358 self._stop = int(stop) 

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

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

361 

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

363 

364 factory: ClassVar[IntervalSliceFactory] 

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

366 

367 For example:: 

368 

369 interval = Interval.factory[2:5] 

370 """ 

371 

372 @classmethod 

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

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

375 intervals. 

376 

377 Parameters 

378 ---------- 

379 first 

380 First point or interval to include in the hull. 

381 *args 

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

383 """ 

384 if type(first) is Interval: 

385 rmin = first.min 

386 rmax = first.max 

387 else: 

388 rmin = rmax = first 

389 for arg in args: 

390 if type(arg) is Interval: 

391 rmin = min(rmin, arg.min) 

392 rmax = max(rmax, arg.max) 

393 else: 

394 rmin = min(rmin, arg) 

395 rmax = max(rmax, arg) 

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

397 

398 @classmethod 

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

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

401 

402 Parameters 

403 ---------- 

404 size 

405 Number of points in the interval. 

406 start 

407 Inclusive minimum point in the interval. 

408 """ 

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

410 

411 @property 

412 def min(self) -> int: 

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

414 return self.start 

415 

416 @property 

417 def max(self) -> int: 

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

419 return self.stop - 1 

420 

421 @property 

422 def start(self) -> int: 

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

424 return self._start 

425 

426 @property 

427 def stop(self) -> int: 

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

429 return self._stop 

430 

431 @property 

432 def size(self) -> int: 

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

434 return self.stop - self.start 

435 

436 @property 

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

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

439 (`__builtins__.range`). 

440 """ 

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

442 

443 @property 

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

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

446 

447 Array values are integers. 

448 """ 

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

450 

451 @property 

452 def absolute(self) -> IntervalSliceFactory: 

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

454 syntax and absolute coordinates. 

455 

456 Notes 

457 ----- 

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

459 """ 

460 return IntervalSliceFactory(self, is_local=False) 

461 

462 @property 

463 def local(self) -> IntervalSliceFactory: 

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

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

466 

467 Notes 

468 ----- 

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

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

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

472 """ 

473 return IntervalSliceFactory(self, is_local=True) 

474 

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

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

477 

478 Parameters 

479 ---------- 

480 n 

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

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

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

484 step 

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

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

487 

488 Returns 

489 ------- 

490 numpy.ndarray 

491 Array of `float` values. 

492 

493 See Also 

494 -------- 

495 numpy.linspace 

496 """ 

497 if n is None: 

498 if step is None: 

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

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

501 elif step is not None: 

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

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

504 

505 @property 

506 def center(self) -> float: 

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

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

509 

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

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

512 either side. 

513 

514 Parameters 

515 ---------- 

516 padding 

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

518 """ 

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

520 

521 def __str__(self) -> str: 

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

523 

524 def __repr__(self) -> str: 

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

526 

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

528 if type(other) is Interval: 

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

530 return False 

531 

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

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

534 

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

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

537 

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

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

540 

541 @overload 

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

543 

544 @overload 

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

546 

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

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

549 points. 

550 

551 Parameters 

552 ---------- 

553 other 

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

555 a scalar or any array-like. 

556 

557 Returns 

558 ------- 

559 `bool` | `numpy.ndarray` 

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

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

562 

563 Notes 

564 ----- 

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

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

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

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

569 appear "on the image". 

570 """ 

571 if isinstance(other, Interval): 

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

573 else: 

574 other = np.asarray(other) 

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

576 if not result.shape: 

577 return bool(result) 

578 return result 

579 

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

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

582 

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

584 raised. 

585 

586 Parameters 

587 ---------- 

588 other 

589 Interval to intersect with this one. 

590 """ 

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

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

593 if new_start < new_stop: 

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

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

596 

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

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

599 

600 Parameters 

601 ---------- 

602 padding 

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

604 """ 

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

606 

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

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

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

610 

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

612 

613 Parameters 

614 ---------- 

615 other 

616 Interval whose values correspond to the container being sliced. 

617 """ 

618 if not other.contains(self): 

619 raise IndexError( 

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

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

622 ) 

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

624 

625 @classmethod 

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

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

628 

629 Parameters 

630 ---------- 

631 legacy 

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

633 """ 

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

635 

636 def to_legacy(self) -> Any: 

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

638 from lsst.geom import IntervalI 

639 

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

641 

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

643 return ( 

644 Interval, 

645 ( 

646 self._start, 

647 self._stop, 

648 ), 

649 ) 

650 

651 @classmethod 

652 def __get_pydantic_core_schema__( 

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

654 ) -> pcs.CoreSchema: 

655 from_typed_dict = pcs.chain_schema( 

656 [ 

657 handler(_SerializedInterval), 

658 pcs.no_info_plain_validator_function(cls._validate), 

659 ] 

660 ) 

661 return pcs.json_or_python_schema( 

662 json_schema=from_typed_dict, 

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

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

665 ) 

666 

667 @classmethod 

668 def __get_pydantic_json_schema__( 

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

670 ) -> JsonSchemaValue: 

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

672 

673 @classmethod 

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

675 return cls(**data) 

676 

677 def _serialize(self) -> _SerializedInterval: 

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

679 

680 

681class IntervalSliceFactory: 

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

683 

684 Parameters 

685 ---------- 

686 parent 

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

688 to allow any bounds. 

689 is_local 

690 Whether slice bounds are interpreted relative to the start of 

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

692 

693 Notes 

694 ----- 

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

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

697 

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

699 

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

701 not allowed. 

702 

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

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

705 interval's bounds:: 

706 

707 parent = Interval.factory[3:6] 

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

709 

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

711 

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

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

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

715 

716 parent = Interval.factory[3:6] 

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

718 

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

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

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

722 """ 

723 

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

725 self._parent = parent 

726 self._is_local = is_local 

727 

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

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

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

731 if self._is_local: 

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

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

734 start += self._parent.start 

735 stop += self._parent.start 

736 else: 

737 start = s.start 

738 stop = s.stop 

739 if start is None: 

740 if self._parent is None: 

741 start = 0 

742 else: 

743 start = self._parent.start 

744 if stop is None: 

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

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

747 stop = self._parent.stop 

748 if self._parent is not None: 

749 if start < self._parent.start: 

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

751 if stop > self._parent.stop: 

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

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

754 

755 

756Interval.factory = IntervalSliceFactory() 

757 

758 

759class _SerializedBox(TypedDict): 

760 y: _SerializedInterval 

761 x: _SerializedInterval 

762 

763 

764class Box: 

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

766 

767 Parameters 

768 ---------- 

769 y 

770 Interval for the y dimension. 

771 x 

772 Interval for the x dimension. 

773 

774 Raises 

775 ------ 

776 TypeError 

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

778 

779 Notes 

780 ----- 

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

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

783 Pydantic model itself. 

784 """ 

785 

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

787 match (y, x): 

788 case (Interval(), Interval()): 

789 self._intervals = YX(y, x) 

790 case _: 

791 raise TypeError( 

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

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

794 "to construct a Box." 

795 ) 

796 

797 __slots__ = ("_intervals",) 

798 

799 factory: ClassVar[BoxSliceFactory] 

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

801 

802 For example:: 

803 

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

805 """ 

806 

807 @classmethod 

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

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

810 

811 Parameters 

812 ---------- 

813 shape 

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

815 start 

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

817 """ 

818 if start is None: 

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

820 match shape: 

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

822 pass 

823 case [y_size, x_size]: 

824 pass 

825 case _: 

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

827 match start: 

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

829 pass 

830 case [y_start, x_start]: 

831 pass 

832 case _: 

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

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

835 

836 @classmethod 

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

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

839 are contained in the new box. 

840 

841 Parameters 

842 ---------- 

843 x_min 

844 Minimum X value. 

845 x_max 

846 Maximum X value. 

847 y_min 

848 Minimum Y value. 

849 y_max 

850 Maximum Y value. 

851 

852 Notes 

853 ----- 

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

855 pixels whose centers lie within the bounds are included. 

856 """ 

857 return Box.factory[ 

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

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

860 ] 

861 

862 @classmethod 

863 def from_sky_circle( 

864 cls, 

865 sky_projection: SkyProjection, 

866 center: astropy.coordinates.SkyCoord, 

867 radius: astropy.coordinates.Angle, 

868 ) -> Box: 

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

870 sky region. 

871 

872 Parameters 

873 ---------- 

874 sky_projection 

875 The sky projection mapping pixels to sky. 

876 center 

877 The center of the circle, as a scalar 

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

879 radius 

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

881 

882 Returns 

883 ------- 

884 Box 

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

886 

887 Raises 

888 ------ 

889 ValueError 

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

891 image has no sky projection. 

892 """ 

893 if not center.isscalar: 

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

895 if not radius.isscalar: 

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

897 center = center.transform_to("icrs") 

898 

899 # Use pyast directly for the region handling. 

900 sky_region = Ast.Circle( 

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

902 1, 

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

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

905 ) 

906 

907 # If it is not already a pyast mapping 

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

909 # the AST textual serialization through a pyast Channel. 

910 sky_to_pixel: Any = sky_projection.sky_to_pixel_transform._ast_mapping 

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

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

913 # astshim serialization. 

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

915 

916 # Calculate the Box around the region. 

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

918 lbnd, ubnd = pixel_region.getregionbounds() 

919 return Box.from_float_bounds( 

920 x_min=float(lbnd[0]), 

921 x_max=float(ubnd[0]), 

922 y_min=float(lbnd[1]), 

923 y_max=float(ubnd[1]), 

924 ) 

925 

926 @property 

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

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

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

930 """ 

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

932 

933 @property 

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

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

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

937 """ 

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

939 

940 @property 

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

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

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

944 

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

946 half-exclusive ranges. 

947 """ 

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

949 

950 @property 

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

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

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

954 

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

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

957 """ 

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

959 

960 @property 

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

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

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

964 """ 

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

966 

967 @property 

968 def x(self) -> Interval: 

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

970 return self._intervals[-1] 

971 

972 @property 

973 def y(self) -> Interval: 

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

975 return self._intervals[-2] 

976 

977 @property 

978 def area(self) -> int: 

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

980 return self.x.size * self.y.size 

981 

982 @property 

983 def absolute(self) -> BoxSliceFactory: 

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

985 syntax and absolute coordinates. 

986 

987 Notes 

988 ----- 

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

990 """ 

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

992 

993 @property 

994 def local(self) -> BoxSliceFactory: 

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

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

997 

998 Notes 

999 ----- 

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

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

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

1003 """ 

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

1005 

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

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

1008 

1009 Parameters 

1010 ---------- 

1011 n 

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

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

1014 provided. 

1015 step 

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

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

1018 ``n``. 

1019 

1020 Returns 

1021 ------- 

1022 `XY` [`numpy.ndarray`] 

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

1024 

1025 See Also 

1026 -------- 

1027 numpy.meshgrid 

1028 """ 

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

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

1031 match n: 

1032 case int(): 

1033 ax = self.x.linspace(n) 

1034 ay = self.y.linspace(n) 

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

1036 ax = self.x.linspace(nx) 

1037 ay = self.y.linspace(ny) 

1038 case [nx, ny]: 

1039 ax = self.x.linspace(nx) 

1040 ay = self.y.linspace(ny) 

1041 case None: 

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

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

1044 case _: 

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

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

1047 

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

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

1050 all sides. 

1051 

1052 Parameters 

1053 ---------- 

1054 padding 

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

1056 """ 

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

1058 

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

1060 if type(other) is Box: 

1061 return self._intervals == other._intervals 

1062 return False 

1063 

1064 def __str__(self) -> str: 

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

1066 

1067 def __repr__(self) -> str: 

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

1069 

1070 @overload 

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

1072 

1073 @overload 

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

1075 

1076 @overload 

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

1078 

1079 @overload 

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

1081 

1082 @overload 

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

1084 

1085 def contains( 

1086 self, 

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

1088 /, 

1089 *, 

1090 y: Any = None, 

1091 x: Any = None, 

1092 ) -> bool | np.ndarray: 

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

1094 

1095 Parameters 

1096 ---------- 

1097 other 

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

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

1100 y 

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

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

1103 Mutually exclusive with ``other``. 

1104 x 

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

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

1107 Mutually exclusive with ``other``. 

1108 

1109 Returns 

1110 ------- 

1111 `bool` | `numpy.ndarray` 

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

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

1114 array with their broadcasted shape. 

1115 

1116 Notes 

1117 ----- 

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

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

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

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

1122 appear "on the image". 

1123 """ 

1124 match other: 

1125 case None: 

1126 if x is None or y is None: 

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

1128 case Box(): 

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

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

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

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

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

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

1135 x, y = other.x, other.y 

1136 case _: 

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

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

1139 if not result.shape: 

1140 return bool(result) 

1141 return result 

1142 

1143 @overload 

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

1145 

1146 @overload 

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

1148 

1149 @overload 

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

1151 

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

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

1154 ``other``. 

1155 

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

1157 

1158 Parameters 

1159 ---------- 

1160 other 

1161 Bounds to intersect with this one. 

1162 """ 

1163 from ._concrete_bounds import _intersect_box 

1164 

1165 return _intersect_box(self, other) 

1166 

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

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

1169 

1170 Parameters 

1171 ---------- 

1172 padding 

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

1174 """ 

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

1176 

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

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

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

1180 correspond to ``other``. 

1181 

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

1183 

1184 Parameters 

1185 ---------- 

1186 other 

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

1188 """ 

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

1190 

1191 @property 

1192 def bbox(self) -> Box: 

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

1194 

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

1196 """ 

1197 return self 

1198 

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

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

1201 

1202 Yields 

1203 ------ 

1204 corner 

1205 Each corner in turn. 

1206 """ 

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

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

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

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

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

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

1213 

1214 def to_polygon(self) -> Polygon: 

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

1216 

1217 Notes 

1218 ----- 

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

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

1221 before using them to form the polygon vertices. 

1222 """ 

1223 from ._polygon import Polygon 

1224 

1225 return Polygon.from_box(self) 

1226 

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

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

1229 

1230 Parameters 

1231 ---------- 

1232 transform 

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

1234 

1235 Notes 

1236 ----- 

1237 This transforms the polygon representation of the box (see 

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

1239 full pixels before transforming them. 

1240 """ 

1241 return self.to_polygon().transform(transform) 

1242 

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

1244 return (Box, self._intervals) 

1245 

1246 @classmethod 

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

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

1249 

1250 Parameters 

1251 ---------- 

1252 legacy 

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

1254 """ 

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

1256 

1257 def to_legacy(self) -> Any: 

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

1259 from lsst.geom import Box2I 

1260 

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

1262 

1263 @classmethod 

1264 def __get_pydantic_core_schema__( 

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

1266 ) -> pcs.CoreSchema: 

1267 from_typed_dict = pcs.chain_schema( 

1268 [ 

1269 handler(_SerializedBox), 

1270 pcs.no_info_plain_validator_function(cls._validate), 

1271 ] 

1272 ) 

1273 return pcs.json_or_python_schema( 

1274 json_schema=from_typed_dict, 

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

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

1277 ) 

1278 

1279 @classmethod 

1280 def __get_pydantic_json_schema__( 

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

1282 ) -> JsonSchemaValue: 

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

1284 

1285 @classmethod 

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

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

1288 

1289 def _serialize(self) -> _SerializedBox: 

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

1291 

1292 def serialize(self) -> Box: 

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

1294 

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

1296 Pydantic serialization hooks. It exists for compatibility with the 

1297 `Bounds` protocol. 

1298 """ 

1299 return self 

1300 

1301 def deserialize(self) -> Box: 

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

1303 

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

1305 Pydantic serialization hooks. It exists for compatibility with the 

1306 `Bounds` protocol. 

1307 """ 

1308 return self 

1309 

1310 

1311class BoxSliceFactory: 

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

1313 

1314 Parameters 

1315 ---------- 

1316 y 

1317 Slice factory used for the y axis. 

1318 x 

1319 Slice factory used for the x axis. 

1320 

1321 Notes 

1322 ----- 

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

1324 `Box` with exactly those bounds:: 

1325 

1326 assert ( 

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

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

1329 ) 

1330 

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

1332 not allowed. 

1333 

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

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

1336 box's bounds:: 

1337 

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

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

1340 

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

1342 

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

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

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

1346 

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

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

1349 """ 

1350 

1351 def __init__( 

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

1353 ) -> None: 

1354 self._y = y 

1355 self._x = x 

1356 

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

1358 match key: 

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

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

1361 case (y, x): 

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

1363 case _: 

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

1365 

1366 

1367Box.factory = BoxSliceFactory() 

1368 

1369 

1370class Bounds(Protocol): 

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

1372 defined in 2-d pixel coordinates. 

1373 

1374 Notes 

1375 ----- 

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

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

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

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

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

1381 intended to handle both of these cases as well. 

1382 """ 

1383 

1384 @property 

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

1386 

1387 @overload 

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

1389 

1390 @overload 

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

1392 

1393 @overload 

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

1395 

1396 @overload 

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

1398 

1399 def contains( 

1400 self, 

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

1402 /, 

1403 *, 

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

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

1406 ) -> bool | np.ndarray: 

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

1408 

1409 Parameters 

1410 ---------- 

1411 point 

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

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

1414 x 

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

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

1417 Mutually exclusive with ``point``. 

1418 y 

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

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

1421 Mutually exclusive with ``point``. 

1422 

1423 Returns 

1424 ------- 

1425 `bool` | `numpy.ndarray` 

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

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

1428 broadcasted shape. 

1429 """ 

1430 ... 

1431 

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

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

1434 

1435 Parameters 

1436 ---------- 

1437 other 

1438 Bounds to intersect with this one. 

1439 """ 

1440 ... 

1441 

1442 def serialize(self) -> BoundsSerializationModel: 

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

1444 

1445 Notes 

1446 ----- 

1447 The returned object must support direct nesting with Pydantic models 

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

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

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

1451 natively serializable. 

1452 """ 

1453 ... 

1454 

1455 

1456class BoundsError(ValueError): 

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

1458 

1459 

1460class NoOverlapError(ValueError): 

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

1462 

1463 

1464class NotContainedError(RuntimeError): 

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

1466 another. 

1467 """ 

1468 

1469 

1470if TYPE_CHECKING: 

1471 

1472 def _test_types() -> None: 

1473 interval = Interval(0, 10) 

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

1475 arr = np.zeros(3) 

1476 

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

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

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

1480 

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

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

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

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

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

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

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

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

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

1490 

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

1492 bounds: Bounds = box 

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

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

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

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

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

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

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