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

327 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-28 01:29 -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 | float) -> 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 if not other.contains(self): 

328 raise IndexError( 

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

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

331 ) 

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

333 

334 @classmethod 

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

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

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

338 

339 def to_legacy(self) -> Any: 

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

341 from lsst.geom import IntervalI 

342 

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

344 

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

346 return ( 

347 Interval, 

348 ( 

349 self._start, 

350 self._stop, 

351 ), 

352 ) 

353 

354 @classmethod 

355 def __get_pydantic_core_schema__( 

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

357 ) -> pcs.CoreSchema: 

358 from_typed_dict = pcs.chain_schema( 

359 [ 

360 handler(_SerializedInterval), 

361 pcs.no_info_plain_validator_function(cls._validate), 

362 ] 

363 ) 

364 return pcs.json_or_python_schema( 

365 json_schema=from_typed_dict, 

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

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

368 ) 

369 

370 @classmethod 

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

372 return cls(**data) 

373 

374 def _serialize(self) -> _SerializedInterval: 

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

376 

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

378 """Return a subset of the interval. 

379 

380 If the start is omitted from the slice, the current start is assumed. 

381 """ 

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

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

384 start = s.start if s.start is not None else self.start 

385 stop = s.stop if s.stop is not None else self.stop 

386 try: 

387 new = Interval(start=start, stop=stop) 

388 except ValueError as e: 

389 raise IndexError(f"Bounds failure when creating slice with start={start} stop={stop}") from e 

390 if not self.contains(new): 

391 raise IndexError( 

392 f"The requested slice ({start}, {stop}) " 

393 f"is not contained within the enclosing interval ({self})" 

394 ) 

395 return new 

396 

397 

398class IntervalSliceFactory: 

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

400 

401 Notes 

402 ----- 

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

404 

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

406 

407 """ 

408 

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

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

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

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

413 

414 

415Interval.factory = IntervalSliceFactory() 

416 

417 

418class _SerializedBox(TypedDict): 

419 y: _SerializedInterval 

420 x: _SerializedInterval 

421 

422 

423class Box: 

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

425 

426 Parameters 

427 ---------- 

428 y 

429 Interval for the y dimension. 

430 x 

431 Interval for the x dimension. 

432 

433 Notes 

434 ----- 

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

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

437 Pydantic model itself. 

438 """ 

439 

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

441 self._intervals = YX(y, x) 

442 

443 __slots__ = ("_intervals",) 

444 

445 factory: ClassVar[BoxSliceFactory] 

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

447 

448 For example:: 

449 

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

451 """ 

452 

453 @classmethod 

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

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

456 

457 Parameters 

458 ---------- 

459 shape 

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

461 start 

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

463 """ 

464 if start is None: 

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

466 match shape: 

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

468 pass 

469 case [y_size, x_size]: 

470 pass 

471 case _: 

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

473 match start: 

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

475 pass 

476 case [y_start, x_start]: 

477 pass 

478 case _: 

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

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

481 

482 @property 

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

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

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

486 """ 

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

488 

489 @property 

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

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

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

493 """ 

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

495 

496 @property 

497 def x(self) -> Interval: 

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

499 return self._intervals[-1] 

500 

501 @property 

502 def y(self) -> Interval: 

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

504 return self._intervals[-2] 

505 

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

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

508 

509 Parameters 

510 ---------- 

511 n 

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

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

514 provided. 

515 step 

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

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

518 ``n``. 

519 

520 Returns 

521 ------- 

522 `XY` [`numpy.ndarray`] 

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

524 

525 See Also 

526 -------- 

527 numpy.meshgrid 

528 """ 

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

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

531 match n: 

532 case int(): 

533 ax = self.x.linspace(n) 

534 ay = self.y.linspace(n) 

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

536 ax = self.x.linspace(nx) 

537 ay = self.y.linspace(ny) 

538 case [nx, ny]: 

539 ax = self.x.linspace(nx) 

540 ay = self.y.linspace(ny) 

541 case None: 

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

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

544 case _: 

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

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

547 

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

549 if type(other) is Box: 

550 return self._intervals == other._intervals 

551 return False 

552 

553 def __str__(self) -> str: 

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

555 

556 def __repr__(self) -> str: 

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

558 

559 @overload 

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

561 

562 @overload 

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

564 

565 @overload 

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

567 

568 def contains( 

569 self, 

570 other: Box | None = None, 

571 *, 

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

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

574 ) -> bool | np.ndarray: 

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

576 

577 Parameters 

578 ---------- 

579 other 

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

581 arguments. 

582 y 

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

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

585 x 

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

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

588 

589 Returns 

590 ------- 

591 `bool` | `numpy.ndarray` 

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

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

594 array with their broadcasted shape. 

595 

596 Notes 

597 ----- 

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

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

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

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

602 appear "on the image". 

603 """ 

604 if other is not None: 

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

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

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

608 elif x is None or y is None: 

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

610 else: 

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

612 if not result.shape: 

613 return bool(result) 

614 return result 

615 

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

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

618 

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

620 """ 

621 intervals = [] 

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

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

624 return None 

625 intervals.append(r) 

626 return Box(*intervals) 

627 

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

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

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

631 

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

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

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

635 correspond to ``other``. 

636 

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

638 """ 

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

640 

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

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

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

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

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

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

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

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

649 

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

651 return (Box, self._intervals) 

652 

653 @classmethod 

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

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

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

657 

658 def to_legacy(self) -> Any: 

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

660 from lsst.geom import Box2I 

661 

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

663 

664 @classmethod 

665 def __get_pydantic_core_schema__( 

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

667 ) -> pcs.CoreSchema: 

668 from_typed_dict = pcs.chain_schema( 

669 [ 

670 handler(_SerializedBox), 

671 pcs.no_info_plain_validator_function(cls._validate), 

672 ] 

673 ) 

674 return pcs.json_or_python_schema( 

675 json_schema=from_typed_dict, 

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

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

678 ) 

679 

680 @classmethod 

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

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

683 

684 def _serialize(self) -> _SerializedBox: 

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

686 

687 def serialize(self) -> Box: 

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

689 

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

691 Pydantic serialization hooks. It exists for compatibility with the 

692 `Bounds` protocol. 

693 """ 

694 return self 

695 

696 @classmethod 

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

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

699 

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

701 Pydantic serialization hooks. It exists for compatibility with the 

702 `Bounds` protocol. 

703 """ 

704 assert isinstance(serialized, Box) 

705 return serialized 

706 

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

708 """Given a slice, return a new box.""" 

709 # If the slice is missing the start, use the same start as the 

710 # current object. 

711 try: 

712 match key: 

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

714 return Box(self.y[y], self.x[x]) 

715 case (y, x): 

716 return Box(self.y[y], self.x[x]) 

717 case _: 

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

719 except IndexError as e: 

720 e.add_note(f"Error applying slice {key} to {self}") 

721 raise 

722 

723 

724class BoxSliceFactory: 

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

726 

727 Notes 

728 ----- 

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

730 

731 assert ( 

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

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

734 ) 

735 """ 

736 

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

738 match key: 

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

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

741 case (y, x): 

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

743 case _: 

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

745 

746 

747Box.factory = BoxSliceFactory() 

748 

749 

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

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

752type SerializableBounds = Box 

753 

754 

755class Bounds(Protocol): 

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

757 defined in 2-d pixel coordinates. 

758 

759 Notes 

760 ----- 

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

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

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

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

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

766 intended to handle both of these cases as well. 

767 """ 

768 

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

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

771 ... 

772 

773 @overload 

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

775 

776 @overload 

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

778 

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

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

781 

782 Parameters 

783 ---------- 

784 x 

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

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

787 y 

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

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

790 

791 Returns 

792 ------- 

793 `bool` | `numpy.ndarray` 

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

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

796 shape. 

797 """ 

798 ... 

799 

800 def serialize(self) -> SerializableBounds: 

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

802 ... 

803 

804 @classmethod 

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

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

807 match serialized: 

808 case Box(): 

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

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