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

478 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 07:45 +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 Intervals should generally be constructed by a classmethod factory (e.g. 

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

341 

342 interval = Interval.factory[a:b] 

343 

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

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

346 immediately see how the endpoints are interpreted. 

347 

348 Parameters 

349 ---------- 

350 start 

351 Inclusive minimum point in the interval. 

352 stop 

353 One past the maximum point in the interval. 

354 

355 Notes 

356 ----- 

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

358 

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

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

361 Pydantic model itself. 

362 """ 

363 

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

365 # Coerce to be defensive against numpy int scalars. 

366 self._start = int(start) 

367 self._stop = int(stop) 

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

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

370 

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

372 

373 factory: ClassVar[IntervalSliceFactory] 

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

375 

376 For example:: 

377 

378 interval = Interval.factory[2:5] 

379 """ 

380 

381 @classmethod 

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

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

384 intervals. 

385 

386 Parameters 

387 ---------- 

388 first 

389 First point or interval to include in the hull. 

390 *args 

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

392 """ 

393 if type(first) is Interval: 

394 rmin = first.min 

395 rmax = first.max 

396 else: 

397 rmin = rmax = first 

398 for arg in args: 

399 if type(arg) is Interval: 

400 rmin = min(rmin, arg.min) 

401 rmax = max(rmax, arg.max) 

402 else: 

403 rmin = min(rmin, arg) 

404 rmax = max(rmax, arg) 

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

406 

407 @classmethod 

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

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

410 

411 Parameters 

412 ---------- 

413 size 

414 Number of points in the interval. 

415 start 

416 Inclusive minimum point in the interval. 

417 """ 

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

419 

420 @property 

421 def min(self) -> int: 

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

423 return self.start 

424 

425 @property 

426 def max(self) -> int: 

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

428 return self.stop - 1 

429 

430 @property 

431 def start(self) -> int: 

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

433 return self._start 

434 

435 @property 

436 def stop(self) -> int: 

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

438 return self._stop 

439 

440 @property 

441 def size(self) -> int: 

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

443 return self.stop - self.start 

444 

445 @property 

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

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

448 (`__builtins__.range`). 

449 """ 

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

451 

452 @property 

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

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

455 

456 Array values are integers. 

457 """ 

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

459 

460 @property 

461 def absolute(self) -> IntervalSliceFactory: 

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

463 syntax and absolute coordinates. 

464 

465 Notes 

466 ----- 

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

468 """ 

469 return IntervalSliceFactory(self, is_local=False) 

470 

471 @property 

472 def local(self) -> IntervalSliceFactory: 

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

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

475 

476 Notes 

477 ----- 

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

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

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

481 """ 

482 return IntervalSliceFactory(self, is_local=True) 

483 

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

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

486 

487 Parameters 

488 ---------- 

489 n 

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

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

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

493 step 

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

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

496 

497 Returns 

498 ------- 

499 numpy.ndarray 

500 Array of `float` values. 

501 

502 See Also 

503 -------- 

504 numpy.linspace 

505 """ 

506 if n is None: 

507 if step is None: 

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

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

510 elif step is not None: 

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

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

513 

514 @property 

515 def center(self) -> float: 

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

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

518 

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

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

521 either side. 

522 

523 Parameters 

524 ---------- 

525 padding 

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

527 """ 

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

529 

530 def __str__(self) -> str: 

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

532 

533 def __repr__(self) -> str: 

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

535 

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

537 if type(other) is Interval: 

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

539 return False 

540 

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

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

543 

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

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

546 

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

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

549 

550 @overload 

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

552 

553 @overload 

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

555 

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

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

558 points. 

559 

560 Parameters 

561 ---------- 

562 other 

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

564 a scalar or any array-like. 

565 

566 Returns 

567 ------- 

568 `bool` | `numpy.ndarray` 

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

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

571 

572 Notes 

573 ----- 

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

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

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

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

578 appear "on the image". 

579 """ 

580 if isinstance(other, Interval): 

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

582 else: 

583 other = np.asarray(other) 

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

585 if not result.shape: 

586 return bool(result) 

587 return result 

588 

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

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

591 

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

593 raised. 

594 

595 Parameters 

596 ---------- 

597 other 

598 Interval to intersect with this one. 

599 """ 

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

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

602 if new_start < new_stop: 

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

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

605 

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

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

608 

609 Parameters 

610 ---------- 

611 padding 

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

613 """ 

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

615 

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

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

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

619 

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

621 

622 Parameters 

623 ---------- 

624 other 

625 Interval whose values correspond to the container being sliced. 

626 """ 

627 if not other.contains(self): 

628 raise IndexError( 

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

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

631 ) 

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

633 

634 @classmethod 

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

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

637 

638 Parameters 

639 ---------- 

640 legacy 

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

642 """ 

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

644 

645 def to_legacy(self) -> Any: 

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

647 from lsst.geom import IntervalI 

648 

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

650 

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

652 return ( 

653 Interval, 

654 ( 

655 self._start, 

656 self._stop, 

657 ), 

658 ) 

659 

660 @classmethod 

661 def __get_pydantic_core_schema__( 

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

663 ) -> pcs.CoreSchema: 

664 from_typed_dict = pcs.chain_schema( 

665 [ 

666 handler(_SerializedInterval), 

667 pcs.no_info_plain_validator_function(cls._validate), 

668 ] 

669 ) 

670 return pcs.json_or_python_schema( 

671 json_schema=from_typed_dict, 

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

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

674 ) 

675 

676 @classmethod 

677 def __get_pydantic_json_schema__( 

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

679 ) -> JsonSchemaValue: 

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

681 

682 @classmethod 

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

684 return cls(**data) 

685 

686 def _serialize(self) -> _SerializedInterval: 

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

688 

689 

690class IntervalSliceFactory: 

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

692 

693 Parameters 

694 ---------- 

695 parent 

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

697 to allow any bounds. 

698 is_local 

699 Whether slice bounds are interpreted relative to the start of 

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

701 

702 Notes 

703 ----- 

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

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

706 

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

708 

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

710 not allowed. 

711 

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

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

714 interval's bounds:: 

715 

716 parent = Interval.factory[3:6] 

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

718 

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

720 

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

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

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

724 

725 parent = Interval.factory[3:6] 

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

727 

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

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

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

731 """ 

732 

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

734 self._parent = parent 

735 self._is_local = is_local 

736 

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

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

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

740 if self._is_local: 

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

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

743 start += self._parent.start 

744 stop += self._parent.start 

745 else: 

746 start = s.start 

747 stop = s.stop 

748 if start is None: 

749 if self._parent is None: 

750 start = 0 

751 else: 

752 start = self._parent.start 

753 if stop is None: 

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

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

756 stop = self._parent.stop 

757 if self._parent is not None: 

758 if start < self._parent.start: 

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

760 if stop > self._parent.stop: 

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

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

763 

764 

765Interval.factory = IntervalSliceFactory() 

766 

767 

768class _SerializedBox(TypedDict): 

769 y: _SerializedInterval 

770 x: _SerializedInterval 

771 

772 

773class Box: 

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

775 

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

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

778 slicing proxy:: 

779 

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

781 

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

783 

784 Parameters 

785 ---------- 

786 y 

787 Interval for the y dimension. 

788 x 

789 Interval for the x dimension. 

790 

791 Raises 

792 ------ 

793 TypeError 

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

795 

796 Notes 

797 ----- 

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

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

800 Pydantic model itself. 

801 """ 

802 

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

804 match (y, x): 

805 case (Interval(), Interval()): 

806 self._intervals = YX(y, x) 

807 case _: 

808 raise TypeError( 

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

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

811 "to construct a Box." 

812 ) 

813 

814 __slots__ = ("_intervals",) 

815 

816 factory: ClassVar[BoxSliceFactory] 

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

818 

819 For example:: 

820 

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

822 """ 

823 

824 @classmethod 

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

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

827 

828 Parameters 

829 ---------- 

830 shape 

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

832 start 

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

834 """ 

835 if start is None: 

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

837 match shape: 

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

839 pass 

840 case [y_size, x_size]: 

841 pass 

842 case _: 

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

844 match start: 

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

846 pass 

847 case [y_start, x_start]: 

848 pass 

849 case _: 

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

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

852 

853 @classmethod 

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

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

856 are contained in the new box. 

857 

858 Parameters 

859 ---------- 

860 x_min 

861 Minimum X value. 

862 x_max 

863 Maximum X value. 

864 y_min 

865 Minimum Y value. 

866 y_max 

867 Maximum Y value. 

868 

869 Notes 

870 ----- 

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

872 pixels whose centers lie within the bounds are included. 

873 """ 

874 return Box.factory[ 

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

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

877 ] 

878 

879 @classmethod 

880 def from_sky_circle( 

881 cls, 

882 sky_projection: SkyProjection, 

883 center: astropy.coordinates.SkyCoord, 

884 radius: astropy.coordinates.Angle, 

885 ) -> Box: 

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

887 sky region. 

888 

889 Parameters 

890 ---------- 

891 sky_projection 

892 The sky projection mapping pixels to sky. 

893 center 

894 The center of the circle, as a scalar 

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

896 radius 

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

898 

899 Returns 

900 ------- 

901 Box 

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

903 

904 Raises 

905 ------ 

906 ValueError 

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

908 image has no sky projection. 

909 """ 

910 if not center.isscalar: 

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

912 if not radius.isscalar: 

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

914 center = center.transform_to("icrs") 

915 

916 # Use pyast directly for the region handling. 

917 sky_region = Ast.Circle( 

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

919 1, 

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

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

922 ) 

923 

924 # If it is not already a pyast mapping 

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

926 # the AST textual serialization through a pyast Channel. 

927 sky_to_pixel: Any = sky_projection.sky_to_pixel_transform._ast_mapping 

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

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

930 # astshim serialization. 

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

932 

933 # Calculate the Box around the region. 

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

935 lbnd, ubnd = pixel_region.getregionbounds() 

936 return Box.from_float_bounds( 

937 x_min=float(lbnd[0]), 

938 x_max=float(ubnd[0]), 

939 y_min=float(lbnd[1]), 

940 y_max=float(ubnd[1]), 

941 ) 

942 

943 @property 

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

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

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

947 """ 

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

949 

950 @property 

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

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

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

954 """ 

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

956 

957 @property 

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

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

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

961 

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

963 half-exclusive ranges. 

964 """ 

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

966 

967 @property 

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

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

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

971 

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

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

974 """ 

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

976 

977 @property 

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

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

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

981 """ 

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

983 

984 @property 

985 def x(self) -> Interval: 

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

987 return self._intervals[-1] 

988 

989 @property 

990 def y(self) -> Interval: 

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

992 return self._intervals[-2] 

993 

994 @property 

995 def area(self) -> int: 

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

997 return self.x.size * self.y.size 

998 

999 @property 

1000 def absolute(self) -> BoxSliceFactory: 

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

1002 syntax and absolute coordinates. 

1003 

1004 Notes 

1005 ----- 

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

1007 """ 

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

1009 

1010 @property 

1011 def local(self) -> BoxSliceFactory: 

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

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

1014 

1015 Notes 

1016 ----- 

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

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

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

1020 """ 

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

1022 

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

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

1025 

1026 Parameters 

1027 ---------- 

1028 n 

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

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

1031 provided. 

1032 step 

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

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

1035 ``n``. 

1036 

1037 Returns 

1038 ------- 

1039 `XY` [`numpy.ndarray`] 

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

1041 

1042 See Also 

1043 -------- 

1044 numpy.meshgrid 

1045 """ 

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

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

1048 match n: 

1049 case int(): 

1050 ax = self.x.linspace(n) 

1051 ay = self.y.linspace(n) 

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

1053 ax = self.x.linspace(nx) 

1054 ay = self.y.linspace(ny) 

1055 case [nx, ny]: 

1056 ax = self.x.linspace(nx) 

1057 ay = self.y.linspace(ny) 

1058 case None: 

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

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

1061 case _: 

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

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

1064 

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

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

1067 all sides. 

1068 

1069 Parameters 

1070 ---------- 

1071 padding 

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

1073 """ 

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

1075 

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

1077 if type(other) is Box: 

1078 return self._intervals == other._intervals 

1079 return False 

1080 

1081 def __str__(self) -> str: 

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

1083 

1084 def __repr__(self) -> str: 

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

1086 

1087 @overload 

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

1089 

1090 @overload 

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

1092 

1093 @overload 

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

1095 

1096 @overload 

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

1098 

1099 @overload 

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

1101 

1102 def contains( 

1103 self, 

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

1105 /, 

1106 *, 

1107 y: Any = None, 

1108 x: Any = None, 

1109 ) -> bool | np.ndarray: 

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

1111 

1112 Parameters 

1113 ---------- 

1114 other 

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

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

1117 y 

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

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

1120 Mutually exclusive with ``other``. 

1121 x 

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

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

1124 Mutually exclusive with ``other``. 

1125 

1126 Returns 

1127 ------- 

1128 `bool` | `numpy.ndarray` 

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

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

1131 array with their broadcasted shape. 

1132 

1133 Notes 

1134 ----- 

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

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

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

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

1139 appear "on the image". 

1140 """ 

1141 match other: 

1142 case None: 

1143 if x is None or y is None: 

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

1145 case Box(): 

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

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

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

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

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

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

1152 x, y = other.x, other.y 

1153 case _: 

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

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

1156 if not result.shape: 

1157 return bool(result) 

1158 return result 

1159 

1160 @overload 

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

1162 

1163 @overload 

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

1165 

1166 @overload 

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

1168 

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

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

1171 ``other``. 

1172 

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

1174 

1175 Parameters 

1176 ---------- 

1177 other 

1178 Bounds to intersect with this one. 

1179 """ 

1180 from ._concrete_bounds import _intersect_box 

1181 

1182 return _intersect_box(self, other) 

1183 

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

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

1186 

1187 Parameters 

1188 ---------- 

1189 padding 

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

1191 """ 

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

1193 

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

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

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

1197 correspond to ``other``. 

1198 

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

1200 

1201 Parameters 

1202 ---------- 

1203 other 

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

1205 """ 

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

1207 

1208 @property 

1209 def bbox(self) -> Box: 

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

1211 

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

1213 """ 

1214 return self 

1215 

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

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

1218 

1219 Yields 

1220 ------ 

1221 corner 

1222 Each corner in turn. 

1223 """ 

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

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

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

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

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

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

1230 

1231 def to_polygon(self) -> Polygon: 

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

1233 

1234 Notes 

1235 ----- 

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

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

1238 before using them to form the polygon vertices. 

1239 """ 

1240 from ._polygon import Polygon 

1241 

1242 return Polygon.from_box(self) 

1243 

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

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

1246 

1247 Parameters 

1248 ---------- 

1249 transform 

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

1251 

1252 Notes 

1253 ----- 

1254 This transforms the polygon representation of the box (see 

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

1256 full pixels before transforming them. 

1257 """ 

1258 return self.to_polygon().transform(transform) 

1259 

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

1261 return (Box, self._intervals) 

1262 

1263 @classmethod 

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

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

1266 

1267 Parameters 

1268 ---------- 

1269 legacy 

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

1271 """ 

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

1273 

1274 def to_legacy(self) -> Any: 

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

1276 from lsst.geom import Box2I 

1277 

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

1279 

1280 @classmethod 

1281 def __get_pydantic_core_schema__( 

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

1283 ) -> pcs.CoreSchema: 

1284 from_typed_dict = pcs.chain_schema( 

1285 [ 

1286 handler(_SerializedBox), 

1287 pcs.no_info_plain_validator_function(cls._validate), 

1288 ] 

1289 ) 

1290 return pcs.json_or_python_schema( 

1291 json_schema=from_typed_dict, 

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

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

1294 ) 

1295 

1296 @classmethod 

1297 def __get_pydantic_json_schema__( 

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

1299 ) -> JsonSchemaValue: 

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

1301 

1302 @classmethod 

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

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

1305 

1306 def _serialize(self) -> _SerializedBox: 

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

1308 

1309 def serialize(self) -> Box: 

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

1311 

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

1313 Pydantic serialization hooks. It exists for compatibility with the 

1314 `Bounds` protocol. 

1315 """ 

1316 return self 

1317 

1318 def deserialize(self) -> Box: 

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

1320 

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

1322 Pydantic serialization hooks. It exists for compatibility with the 

1323 `Bounds` protocol. 

1324 """ 

1325 return self 

1326 

1327 

1328class BoxSliceFactory: 

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

1330 

1331 Parameters 

1332 ---------- 

1333 y 

1334 Slice factory used for the y axis. 

1335 x 

1336 Slice factory used for the x axis. 

1337 

1338 Notes 

1339 ----- 

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

1341 `Box` with exactly those bounds:: 

1342 

1343 assert ( 

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

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

1346 ) 

1347 

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

1349 not allowed. 

1350 

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

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

1353 box's bounds:: 

1354 

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

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

1357 

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

1359 

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

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

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

1363 

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

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

1366 """ 

1367 

1368 def __init__( 

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

1370 ) -> None: 

1371 self._y = y 

1372 self._x = x 

1373 

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

1375 match key: 

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

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

1378 case (y, x): 

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

1380 case _: 

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

1382 

1383 

1384Box.factory = BoxSliceFactory() 

1385 

1386 

1387class Bounds(Protocol): 

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

1389 defined in 2-d pixel coordinates. 

1390 

1391 Notes 

1392 ----- 

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

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

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

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

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

1398 intended to handle both of these cases as well. 

1399 """ 

1400 

1401 @property 

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

1403 

1404 @overload 

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

1406 

1407 @overload 

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

1409 

1410 @overload 

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

1412 

1413 @overload 

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

1415 

1416 def contains( 

1417 self, 

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

1419 /, 

1420 *, 

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

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

1423 ) -> bool | np.ndarray: 

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

1425 

1426 Parameters 

1427 ---------- 

1428 point 

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

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

1431 x 

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

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

1434 Mutually exclusive with ``point``. 

1435 y 

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

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

1438 Mutually exclusive with ``point``. 

1439 

1440 Returns 

1441 ------- 

1442 `bool` | `numpy.ndarray` 

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

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

1445 broadcasted shape. 

1446 """ 

1447 ... 

1448 

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

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

1451 

1452 Parameters 

1453 ---------- 

1454 other 

1455 Bounds to intersect with this one. 

1456 """ 

1457 ... 

1458 

1459 def serialize(self) -> BoundsSerializationModel: 

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

1461 

1462 Notes 

1463 ----- 

1464 The returned object must support direct nesting with Pydantic models 

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

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

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

1468 natively serializable. 

1469 """ 

1470 ... 

1471 

1472 

1473class BoundsError(ValueError): 

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

1475 

1476 

1477class NoOverlapError(ValueError): 

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

1479 

1480 

1481class NotContainedError(RuntimeError): 

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

1483 another. 

1484 """ 

1485 

1486 

1487if TYPE_CHECKING: 

1488 

1489 def _test_types() -> None: 

1490 interval = Interval(0, 10) 

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

1492 arr = np.zeros(3) 

1493 

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

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

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

1497 

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

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

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

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

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

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

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

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

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

1507 

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

1509 bounds: Bounds = box 

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

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

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

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

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

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

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