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

343 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-15 08:42 +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 TypedDict, 

35 TypeVar, 

36 final, 

37 overload, 

38) 

39 

40import numpy as np 

41import pydantic 

42import pydantic_core.core_schema as pcs 

43 

44if TYPE_CHECKING: 

45 from ._concrete_bounds import SerializableBounds 

46 

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

48# autodoc-typehints plugin. 

49T = TypeVar("T") 

50 

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

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

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

54 

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

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

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

58# upstream of this package in the future. 

59 

60 

61class YX[T](NamedTuple): 

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

63 

64 Notes 

65 ----- 

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

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

68 arithmetic operations behave as they would on a 

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

70 concatenates). 

71 

72 See Also 

73 -------- 

74 XY 

75 """ 

76 

77 y: T 

78 """The y / row object.""" 

79 

80 x: T 

81 """The x / column object.""" 

82 

83 @property 

84 def xy(self) -> XY: 

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

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

87 

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

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

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

91 

92 

93class XY[T](NamedTuple): 

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

95 

96 Notes 

97 ----- 

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

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

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

101 vector (e.g. adding concatenates). 

102 

103 See Also 

104 -------- 

105 YX 

106 """ 

107 

108 x: T 

109 """The x / column object.""" 

110 

111 y: T 

112 """The y / row object.""" 

113 

114 @property 

115 def yx(self) -> YX: 

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

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

118 

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

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

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

122 

123 

124class _SerializedInterval(TypedDict): 

125 start: int 

126 stop: int 

127 

128 

129@final 

130class Interval: 

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

132 

133 Parameters 

134 ---------- 

135 start 

136 Inclusive minimum point in the interval. 

137 stop 

138 One past the maximum point in the interval. 

139 

140 Notes 

141 ----- 

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

143 

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

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

146 Pydantic model itself. 

147 """ 

148 

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

150 # Coerce to be defensive against numpy int scalars. 

151 self._start = int(start) 

152 self._stop = int(stop) 

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

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

155 

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

157 

158 factory: ClassVar[IntervalSliceFactory] 

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

160 

161 For example:: 

162 

163 interval = Interval.factory[2:5] 

164 """ 

165 

166 @classmethod 

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

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

169 intervals. 

170 """ 

171 if type(first) is Interval: 

172 rmin = first.min 

173 rmax = first.max 

174 else: 

175 rmin = rmax = first 

176 for arg in args: 

177 if type(arg) is Interval: 

178 rmin = min(rmin, arg.min) 

179 rmax = max(rmax, arg.max) 

180 else: 

181 rmin = min(rmin, arg) 

182 rmax = max(rmax, arg) 

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

184 

185 @classmethod 

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

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

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

189 

190 @property 

191 def start(self) -> int: 

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

193 return self._start 

194 

195 @property 

196 def stop(self) -> int: 

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

198 return self._stop 

199 

200 @property 

201 def min(self) -> int: 

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

203 return self.start 

204 

205 @property 

206 def max(self) -> int: 

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

208 return self.stop - 1 

209 

210 @property 

211 def size(self) -> int: 

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

213 return self.stop - self.start 

214 

215 @property 

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

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

218 (`__builtins__.range`). 

219 """ 

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

221 

222 @property 

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

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

225 

226 Array values are integers. 

227 """ 

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

229 

230 @property 

231 def absolute(self) -> IntervalSliceFactory: 

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

233 syntax and absolute coordinates. 

234 

235 Notes 

236 ----- 

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

238 """ 

239 return IntervalSliceFactory(self, is_local=False) 

240 

241 @property 

242 def local(self) -> IntervalSliceFactory: 

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

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

245 

246 Notes 

247 ----- 

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

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

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

251 """ 

252 return IntervalSliceFactory(self, is_local=True) 

253 

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

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

256 

257 Parameters 

258 ---------- 

259 n 

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

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

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

263 step 

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

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

266 

267 Returns 

268 ------- 

269 numpy.ndarray 

270 Array of `float` values. 

271 

272 See Also 

273 -------- 

274 numpy.linspace 

275 """ 

276 if n is None: 

277 if step is None: 

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

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

280 elif step is not None: 

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

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

283 

284 @property 

285 def center(self) -> float: 

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

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

288 

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

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

291 either side. 

292 """ 

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

294 

295 def __str__(self) -> str: 

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

297 

298 def __repr__(self) -> str: 

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

300 

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

302 if type(other) is Interval: 

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

304 return False 

305 

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

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

308 

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

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

311 

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

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

314 

315 @overload 

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

317 

318 @overload 

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

320 

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

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

323 points. 

324 

325 Parameters 

326 ---------- 

327 other 

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

329 

330 Returns 

331 ------- 

332 `bool` | `numpy.ndarray` 

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

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

335 

336 Notes 

337 ----- 

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

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

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

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

342 appear "on the image". 

343 """ 

344 if isinstance(other, Interval): 

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

346 else: 

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

348 if not result.shape: 

349 return bool(result) 

350 return result 

351 

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

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

354 

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

356 raised. 

357 """ 

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

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

360 if new_start < new_stop: 

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

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

363 

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

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

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

367 

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

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

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

371 

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

373 """ 

374 if not other.contains(self): 

375 raise IndexError( 

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

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

378 ) 

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

380 

381 @classmethod 

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

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

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

385 

386 def to_legacy(self) -> Any: 

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

388 from lsst.geom import IntervalI 

389 

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

391 

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

393 return ( 

394 Interval, 

395 ( 

396 self._start, 

397 self._stop, 

398 ), 

399 ) 

400 

401 @classmethod 

402 def __get_pydantic_core_schema__( 

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

404 ) -> pcs.CoreSchema: 

405 from_typed_dict = pcs.chain_schema( 

406 [ 

407 handler(_SerializedInterval), 

408 pcs.no_info_plain_validator_function(cls._validate), 

409 ] 

410 ) 

411 return pcs.json_or_python_schema( 

412 json_schema=from_typed_dict, 

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

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

415 ) 

416 

417 @classmethod 

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

419 return cls(**data) 

420 

421 def _serialize(self) -> _SerializedInterval: 

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

423 

424 

425class IntervalSliceFactory: 

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

427 

428 Notes 

429 ----- 

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

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

432 

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

434 

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

436 not allowed. 

437 

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

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

440 interval's bounds:: 

441 

442 parent = Interval.factory[3:6] 

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

444 

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

446 

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

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

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

450 

451 parent = Interval.factory[3:6] 

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

453 

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

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

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

457 """ 

458 

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

460 self._parent = parent 

461 self._is_local = is_local 

462 

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

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

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

466 if self._is_local: 

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

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

469 start += self._parent.start 

470 stop += self._parent.start 

471 else: 

472 start = s.start 

473 stop = s.stop 

474 if start is None: 

475 if self._parent is None: 

476 start = 0 

477 else: 

478 start = self._parent.start 

479 if stop is None: 

480 if self._parent is None: 

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

482 stop = self._parent.stop 

483 if self._parent is not None: 

484 if start < self._parent.start: 

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

486 if stop > self._parent.stop: 

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

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

489 

490 

491Interval.factory = IntervalSliceFactory() 

492 

493 

494class _SerializedBox(TypedDict): 

495 y: _SerializedInterval 

496 x: _SerializedInterval 

497 

498 

499class Box: 

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

501 

502 Parameters 

503 ---------- 

504 y 

505 Interval for the y dimension. 

506 x 

507 Interval for the x dimension. 

508 

509 Notes 

510 ----- 

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

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

513 Pydantic model itself. 

514 """ 

515 

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

517 self._intervals = YX(y, x) 

518 

519 __slots__ = ("_intervals",) 

520 

521 factory: ClassVar[BoxSliceFactory] 

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

523 

524 For example:: 

525 

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

527 """ 

528 

529 @classmethod 

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

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

532 

533 Parameters 

534 ---------- 

535 shape 

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

537 start 

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

539 """ 

540 if start is None: 

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

542 match shape: 

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

544 pass 

545 case [y_size, x_size]: 

546 pass 

547 case _: 

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

549 match start: 

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

551 pass 

552 case [y_start, x_start]: 

553 pass 

554 case _: 

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

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

557 

558 @property 

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

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

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

562 """ 

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

564 

565 @property 

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

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

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

569 """ 

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

571 

572 @property 

573 def x(self) -> Interval: 

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

575 return self._intervals[-1] 

576 

577 @property 

578 def y(self) -> Interval: 

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

580 return self._intervals[-2] 

581 

582 @property 

583 def absolute(self) -> BoxSliceFactory: 

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

585 syntax and absolute coordinates. 

586 

587 Notes 

588 ----- 

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

590 """ 

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

592 

593 @property 

594 def local(self) -> BoxSliceFactory: 

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

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

597 

598 Notes 

599 ----- 

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

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

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

603 """ 

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

605 

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

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

608 

609 Parameters 

610 ---------- 

611 n 

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

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

614 provided. 

615 step 

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

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

618 ``n``. 

619 

620 Returns 

621 ------- 

622 `XY` [`numpy.ndarray`] 

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

624 

625 See Also 

626 -------- 

627 numpy.meshgrid 

628 """ 

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

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

631 match n: 

632 case int(): 

633 ax = self.x.linspace(n) 

634 ay = self.y.linspace(n) 

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

636 ax = self.x.linspace(nx) 

637 ay = self.y.linspace(ny) 

638 case [nx, ny]: 

639 ax = self.x.linspace(nx) 

640 ay = self.y.linspace(ny) 

641 case None: 

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

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

644 case _: 

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

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

647 

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

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

650 all sides. 

651 """ 

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

653 

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

655 if type(other) is Box: 

656 return self._intervals == other._intervals 

657 return False 

658 

659 def __str__(self) -> str: 

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

661 

662 def __repr__(self) -> str: 

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

664 

665 @overload 

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

667 

668 @overload 

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

670 

671 @overload 

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

673 

674 def contains( 

675 self, 

676 other: Box | None = None, 

677 *, 

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

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

680 ) -> bool | np.ndarray: 

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

682 

683 Parameters 

684 ---------- 

685 other 

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

687 arguments. 

688 y 

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

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

691 x 

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

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

694 

695 Returns 

696 ------- 

697 `bool` | `numpy.ndarray` 

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

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

700 array with their broadcasted shape. 

701 

702 Notes 

703 ----- 

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

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

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

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

708 appear "on the image". 

709 """ 

710 if other is not None: 

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

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

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

714 elif x is None or y is None: 

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

716 else: 

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

718 if not result.shape: 

719 return bool(result) 

720 return result 

721 

722 @overload 

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

724 

725 @overload 

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

727 

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

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

730 ``other``. 

731 

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

733 """ 

734 from ._concrete_bounds import _intersect_box 

735 

736 return _intersect_box(self, other) 

737 

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

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

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

741 

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

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

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

745 correspond to ``other``. 

746 

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

748 """ 

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

750 

751 @property 

752 def bbox(self) -> Box: 

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

754 

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

756 """ 

757 return self 

758 

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

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

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

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

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

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

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

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

767 

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

769 return (Box, self._intervals) 

770 

771 @classmethod 

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

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

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

775 

776 def to_legacy(self) -> Any: 

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

778 from lsst.geom import Box2I 

779 

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

781 

782 @classmethod 

783 def __get_pydantic_core_schema__( 

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

785 ) -> pcs.CoreSchema: 

786 from_typed_dict = pcs.chain_schema( 

787 [ 

788 handler(_SerializedBox), 

789 pcs.no_info_plain_validator_function(cls._validate), 

790 ] 

791 ) 

792 return pcs.json_or_python_schema( 

793 json_schema=from_typed_dict, 

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

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

796 ) 

797 

798 @classmethod 

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

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

801 

802 def _serialize(self) -> _SerializedBox: 

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

804 

805 def serialize(self) -> Box: 

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

807 

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

809 Pydantic serialization hooks. It exists for compatibility with the 

810 `Bounds` protocol. 

811 """ 

812 return self 

813 

814 def deserialize(self) -> Box: 

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

816 

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

818 Pydantic serialization hooks. It exists for compatibility with the 

819 `Bounds` protocol. 

820 """ 

821 return self 

822 

823 

824class BoxSliceFactory: 

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

826 

827 Notes 

828 ----- 

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

830 `Box` with exactly those bounds:: 

831 

832 assert ( 

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

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

835 ) 

836 

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

838 not allowed. 

839 

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

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

842 box's bounds:: 

843 

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

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

846 

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

848 

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

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

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

852 

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

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

855 """ 

856 

857 def __init__( 

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

859 ): 

860 self._y = y 

861 self._x = x 

862 

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

864 match key: 

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

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

867 case (y, x): 

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

869 case _: 

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

871 

872 

873Box.factory = BoxSliceFactory() 

874 

875 

876class Bounds(Protocol): 

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

878 defined in 2-d pixel coordinates. 

879 

880 Notes 

881 ----- 

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

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

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

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

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

887 intended to handle both of these cases as well. 

888 """ 

889 

890 @property 

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

892 

893 @overload 

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

895 

896 @overload 

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

898 

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

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

901 

902 Parameters 

903 ---------- 

904 x 

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

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

907 y 

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

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

910 

911 Returns 

912 ------- 

913 `bool` | `numpy.ndarray` 

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

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

916 shape. 

917 """ 

918 ... 

919 

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

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

922 ... 

923 

924 def serialize(self) -> SerializableBounds: 

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

926 

927 Notes 

928 ----- 

929 The returned object must support direct nesting with Pydantic models 

930 and have a ``deserialize`` method (taking no arguments) that converts 

931 back to this `Bounds` type. It is common for `serialize` and 

932 ``deserialize`` to just return ``self``, when the bounds object is 

933 natively serializable. 

934 """ 

935 ... 

936 

937 

938class BoundsError(ValueError): 

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

940 

941 

942class NoOverlapError(ValueError): 

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