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

341 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-27 01:47 -0700

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

20 "Interval", 

21 "IntervalSliceFactory", 

22 "SerializableBounds", 

23) 

24 

25import math 

26from collections.abc import Callable, Iterator, Sequence 

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

28 

29import numpy as np 

30import pydantic 

31import pydantic_core.core_schema as pcs 

32 

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

34# autodoc-typehints plugin. 

35T = TypeVar("T") 

36 

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

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

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

40 

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

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

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

44# upstream of this package in the future. 

45 

46 

47class YX[T](NamedTuple): 

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

49 

50 Notes 

51 ----- 

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

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

54 arithmetic operations behave as they would on a 

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

56 concatenates). 

57 

58 See Also 

59 -------- 

60 XY 

61 """ 

62 

63 y: T 

64 """The y / row object.""" 

65 

66 x: T 

67 """The x / column object.""" 

68 

69 @property 

70 def xy(self) -> XY: 

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

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

73 

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

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

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

77 

78 

79class XY[T](NamedTuple): 

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

81 

82 Notes 

83 ----- 

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

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

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

87 vector (e.g. adding concatenates). 

88 

89 See Also 

90 -------- 

91 YX 

92 """ 

93 

94 x: T 

95 """The x / column object.""" 

96 

97 y: T 

98 """The y / row object.""" 

99 

100 @property 

101 def yx(self) -> YX: 

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

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

104 

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

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

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

108 

109 

110class _SerializedInterval(TypedDict): 

111 start: int 

112 stop: int 

113 

114 

115@final 

116class Interval: 

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

118 

119 Parameters 

120 ---------- 

121 start 

122 Inclusive minimum point in the interval. 

123 stop 

124 One past the maximum point in the interval. 

125 

126 Notes 

127 ----- 

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

129 

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

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

132 Pydantic model itself. 

133 """ 

134 

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

136 # Coerce to be defensive against numpy int scalars. 

137 self._start = int(start) 

138 self._stop = int(stop) 

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

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

141 

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

143 

144 factory: ClassVar[IntervalSliceFactory] 

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

146 

147 For example:: 

148 

149 interval = Interval.factory[2:5] 

150 """ 

151 

152 @classmethod 

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

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

155 intervals. 

156 """ 

157 if type(first) is Interval: 

158 rmin = first.min 

159 rmax = first.max 

160 else: 

161 rmin = rmax = first 

162 for arg in args: 

163 if type(arg) is Interval: 

164 rmin = min(rmin, arg.min) 

165 rmax = max(rmax, arg.max) 

166 else: 

167 rmin = min(rmin, arg) 

168 rmax = max(rmax, arg) 

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

170 

171 @classmethod 

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

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

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

175 

176 @property 

177 def start(self) -> int: 

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

179 return self._start 

180 

181 @property 

182 def stop(self) -> int: 

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

184 return self._stop 

185 

186 @property 

187 def min(self) -> int: 

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

189 return self.start 

190 

191 @property 

192 def max(self) -> int: 

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

194 return self.stop - 1 

195 

196 @property 

197 def size(self) -> int: 

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

199 return self.stop - self.start 

200 

201 @property 

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

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

204 (`__builtins__.range`). 

205 """ 

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

207 

208 @property 

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

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

211 

212 Array values are integers. 

213 """ 

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

215 

216 @property 

217 def absolute(self) -> IntervalSliceFactory: 

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

219 syntax and absolute coordinates. 

220 

221 Notes 

222 ----- 

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

224 """ 

225 return IntervalSliceFactory(self, is_local=False) 

226 

227 @property 

228 def local(self) -> IntervalSliceFactory: 

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

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

231 

232 Notes 

233 ----- 

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

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

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

237 """ 

238 return IntervalSliceFactory(self, is_local=True) 

239 

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

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

242 

243 Parameters 

244 ---------- 

245 n 

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

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

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

249 step 

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

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

252 

253 Returns 

254 ------- 

255 numpy.ndarray 

256 Array of `float` values. 

257 

258 See Also 

259 -------- 

260 numpy.linspace 

261 """ 

262 if n is None: 

263 if step is None: 

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

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

266 elif step is not None: 

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

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

269 

270 @property 

271 def center(self) -> float: 

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

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

274 

275 def __str__(self) -> str: 

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

277 

278 def __repr__(self) -> str: 

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

280 

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

282 if type(other) is Interval: 

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

284 return False 

285 

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

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

288 

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

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

291 

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

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

294 

295 @overload 

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

297 

298 @overload 

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

300 

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

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

303 points. 

304 

305 Parameters 

306 ---------- 

307 other 

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

309 

310 Returns 

311 ------- 

312 `bool` | `numpy.ndarray` 

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

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

315 

316 Notes 

317 ----- 

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

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

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

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

322 appear "on the image". 

323 """ 

324 if isinstance(other, Interval): 

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

326 else: 

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

328 if not result.shape: 

329 return bool(result) 

330 return result 

331 

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

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

334 

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

336 """ 

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

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

339 if new_start < new_stop: 

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

341 return None 

342 

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

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

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

346 

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

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

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

350 

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

352 """ 

353 if not other.contains(self): 

354 raise IndexError( 

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

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

357 ) 

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

359 

360 @classmethod 

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

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

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

364 

365 def to_legacy(self) -> Any: 

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

367 from lsst.geom import IntervalI 

368 

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

370 

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

372 return ( 

373 Interval, 

374 ( 

375 self._start, 

376 self._stop, 

377 ), 

378 ) 

379 

380 @classmethod 

381 def __get_pydantic_core_schema__( 

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

383 ) -> pcs.CoreSchema: 

384 from_typed_dict = pcs.chain_schema( 

385 [ 

386 handler(_SerializedInterval), 

387 pcs.no_info_plain_validator_function(cls._validate), 

388 ] 

389 ) 

390 return pcs.json_or_python_schema( 

391 json_schema=from_typed_dict, 

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

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

394 ) 

395 

396 @classmethod 

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

398 return cls(**data) 

399 

400 def _serialize(self) -> _SerializedInterval: 

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

402 

403 

404class IntervalSliceFactory: 

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

406 

407 Notes 

408 ----- 

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

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

411 

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

413 

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

415 not allowed. 

416 

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

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

419 interval's bounds:: 

420 

421 parent = Interval.factory[3:6] 

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

423 

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

425 

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

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

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

429 

430 parent = Interval.factory[3:6] 

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

432 

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

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

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

436 """ 

437 

438 def __init__(self, parent: Interval | None = None, is_local: bool = False): 

439 self._parent = parent 

440 self._is_local = is_local 

441 

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

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

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

445 if self._is_local: 

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

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

448 start += self._parent.start 

449 stop += self._parent.start 

450 else: 

451 start = s.start 

452 stop = s.stop 

453 if start is None: 

454 if self._parent is None: 

455 start = 0 

456 else: 

457 start = self._parent.start 

458 if stop is None: 

459 if self._parent is None: 

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

461 stop = self._parent.stop 

462 if self._parent is not None: 

463 if start < self._parent.start: 

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

465 if stop > self._parent.stop: 

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

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

468 

469 

470Interval.factory = IntervalSliceFactory() 

471 

472 

473class _SerializedBox(TypedDict): 

474 y: _SerializedInterval 

475 x: _SerializedInterval 

476 

477 

478class Box: 

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

480 

481 Parameters 

482 ---------- 

483 y 

484 Interval for the y dimension. 

485 x 

486 Interval for the x dimension. 

487 

488 Notes 

489 ----- 

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

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

492 Pydantic model itself. 

493 """ 

494 

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

496 self._intervals = YX(y, x) 

497 

498 __slots__ = ("_intervals",) 

499 

500 factory: ClassVar[BoxSliceFactory] 

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

502 

503 For example:: 

504 

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

506 """ 

507 

508 @classmethod 

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

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

511 

512 Parameters 

513 ---------- 

514 shape 

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

516 start 

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

518 """ 

519 if start is None: 

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

521 match shape: 

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

523 pass 

524 case [y_size, x_size]: 

525 pass 

526 case _: 

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

528 match start: 

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

530 pass 

531 case [y_start, x_start]: 

532 pass 

533 case _: 

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

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

536 

537 @property 

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

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

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

541 """ 

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

543 

544 @property 

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

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

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

548 """ 

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

550 

551 @property 

552 def x(self) -> Interval: 

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

554 return self._intervals[-1] 

555 

556 @property 

557 def y(self) -> Interval: 

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

559 return self._intervals[-2] 

560 

561 @property 

562 def absolute(self) -> BoxSliceFactory: 

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

564 syntax and absolute coordinates. 

565 

566 Notes 

567 ----- 

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

569 """ 

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

571 

572 @property 

573 def local(self) -> BoxSliceFactory: 

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

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

576 

577 Notes 

578 ----- 

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

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

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

582 """ 

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

584 

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

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

587 

588 Parameters 

589 ---------- 

590 n 

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

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

593 provided. 

594 step 

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

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

597 ``n``. 

598 

599 Returns 

600 ------- 

601 `XY` [`numpy.ndarray`] 

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

603 

604 See Also 

605 -------- 

606 numpy.meshgrid 

607 """ 

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

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

610 match n: 

611 case int(): 

612 ax = self.x.linspace(n) 

613 ay = self.y.linspace(n) 

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

615 ax = self.x.linspace(nx) 

616 ay = self.y.linspace(ny) 

617 case [nx, ny]: 

618 ax = self.x.linspace(nx) 

619 ay = self.y.linspace(ny) 

620 case None: 

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

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

623 case _: 

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

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

626 

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

628 if type(other) is Box: 

629 return self._intervals == other._intervals 

630 return False 

631 

632 def __str__(self) -> str: 

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

634 

635 def __repr__(self) -> str: 

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

637 

638 @overload 

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

640 

641 @overload 

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

643 

644 @overload 

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

646 

647 def contains( 

648 self, 

649 other: Box | None = None, 

650 *, 

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

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

653 ) -> bool | np.ndarray: 

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

655 

656 Parameters 

657 ---------- 

658 other 

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

660 arguments. 

661 y 

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

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

664 x 

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

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

667 

668 Returns 

669 ------- 

670 `bool` | `numpy.ndarray` 

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

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

673 array with their broadcasted shape. 

674 

675 Notes 

676 ----- 

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

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

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

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

681 appear "on the image". 

682 """ 

683 if other is not None: 

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

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

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

687 elif x is None or y is None: 

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

689 else: 

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

691 if not result.shape: 

692 return bool(result) 

693 return result 

694 

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

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

697 

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

699 """ 

700 intervals = [] 

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

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

703 return None 

704 intervals.append(r) 

705 return Box(*intervals) 

706 

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

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

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

710 

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

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

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

714 correspond to ``other``. 

715 

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

717 """ 

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

719 

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

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

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

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

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

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

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

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

728 

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

730 return (Box, self._intervals) 

731 

732 @classmethod 

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

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

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

736 

737 def to_legacy(self) -> Any: 

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

739 from lsst.geom import Box2I 

740 

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

742 

743 @classmethod 

744 def __get_pydantic_core_schema__( 

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

746 ) -> pcs.CoreSchema: 

747 from_typed_dict = pcs.chain_schema( 

748 [ 

749 handler(_SerializedBox), 

750 pcs.no_info_plain_validator_function(cls._validate), 

751 ] 

752 ) 

753 return pcs.json_or_python_schema( 

754 json_schema=from_typed_dict, 

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

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

757 ) 

758 

759 @classmethod 

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

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

762 

763 def _serialize(self) -> _SerializedBox: 

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

765 

766 def serialize(self) -> Box: 

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

768 

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

770 Pydantic serialization hooks. It exists for compatibility with the 

771 `Bounds` protocol. 

772 """ 

773 return self 

774 

775 @classmethod 

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

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

778 

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

780 Pydantic serialization hooks. It exists for compatibility with the 

781 `Bounds` protocol. 

782 """ 

783 assert isinstance(serialized, Box) 

784 return serialized 

785 

786 

787class BoxSliceFactory: 

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

789 

790 Notes 

791 ----- 

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

793 `Box` with exactly those bounds:: 

794 

795 assert ( 

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

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

798 ) 

799 

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

801 not allowed. 

802 

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

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

805 box's bounds:: 

806 

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

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

809 

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

811 

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

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

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

815 

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

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

818 """ 

819 

820 def __init__( 

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

822 ): 

823 self._y = y 

824 self._x = x 

825 

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

827 match key: 

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

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

830 case (y, x): 

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

832 case _: 

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

834 

835 

836Box.factory = BoxSliceFactory() 

837 

838 

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

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

841type SerializableBounds = Box 

842 

843 

844class Bounds(Protocol): 

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

846 defined in 2-d pixel coordinates. 

847 

848 Notes 

849 ----- 

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

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

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

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

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

855 intended to handle both of these cases as well. 

856 """ 

857 

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

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

860 ... 

861 

862 @overload 

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

864 

865 @overload 

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

867 

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

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

870 

871 Parameters 

872 ---------- 

873 x 

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

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

876 y 

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

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

879 

880 Returns 

881 ------- 

882 `bool` | `numpy.ndarray` 

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

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

885 shape. 

886 """ 

887 ... 

888 

889 def serialize(self) -> SerializableBounds: 

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

891 ... 

892 

893 @classmethod 

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

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

896 match serialized: 

897 case Box(): 

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

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