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

339 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-18 09:00 +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) 

24 

25import math 

26from collections.abc import Callable, Iterator, Sequence 

27from typing import ( 

28 TYPE_CHECKING, 

29 Any, 

30 ClassVar, 

31 NamedTuple, 

32 Protocol, 

33 Self, 

34 TypedDict, 

35 TypeVar, 

36 cast, 

37 final, 

38 overload, 

39) 

40 

41import numpy as np 

42import pydantic 

43import pydantic_core.core_schema as pcs 

44 

45if TYPE_CHECKING: 

46 from ._concrete_bounds import SerializableBounds 

47 

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

49# autodoc-typehints plugin. 

50T = TypeVar("T") 

51 

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

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

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

55 

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

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

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

59# upstream of this package in the future. 

60 

61 

62class YX[T](NamedTuple): 

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

64 

65 Notes 

66 ----- 

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

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

69 arithmetic operations behave as they would on a 

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

71 concatenates). 

72 

73 See Also 

74 -------- 

75 XY 

76 """ 

77 

78 y: T 

79 """The y / row object.""" 

80 

81 x: T 

82 """The x / column object.""" 

83 

84 @property 

85 def xy(self) -> XY: 

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

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

88 

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

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

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

92 

93 

94class XY[T](NamedTuple): 

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

96 

97 Notes 

98 ----- 

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

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

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

102 vector (e.g. adding concatenates). 

103 

104 See Also 

105 -------- 

106 YX 

107 """ 

108 

109 x: T 

110 """The x / column object.""" 

111 

112 y: T 

113 """The y / row object.""" 

114 

115 @property 

116 def yx(self) -> YX: 

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

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

119 

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

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

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

123 

124 

125class _SerializedInterval(TypedDict): 

126 start: int 

127 stop: int 

128 

129 

130@final 

131class Interval: 

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

133 

134 Parameters 

135 ---------- 

136 start 

137 Inclusive minimum point in the interval. 

138 stop 

139 One past the maximum point in the interval. 

140 

141 Notes 

142 ----- 

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

144 

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

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

147 Pydantic model itself. 

148 """ 

149 

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

151 # Coerce to be defensive against numpy int scalars. 

152 self._start = int(start) 

153 self._stop = int(stop) 

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

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

156 

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

158 

159 factory: ClassVar[IntervalSliceFactory] 

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

161 

162 For example:: 

163 

164 interval = Interval.factory[2:5] 

165 """ 

166 

167 @classmethod 

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

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

170 intervals. 

171 """ 

172 if type(first) is Interval: 

173 rmin = first.min 

174 rmax = first.max 

175 else: 

176 rmin = rmax = first 

177 for arg in args: 

178 if type(arg) is Interval: 

179 rmin = min(rmin, arg.min) 

180 rmax = max(rmax, arg.max) 

181 else: 

182 rmin = min(rmin, arg) 

183 rmax = max(rmax, arg) 

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

185 

186 @classmethod 

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

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

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

190 

191 @property 

192 def start(self) -> int: 

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

194 return self._start 

195 

196 @property 

197 def stop(self) -> int: 

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

199 return self._stop 

200 

201 @property 

202 def min(self) -> int: 

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

204 return self.start 

205 

206 @property 

207 def max(self) -> int: 

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

209 return self.stop - 1 

210 

211 @property 

212 def size(self) -> int: 

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

214 return self.stop - self.start 

215 

216 @property 

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

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

219 (`__builtins__.range`). 

220 """ 

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

222 

223 @property 

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

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

226 

227 Array values are integers. 

228 """ 

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

230 

231 @property 

232 def absolute(self) -> IntervalSliceFactory: 

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

234 syntax and absolute coordinates. 

235 

236 Notes 

237 ----- 

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

239 """ 

240 return IntervalSliceFactory(self, is_local=False) 

241 

242 @property 

243 def local(self) -> IntervalSliceFactory: 

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

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

246 

247 Notes 

248 ----- 

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

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

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

252 """ 

253 return IntervalSliceFactory(self, is_local=True) 

254 

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

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

257 

258 Parameters 

259 ---------- 

260 n 

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

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

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

264 step 

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

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

267 

268 Returns 

269 ------- 

270 numpy.ndarray 

271 Array of `float` values. 

272 

273 See Also 

274 -------- 

275 numpy.linspace 

276 """ 

277 if n is None: 

278 if step is None: 

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

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

281 elif step is not None: 

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

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

284 

285 @property 

286 def center(self) -> float: 

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

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

289 

290 def __str__(self) -> str: 

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

292 

293 def __repr__(self) -> str: 

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

295 

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

297 if type(other) is Interval: 

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

299 return False 

300 

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

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

303 

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

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

306 

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

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

309 

310 @overload 

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

312 

313 @overload 

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

315 

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

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

318 points. 

319 

320 Parameters 

321 ---------- 

322 other 

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

324 

325 Returns 

326 ------- 

327 `bool` | `numpy.ndarray` 

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

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

330 

331 Notes 

332 ----- 

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

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

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

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

337 appear "on the image". 

338 """ 

339 if isinstance(other, Interval): 

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

341 else: 

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

343 if not result.shape: 

344 return bool(result) 

345 return result 

346 

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

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

349 

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

351 """ 

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

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

354 if new_start < new_stop: 

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

356 return None 

357 

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

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

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

361 

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

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

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

365 

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

367 """ 

368 if not other.contains(self): 

369 raise IndexError( 

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

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

372 ) 

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

374 

375 @classmethod 

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

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

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

379 

380 def to_legacy(self) -> Any: 

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

382 from lsst.geom import IntervalI 

383 

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

385 

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

387 return ( 

388 Interval, 

389 ( 

390 self._start, 

391 self._stop, 

392 ), 

393 ) 

394 

395 @classmethod 

396 def __get_pydantic_core_schema__( 

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

398 ) -> pcs.CoreSchema: 

399 from_typed_dict = pcs.chain_schema( 

400 [ 

401 handler(_SerializedInterval), 

402 pcs.no_info_plain_validator_function(cls._validate), 

403 ] 

404 ) 

405 return pcs.json_or_python_schema( 

406 json_schema=from_typed_dict, 

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

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

409 ) 

410 

411 @classmethod 

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

413 return cls(**data) 

414 

415 def _serialize(self) -> _SerializedInterval: 

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

417 

418 

419class IntervalSliceFactory: 

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

421 

422 Notes 

423 ----- 

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

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

426 

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

428 

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

430 not allowed. 

431 

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

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

434 interval's bounds:: 

435 

436 parent = Interval.factory[3:6] 

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

438 

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

440 

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

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

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

444 

445 parent = Interval.factory[3:6] 

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

447 

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

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

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

451 """ 

452 

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

454 self._parent = parent 

455 self._is_local = is_local 

456 

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

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

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

460 if self._is_local: 

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

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

463 start += self._parent.start 

464 stop += self._parent.start 

465 else: 

466 start = s.start 

467 stop = s.stop 

468 if start is None: 

469 if self._parent is None: 

470 start = 0 

471 else: 

472 start = self._parent.start 

473 if stop is None: 

474 if self._parent is None: 

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

476 stop = self._parent.stop 

477 if self._parent is not None: 

478 if start < self._parent.start: 

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

480 if stop > self._parent.stop: 

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

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

483 

484 

485Interval.factory = IntervalSliceFactory() 

486 

487 

488class _SerializedBox(TypedDict): 

489 y: _SerializedInterval 

490 x: _SerializedInterval 

491 

492 

493class Box: 

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

495 

496 Parameters 

497 ---------- 

498 y 

499 Interval for the y dimension. 

500 x 

501 Interval for the x dimension. 

502 

503 Notes 

504 ----- 

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

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

507 Pydantic model itself. 

508 """ 

509 

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

511 self._intervals = YX(y, x) 

512 

513 __slots__ = ("_intervals",) 

514 

515 factory: ClassVar[BoxSliceFactory] 

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

517 

518 For example:: 

519 

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

521 """ 

522 

523 @classmethod 

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

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

526 

527 Parameters 

528 ---------- 

529 shape 

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

531 start 

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

533 """ 

534 if start is None: 

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

536 match shape: 

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

538 pass 

539 case [y_size, x_size]: 

540 pass 

541 case _: 

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

543 match start: 

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

545 pass 

546 case [y_start, x_start]: 

547 pass 

548 case _: 

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

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

551 

552 @property 

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

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

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

556 """ 

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

558 

559 @property 

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

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

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

563 """ 

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

565 

566 @property 

567 def x(self) -> Interval: 

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

569 return self._intervals[-1] 

570 

571 @property 

572 def y(self) -> Interval: 

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

574 return self._intervals[-2] 

575 

576 @property 

577 def absolute(self) -> BoxSliceFactory: 

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

579 syntax and absolute coordinates. 

580 

581 Notes 

582 ----- 

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

584 """ 

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

586 

587 @property 

588 def local(self) -> BoxSliceFactory: 

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

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

591 

592 Notes 

593 ----- 

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

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

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

597 """ 

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

599 

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

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

602 

603 Parameters 

604 ---------- 

605 n 

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

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

608 provided. 

609 step 

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

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

612 ``n``. 

613 

614 Returns 

615 ------- 

616 `XY` [`numpy.ndarray`] 

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

618 

619 See Also 

620 -------- 

621 numpy.meshgrid 

622 """ 

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

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

625 match n: 

626 case int(): 

627 ax = self.x.linspace(n) 

628 ay = self.y.linspace(n) 

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

630 ax = self.x.linspace(nx) 

631 ay = self.y.linspace(ny) 

632 case [nx, ny]: 

633 ax = self.x.linspace(nx) 

634 ay = self.y.linspace(ny) 

635 case None: 

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

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

638 case _: 

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

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

641 

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

643 if type(other) is Box: 

644 return self._intervals == other._intervals 

645 return False 

646 

647 def __str__(self) -> str: 

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

649 

650 def __repr__(self) -> str: 

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

652 

653 @overload 

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

655 

656 @overload 

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

658 

659 @overload 

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

661 

662 def contains( 

663 self, 

664 other: Box | None = None, 

665 *, 

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

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

668 ) -> bool | np.ndarray: 

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

670 

671 Parameters 

672 ---------- 

673 other 

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

675 arguments. 

676 y 

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

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

679 x 

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

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

682 

683 Returns 

684 ------- 

685 `bool` | `numpy.ndarray` 

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

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

688 array with their broadcasted shape. 

689 

690 Notes 

691 ----- 

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

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

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

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

696 appear "on the image". 

697 """ 

698 if other is not None: 

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

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

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

702 elif x is None or y is None: 

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

704 else: 

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

706 if not result.shape: 

707 return bool(result) 

708 return result 

709 

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

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

712 

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

714 """ 

715 intervals = [] 

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

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

718 return None 

719 intervals.append(r) 

720 return Box(*intervals) 

721 

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

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

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

725 

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

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

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

729 correspond to ``other``. 

730 

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

732 """ 

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

734 

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

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

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

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

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

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

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

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

743 

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

745 return (Box, self._intervals) 

746 

747 @classmethod 

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

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

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

751 

752 def to_legacy(self) -> Any: 

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

754 from lsst.geom import Box2I 

755 

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

757 

758 @classmethod 

759 def __get_pydantic_core_schema__( 

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

761 ) -> pcs.CoreSchema: 

762 from_typed_dict = pcs.chain_schema( 

763 [ 

764 handler(_SerializedBox), 

765 pcs.no_info_plain_validator_function(cls._validate), 

766 ] 

767 ) 

768 return pcs.json_or_python_schema( 

769 json_schema=from_typed_dict, 

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

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

772 ) 

773 

774 @classmethod 

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

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

777 

778 def _serialize(self) -> _SerializedBox: 

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

780 

781 def serialize(self) -> Box: 

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

783 

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

785 Pydantic serialization hooks. It exists for compatibility with the 

786 `Bounds` protocol. 

787 """ 

788 return self 

789 

790 @classmethod 

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

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

793 

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

795 Pydantic serialization hooks. It exists for compatibility with the 

796 `Bounds` protocol. 

797 """ 

798 assert isinstance(serialized, Box) 

799 return serialized 

800 

801 

802class BoxSliceFactory: 

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

804 

805 Notes 

806 ----- 

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

808 `Box` with exactly those bounds:: 

809 

810 assert ( 

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

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

813 ) 

814 

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

816 not allowed. 

817 

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

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

820 box's bounds:: 

821 

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

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

824 

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

826 

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

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

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

830 

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

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

833 """ 

834 

835 def __init__( 

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

837 ): 

838 self._y = y 

839 self._x = x 

840 

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

842 match key: 

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

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

845 case (y, x): 

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

847 case _: 

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

849 

850 

851Box.factory = BoxSliceFactory() 

852 

853 

854class Bounds(Protocol): 

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

856 defined in 2-d pixel coordinates. 

857 

858 Notes 

859 ----- 

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

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

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

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

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

865 intended to handle both of these cases as well. 

866 """ 

867 

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

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

870 ... 

871 

872 @overload 

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

874 

875 @overload 

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

877 

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

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

880 

881 Parameters 

882 ---------- 

883 x 

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

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

886 y 

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

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

889 

890 Returns 

891 ------- 

892 `bool` | `numpy.ndarray` 

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

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

895 shape. 

896 """ 

897 ... 

898 

899 def serialize(self) -> SerializableBounds: 

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

901 ... 

902 

903 @classmethod 

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

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

906 from ._concrete_bounds import deserialize_bounds 

907 

908 return cast(Self, deserialize_bounds(serialized)) 

909 

910 

911class BoundsError(ValueError): 

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