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

349 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-01 08:36 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "XY", 

16 "YX", 

17 "Bounds", 

18 "BoundsError", 

19 "Box", 

20 "BoxSliceFactory", 

21 "Interval", 

22 "IntervalSliceFactory", 

23 "NoOverlapError", 

24) 

25 

26import math 

27from collections.abc import Callable, Iterator, Sequence 

28from typing import ( 

29 TYPE_CHECKING, 

30 Any, 

31 ClassVar, 

32 NamedTuple, 

33 Protocol, 

34 Self, 

35 TypedDict, 

36 TypeVar, 

37 cast, 

38 final, 

39 overload, 

40) 

41 

42import numpy as np 

43import pydantic 

44import pydantic_core.core_schema as pcs 

45 

46if TYPE_CHECKING: 

47 from ._concrete_bounds import SerializableBounds 

48 

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

50# autodoc-typehints plugin. 

51T = TypeVar("T") 

52 

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

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

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

56 

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

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

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

60# upstream of this package in the future. 

61 

62 

63class YX[T](NamedTuple): 

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

65 

66 Notes 

67 ----- 

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

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

70 arithmetic operations behave as they would on a 

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

72 concatenates). 

73 

74 See Also 

75 -------- 

76 XY 

77 """ 

78 

79 y: T 

80 """The y / row object.""" 

81 

82 x: T 

83 """The x / column object.""" 

84 

85 @property 

86 def xy(self) -> XY: 

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

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

89 

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

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

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

93 

94 

95class XY[T](NamedTuple): 

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

97 

98 Notes 

99 ----- 

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

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

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

103 vector (e.g. adding concatenates). 

104 

105 See Also 

106 -------- 

107 YX 

108 """ 

109 

110 x: T 

111 """The x / column object.""" 

112 

113 y: T 

114 """The y / row object.""" 

115 

116 @property 

117 def yx(self) -> YX: 

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

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

120 

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

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

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

124 

125 

126class _SerializedInterval(TypedDict): 

127 start: int 

128 stop: int 

129 

130 

131@final 

132class Interval: 

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

134 

135 Parameters 

136 ---------- 

137 start 

138 Inclusive minimum point in the interval. 

139 stop 

140 One past the maximum point in the interval. 

141 

142 Notes 

143 ----- 

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

145 

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

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

148 Pydantic model itself. 

149 """ 

150 

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

152 # Coerce to be defensive against numpy int scalars. 

153 self._start = int(start) 

154 self._stop = int(stop) 

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

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

157 

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

159 

160 factory: ClassVar[IntervalSliceFactory] 

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

162 

163 For example:: 

164 

165 interval = Interval.factory[2:5] 

166 """ 

167 

168 @classmethod 

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

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

171 intervals. 

172 """ 

173 if type(first) is Interval: 

174 rmin = first.min 

175 rmax = first.max 

176 else: 

177 rmin = rmax = first 

178 for arg in args: 

179 if type(arg) is Interval: 

180 rmin = min(rmin, arg.min) 

181 rmax = max(rmax, arg.max) 

182 else: 

183 rmin = min(rmin, arg) 

184 rmax = max(rmax, arg) 

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

186 

187 @classmethod 

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

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

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

191 

192 @property 

193 def start(self) -> int: 

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

195 return self._start 

196 

197 @property 

198 def stop(self) -> int: 

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

200 return self._stop 

201 

202 @property 

203 def min(self) -> int: 

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

205 return self.start 

206 

207 @property 

208 def max(self) -> int: 

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

210 return self.stop - 1 

211 

212 @property 

213 def size(self) -> int: 

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

215 return self.stop - self.start 

216 

217 @property 

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

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

220 (`__builtins__.range`). 

221 """ 

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

223 

224 @property 

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

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

227 

228 Array values are integers. 

229 """ 

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

231 

232 @property 

233 def absolute(self) -> IntervalSliceFactory: 

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

235 syntax and absolute coordinates. 

236 

237 Notes 

238 ----- 

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

240 """ 

241 return IntervalSliceFactory(self, is_local=False) 

242 

243 @property 

244 def local(self) -> IntervalSliceFactory: 

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

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

247 

248 Notes 

249 ----- 

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

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

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

253 """ 

254 return IntervalSliceFactory(self, is_local=True) 

255 

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

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

258 

259 Parameters 

260 ---------- 

261 n 

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

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

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

265 step 

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

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

268 

269 Returns 

270 ------- 

271 numpy.ndarray 

272 Array of `float` values. 

273 

274 See Also 

275 -------- 

276 numpy.linspace 

277 """ 

278 if n is None: 

279 if step is None: 

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

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

282 elif step is not None: 

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

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

285 

286 @property 

287 def center(self) -> float: 

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

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

290 

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

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

293 either side. 

294 """ 

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

296 

297 def __str__(self) -> str: 

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

299 

300 def __repr__(self) -> str: 

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

302 

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

304 if type(other) is Interval: 

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

306 return False 

307 

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

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

310 

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

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

313 

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

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

316 

317 @overload 

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

319 

320 @overload 

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

322 

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

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

325 points. 

326 

327 Parameters 

328 ---------- 

329 other 

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

331 

332 Returns 

333 ------- 

334 `bool` | `numpy.ndarray` 

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

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

337 

338 Notes 

339 ----- 

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

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

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

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

344 appear "on the image". 

345 """ 

346 if isinstance(other, Interval): 

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

348 else: 

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

350 if not result.shape: 

351 return bool(result) 

352 return result 

353 

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

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

356 

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

358 raised. 

359 """ 

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

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

362 if new_start < new_stop: 

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

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

365 

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

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

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

369 

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

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

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

373 

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

375 """ 

376 if not other.contains(self): 

377 raise IndexError( 

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

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

380 ) 

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

382 

383 @classmethod 

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

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

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

387 

388 def to_legacy(self) -> Any: 

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

390 from lsst.geom import IntervalI 

391 

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

393 

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

395 return ( 

396 Interval, 

397 ( 

398 self._start, 

399 self._stop, 

400 ), 

401 ) 

402 

403 @classmethod 

404 def __get_pydantic_core_schema__( 

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

406 ) -> pcs.CoreSchema: 

407 from_typed_dict = pcs.chain_schema( 

408 [ 

409 handler(_SerializedInterval), 

410 pcs.no_info_plain_validator_function(cls._validate), 

411 ] 

412 ) 

413 return pcs.json_or_python_schema( 

414 json_schema=from_typed_dict, 

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

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

417 ) 

418 

419 @classmethod 

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

421 return cls(**data) 

422 

423 def _serialize(self) -> _SerializedInterval: 

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

425 

426 

427class IntervalSliceFactory: 

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

429 

430 Notes 

431 ----- 

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

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

434 

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

436 

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

438 not allowed. 

439 

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

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

442 interval's bounds:: 

443 

444 parent = Interval.factory[3:6] 

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

446 

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

448 

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

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

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

452 

453 parent = Interval.factory[3:6] 

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

455 

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

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

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

459 """ 

460 

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

462 self._parent = parent 

463 self._is_local = is_local 

464 

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

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

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

468 if self._is_local: 

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

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

471 start += self._parent.start 

472 stop += self._parent.start 

473 else: 

474 start = s.start 

475 stop = s.stop 

476 if start is None: 

477 if self._parent is None: 

478 start = 0 

479 else: 

480 start = self._parent.start 

481 if stop is None: 

482 if self._parent is None: 

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

484 stop = self._parent.stop 

485 if self._parent is not None: 

486 if start < self._parent.start: 

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

488 if stop > self._parent.stop: 

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

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

491 

492 

493Interval.factory = IntervalSliceFactory() 

494 

495 

496class _SerializedBox(TypedDict): 

497 y: _SerializedInterval 

498 x: _SerializedInterval 

499 

500 

501class Box: 

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

503 

504 Parameters 

505 ---------- 

506 y 

507 Interval for the y dimension. 

508 x 

509 Interval for the x dimension. 

510 

511 Notes 

512 ----- 

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

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

515 Pydantic model itself. 

516 """ 

517 

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

519 self._intervals = YX(y, x) 

520 

521 __slots__ = ("_intervals",) 

522 

523 factory: ClassVar[BoxSliceFactory] 

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

525 

526 For example:: 

527 

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

529 """ 

530 

531 @classmethod 

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

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

534 

535 Parameters 

536 ---------- 

537 shape 

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

539 start 

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

541 """ 

542 if start is None: 

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

544 match shape: 

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

546 pass 

547 case [y_size, x_size]: 

548 pass 

549 case _: 

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

551 match start: 

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

553 pass 

554 case [y_start, x_start]: 

555 pass 

556 case _: 

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

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

559 

560 @property 

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

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

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

564 """ 

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

566 

567 @property 

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

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

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

571 """ 

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

573 

574 @property 

575 def x(self) -> Interval: 

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

577 return self._intervals[-1] 

578 

579 @property 

580 def y(self) -> Interval: 

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

582 return self._intervals[-2] 

583 

584 @property 

585 def absolute(self) -> BoxSliceFactory: 

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

587 syntax and absolute coordinates. 

588 

589 Notes 

590 ----- 

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

592 """ 

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

594 

595 @property 

596 def local(self) -> BoxSliceFactory: 

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

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

599 

600 Notes 

601 ----- 

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

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

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

605 """ 

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

607 

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

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

610 

611 Parameters 

612 ---------- 

613 n 

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

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

616 provided. 

617 step 

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

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

620 ``n``. 

621 

622 Returns 

623 ------- 

624 `XY` [`numpy.ndarray`] 

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

626 

627 See Also 

628 -------- 

629 numpy.meshgrid 

630 """ 

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

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

633 match n: 

634 case int(): 

635 ax = self.x.linspace(n) 

636 ay = self.y.linspace(n) 

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

638 ax = self.x.linspace(nx) 

639 ay = self.y.linspace(ny) 

640 case [nx, ny]: 

641 ax = self.x.linspace(nx) 

642 ay = self.y.linspace(ny) 

643 case None: 

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

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

646 case _: 

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

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

649 

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

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

652 all sides. 

653 """ 

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

655 

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

657 if type(other) is Box: 

658 return self._intervals == other._intervals 

659 return False 

660 

661 def __str__(self) -> str: 

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

663 

664 def __repr__(self) -> str: 

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

666 

667 @overload 

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

669 

670 @overload 

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

672 

673 @overload 

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

675 

676 def contains( 

677 self, 

678 other: Box | None = None, 

679 *, 

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

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

682 ) -> bool | np.ndarray: 

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

684 

685 Parameters 

686 ---------- 

687 other 

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

689 arguments. 

690 y 

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

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

693 x 

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

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

696 

697 Returns 

698 ------- 

699 `bool` | `numpy.ndarray` 

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

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

702 array with their broadcasted shape. 

703 

704 Notes 

705 ----- 

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

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

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

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

710 appear "on the image". 

711 """ 

712 if other is not None: 

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

714 raise TypeError("Too many arguments to 'Box.contains'.") 

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

716 elif x is None or y is None: 

717 raise TypeError("Not enough arguments to 'Box.contains'.") 

718 else: 

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

720 if not result.shape: 

721 return bool(result) 

722 return result 

723 

724 @overload 

725 def intersection(self, other: Box) -> Box: ... 725 ↛ exitline 725 didn't return from function 'intersection' because

726 

727 @overload 

728 def intersection(self, other: Bounds) -> Bounds: ... 728 ↛ exitline 728 didn't return from function 'intersection' because

729 

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

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

732 ``other``. 

733 

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

735 """ 

736 from ._concrete_bounds import _intersect_box 

737 

738 return _intersect_box(self, other) 

739 

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

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

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

743 

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

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

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

747 correspond to ``other``. 

748 

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

750 """ 

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

752 

753 @property 

754 def bbox(self) -> Box: 

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

756 

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

758 """ 

759 return self 

760 

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

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

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

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

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

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

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

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

769 

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

771 return (Box, self._intervals) 

772 

773 @classmethod 

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

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

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

777 

778 def to_legacy(self) -> Any: 

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

780 from lsst.geom import Box2I 

781 

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

783 

784 @classmethod 

785 def __get_pydantic_core_schema__( 

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

787 ) -> pcs.CoreSchema: 

788 from_typed_dict = pcs.chain_schema( 

789 [ 

790 handler(_SerializedBox), 

791 pcs.no_info_plain_validator_function(cls._validate), 

792 ] 

793 ) 

794 return pcs.json_or_python_schema( 

795 json_schema=from_typed_dict, 

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

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

798 ) 

799 

800 @classmethod 

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

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

803 

804 def _serialize(self) -> _SerializedBox: 

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

806 

807 def serialize(self) -> Box: 

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

809 

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

811 Pydantic serialization hooks. It exists for compatibility with the 

812 `Bounds` protocol. 

813 """ 

814 return self 

815 

816 @classmethod 

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

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

819 

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

821 Pydantic serialization hooks. It exists for compatibility with the 

822 `Bounds` protocol. 

823 """ 

824 assert isinstance(serialized, Box) 

825 return serialized 

826 

827 

828class BoxSliceFactory: 

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

830 

831 Notes 

832 ----- 

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

834 `Box` with exactly those bounds:: 

835 

836 assert ( 

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

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

839 ) 

840 

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

842 not allowed. 

843 

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

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

846 box's bounds:: 

847 

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

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

850 

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

852 

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

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

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

856 

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

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

859 """ 

860 

861 def __init__( 

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

863 ): 

864 self._y = y 

865 self._x = x 

866 

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

868 match key: 

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

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

871 case (y, x): 

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

873 case _: 

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

875 

876 

877Box.factory = BoxSliceFactory() 

878 

879 

880class Bounds(Protocol): 

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

882 defined in 2-d pixel coordinates. 

883 

884 Notes 

885 ----- 

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

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

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

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

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

891 intended to handle both of these cases as well. 

892 """ 

893 

894 @property 

895 def bbox(self) -> Box: ... 895 ↛ exitline 895 didn't return from function 'bbox' because

896 

897 @overload 

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

899 

900 @overload 

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

902 

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

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

905 

906 Parameters 

907 ---------- 

908 x 

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

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

911 y 

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

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

914 

915 Returns 

916 ------- 

917 `bool` | `numpy.ndarray` 

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

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

920 shape. 

921 """ 

922 ... 

923 

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

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

926 ... 

927 

928 def serialize(self) -> SerializableBounds: 

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

930 ... 

931 

932 @classmethod 

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

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

935 from ._concrete_bounds import deserialize_bounds 

936 

937 return cast(Self, deserialize_bounds(serialized)) 

938 

939 

940class BoundsError(ValueError): 

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

942 

943 

944class NoOverlapError(ValueError): 

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