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

297 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-27 01:56 -0800

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 "Box", 

19 "Interval", 

20 "SerializableBounds", 

21) 

22 

23import math 

24from collections.abc import Callable, Iterator, Sequence 

25from typing import Any, ClassVar, NamedTuple, Protocol, Self, TypedDict, TypeVar, final, overload 

26 

27import numpy as np 

28import pydantic 

29import pydantic_core.core_schema as pcs 

30 

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

32# autodoc-typehints plugin. 

33T = TypeVar("T") 

34 

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

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

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

38 

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

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

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

42# upstream of this package in the future. 

43 

44 

45class YX[T](NamedTuple): 

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

47 

48 Notes 

49 ----- 

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

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

52 arithmetic operations behave as they would on a 

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

54 concatenates). 

55 

56 See Also 

57 -------- 

58 XY 

59 """ 

60 

61 y: T 

62 """The y / row object.""" 

63 

64 x: T 

65 """The x / column object.""" 

66 

67 @property 

68 def xy(self) -> XY: 

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

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

71 

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

73 """Apply a function to both objects.""" 

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

75 

76 

77class XY[T](NamedTuple): 

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

79 

80 Notes 

81 ----- 

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

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

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

85 vector (e.g. adding concatenates). 

86 

87 See Also 

88 -------- 

89 YX 

90 """ 

91 

92 x: T 

93 """The x / column object.""" 

94 

95 y: T 

96 """The y / row object.""" 

97 

98 @property 

99 def yx(self) -> YX: 

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

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

102 

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

104 """Apply a function to both objects.""" 

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

106 

107 

108class _SerializedInterval(TypedDict): 

109 start: int 

110 stop: int 

111 

112 

113@final 

114class Interval: 

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

116 

117 Parameters 

118 ---------- 

119 start 

120 Inclusive minimum point in the interval. 

121 stop 

122 One past the maximum point in the interval. 

123 

124 Notes 

125 ----- 

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

127 

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

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

130 Pydantic model itself. 

131 """ 

132 

133 def __init__(self, start: int, stop: int): 

134 # Coerce to be defensive against numpy int scalars. 

135 self._start = int(start) 

136 self._stop = int(stop) 

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

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

139 

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

141 

142 factory: ClassVar[IntervalSliceFactory] 

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

144 

145 For example:: 

146 

147 interval = Interval.factory[2:5] 

148 """ 

149 

150 @classmethod 

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

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

153 intervals. 

154 """ 

155 if type(first) is Interval: 

156 rmin = first.min 

157 rmax = first.max 

158 else: 

159 rmin = rmax = first 

160 for arg in args: 

161 if type(arg) is Interval: 

162 rmin = min(rmin, arg.min) 

163 rmax = max(rmax, arg.max) 

164 else: 

165 rmin = min(rmin, arg) 

166 rmax = max(rmax, arg) 

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

168 

169 @classmethod 

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

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

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

173 

174 @property 

175 def start(self) -> int: 

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

177 return self._start 

178 

179 @property 

180 def stop(self) -> int: 

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

182 return self._stop 

183 

184 @property 

185 def min(self) -> int: 

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

187 return self.start 

188 

189 @property 

190 def max(self) -> int: 

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

192 return self.stop - 1 

193 

194 @property 

195 def size(self) -> int: 

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

197 return self.stop - self.start 

198 

199 @property 

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

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

202 (`__builtins__.range`). 

203 """ 

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

205 

206 @property 

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

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

209 

210 Array values are integers. 

211 """ 

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

213 

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

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

216 

217 Parameters 

218 ---------- 

219 n 

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

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

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

223 step 

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

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

226 

227 Returns 

228 ------- 

229 numpy.ndarray 

230 Array of `float` values. 

231 

232 See Also 

233 -------- 

234 numpy.linspace 

235 """ 

236 if n is None: 

237 if step is None: 

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

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

240 elif step is not None: 

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

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

243 

244 @property 

245 def center(self) -> float: 

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

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

248 

249 def __str__(self) -> str: 

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

251 

252 def __repr__(self) -> str: 

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

254 

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

256 if type(other) is Interval: 

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

258 return False 

259 

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

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

262 

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

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

265 

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

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

268 

269 @overload 

270 def contains(self, other: Interval | int) -> bool: ... 270 ↛ exitline 270 didn't return from function 'contains' because

271 

272 @overload 

273 def contains(self, other: np.ndarray) -> np.ndarray: ... 273 ↛ exitline 273 didn't return from function 'contains' because

274 

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

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

277 points. 

278 

279 Parameters 

280 ---------- 

281 other 

282 Another interval to compare to, or one or more position values. 

283 

284 Returns 

285 ------- 

286 `bool` | `numpy.ndarray` 

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

288 array was passed, an array with the same shape. 

289 

290 Notes 

291 ----- 

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

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

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

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

296 appear "on the image". 

297 """ 

298 if isinstance(other, Interval): 

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

300 else: 

301 result = np.logical_and(self.start - 0.5 <= other, other < self.stop + 0.5) 

302 if not result.shape: 

303 return bool(result) 

304 return result 

305 

306 def intersection(self, other: Interval) -> Interval | None: 

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

308 

309 When there is no overlap between the intervals, `None` is returned. 

310 """ 

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

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

313 if new_start < new_stop: 

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

315 return None 

316 

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

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

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

320 

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

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

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

324 

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

326 """ 

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

328 

329 @classmethod 

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

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

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

333 

334 def to_legacy(self) -> Any: 

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

336 from lsst.geom import IntervalI 

337 

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

339 

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

341 return ( 

342 Interval, 

343 ( 

344 self._start, 

345 self._stop, 

346 ), 

347 ) 

348 

349 @classmethod 

350 def __get_pydantic_core_schema__( 

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

352 ) -> pcs.CoreSchema: 

353 from_typed_dict = pcs.chain_schema( 

354 [ 

355 handler(_SerializedInterval), 

356 pcs.no_info_plain_validator_function(cls._validate), 

357 ] 

358 ) 

359 return pcs.json_or_python_schema( 

360 json_schema=from_typed_dict, 

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

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

363 ) 

364 

365 @classmethod 

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

367 return cls(**data) 

368 

369 def _serialize(self) -> _SerializedInterval: 

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

371 

372 

373class IntervalSliceFactory: 

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

375 

376 Notes 

377 ----- 

378 When indexed with a single slice, this returns an `Interval`:: 

379 

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

381 

382 """ 

383 

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

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

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

387 return Interval(start=s.start or 0, stop=s.stop) 

388 

389 

390Interval.factory = IntervalSliceFactory() 

391 

392 

393class _SerializedBox(TypedDict): 

394 y: _SerializedInterval 

395 x: _SerializedInterval 

396 

397 

398class Box: 

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

400 

401 Parameters 

402 ---------- 

403 y 

404 Interval for the y dimension. 

405 x 

406 Interval for the x dimension. 

407 

408 Notes 

409 ----- 

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

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

412 Pydantic model itself. 

413 """ 

414 

415 def __init__(self, y: Interval, x: Interval): 

416 self._intervals = YX(y, x) 

417 

418 __slots__ = ("_intervals",) 

419 

420 factory: ClassVar[BoxSliceFactory] 

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

422 

423 For example:: 

424 

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

426 """ 

427 

428 @classmethod 

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

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

431 

432 Parameters 

433 ---------- 

434 shape 

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

436 start 

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

438 """ 

439 if start is None: 

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

441 match shape: 

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

443 pass 

444 case [y_size, x_size]: 

445 pass 

446 case _: 

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

448 match start: 

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

450 pass 

451 case [y_start, x_start]: 

452 pass 

453 case _: 

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

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

456 

457 @property 

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

459 """Tuple holding the starts of the intervals ordered ``(y, x)`` 

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

461 """ 

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

463 

464 @property 

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

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

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

468 """ 

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

470 

471 @property 

472 def x(self) -> Interval: 

473 """Shortcut for the last dimension's interval (`int`).""" 

474 return self._intervals[-1] 

475 

476 @property 

477 def y(self) -> Interval: 

478 """Shortcut for the second-to-last dimension's interval (`int`).""" 

479 return self._intervals[-2] 

480 

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

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

483 

484 Parameters 

485 ---------- 

486 n 

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

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

489 provided. 

490 step 

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

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

493 ``n``. 

494 

495 Returns 

496 ------- 

497 `XY` [`numpy.ndarray`] 

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

499 

500 See Also 

501 -------- 

502 numpy.meshgrid 

503 """ 

504 match n: 

505 case int(): 

506 ax = self.x.linspace(n) 

507 ay = self.y.linspace(n) 

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

509 ax = self.x.linspace(nx) 

510 ay = self.y.linspace(ny) 

511 case [nx, ny]: 

512 ax = self.x.linspace(nx) 

513 ay = self.y.linspace(ny) 

514 case None: 

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

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

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

518 

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

520 if type(other) is Box: 

521 return self._intervals == other._intervals 

522 return False 

523 

524 def __str__(self) -> str: 

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

526 

527 def __repr__(self) -> str: 

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

529 

530 @overload 

531 def contains(self, other: Box, /) -> bool: ... 531 ↛ exitline 531 didn't return from function 'contains' because

532 

533 @overload 

534 def contains(self, *, y: int, x: int) -> bool: ... 534 ↛ exitline 534 didn't return from function 'contains' because

535 

536 @overload 

537 def contains(self, *, y: np.ndarray, x: np.ndarray) -> np.ndarray: ... 537 ↛ exitline 537 didn't return from function 'contains' because

538 

539 def contains( 

540 self, 

541 other: Box | None = None, 

542 *, 

543 y: int | np.ndarray | None = None, 

544 x: int | np.ndarray | None = None, 

545 ) -> bool | np.ndarray: 

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

547 

548 Parameters 

549 ---------- 

550 other 

551 Another box to compare to. Not compatible with the ``y`` and ``x`` 

552 arguments. 

553 y 

554 One or more integer Y coordinates to test for containment. 

555 If an array, an array of results will be returned. 

556 x 

557 One or more integer X coordinates to test for containment. 

558 If an array, an array of results will be returned. 

559 

560 Returns 

561 ------- 

562 `bool` | `numpy.ndarray` 

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

564 single `bool` value. If ``x`` and ``y`` are arrays, a boolean 

565 array with their broadcasted shape. 

566 

567 Notes 

568 ----- 

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

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

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

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

573 appear "on the image". 

574 """ 

575 if other is not None: 

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

577 raise TypeError("Too many arguments to 'Box.contain'.") 

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

579 elif x is None or y is None: 

580 raise TypeError("Not enough arguments to 'Box.contain'.") 

581 else: 

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

583 if not result.shape: 

584 return bool(result) 

585 return result 

586 

587 def intersection(self, other: Box) -> Box | None: 

588 """Return a box that is contained by both ``self`` and ``other``. 

589 

590 When there is no overlap between the boxes, `None` is returned. 

591 """ 

592 intervals = [] 

593 for a, b in zip(self._intervals, other._intervals, strict=True): 

594 if (r := a.intersection(b)) is None: 

595 return None 

596 intervals.append(r) 

597 return Box(*intervals) 

598 

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

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

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

602 

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

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

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

606 correspond to ``other``. 

607 

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

609 """ 

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

611 

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

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

614 if len(self._intervals) != 2: 

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

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

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

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

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

620 

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

622 return (Box, self._intervals) 

623 

624 @classmethod 

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

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

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

628 

629 def to_legacy(self) -> Any: 

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

631 from lsst.geom import Box2I 

632 

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

634 

635 @classmethod 

636 def __get_pydantic_core_schema__( 

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

638 ) -> pcs.CoreSchema: 

639 from_typed_dict = pcs.chain_schema( 

640 [ 

641 handler(_SerializedBox), 

642 pcs.no_info_plain_validator_function(cls._validate), 

643 ] 

644 ) 

645 return pcs.json_or_python_schema( 

646 json_schema=from_typed_dict, 

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

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

649 ) 

650 

651 @classmethod 

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

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

654 

655 def _serialize(self) -> _SerializedBox: 

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

657 

658 def serialize(self) -> Box: 

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

660 

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

662 Pydantic serialization hooks. It exists for compatibility with the 

663 `Bounds` protocol. 

664 """ 

665 return self 

666 

667 @classmethod 

668 def deserialize(cls, serialized: SerializableBounds) -> Box: 

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

670 

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

672 Pydantic serialization hooks. It exists for compatibility with the 

673 `Bounds` protocol. 

674 """ 

675 assert isinstance(serialized, Box) 

676 return serialized 

677 

678 

679class BoxSliceFactory: 

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

681 

682 Notes 

683 ----- 

684 When indexed with one or more slices, this returns a `Box`: 

685 

686 assert ( 

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

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

689 ) 

690 """ 

691 

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

693 match key: 

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

695 return Box(Interval.factory[y], Interval.factory[x]) 

696 case (y, x): 

697 return Box(Interval.factory[y], Interval.factory[x]) 

698 case _: 

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

700 

701 

702Box.factory = BoxSliceFactory() 

703 

704 

705# This is expected to become a union of concrete Bounds types that we can 

706# serialize via pydantic. Right now that's only Box. 

707type SerializableBounds = Box 

708 

709 

710class Bounds(Protocol): 

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

712 defined in 2-d pixel coordinates. 

713 

714 Notes 

715 ----- 

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

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

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

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

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

721 intended to handle both of these cases as well. 

722 """ 

723 

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

725 """Iterate over points on the boundary as ``(y, x)`` tuples.""" 

726 ... 

727 

728 @overload 

729 def contains(self, *, x: int, y: int) -> bool: ... 729 ↛ exitline 729 didn't return from function 'contains' because

730 

731 @overload 

732 def contains(self, *, x: np.ndarray, y: np.ndarray) -> np.ndarray: ... 732 ↛ exitline 732 didn't return from function 'contains' because

733 

734 def contains(self, *, x: int | np.ndarray, y: int | np.ndarray) -> bool | np.ndarray: 

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

736 

737 Parameters 

738 ---------- 

739 x 

740 One or more integer X coordinates to test for containment. 

741 If an array, an array of results will be returned. 

742 y 

743 One or more integer Y coordinates to test for containment. 

744 If an array, an array of results will be returned. 

745 

746 Returns 

747 ------- 

748 `bool` | `numpy.ndarray` 

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

750 ``x`` and ``y`` are arrays, a boolean array with their broadcasted 

751 shape. 

752 """ 

753 ... 

754 

755 def serialize(self) -> SerializableBounds: 

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

757 ... 

758 

759 @classmethod 

760 def deserialize(cls, serialized: SerializableBounds) -> Self: 

761 """Convert a serialized bounds object into its in-memory form.""" 

762 match serialized: 

763 case Box(): 

764 return serialized # type: ignore[return-value] 

765 raise RuntimeError(f"Cannot deserialize {serialized!r}.")