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

368 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-10 16:39 +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 

43from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue 

44 

45if TYPE_CHECKING: 

46 from ._concrete_bounds import SerializableBounds 

47 

48 try: 

49 from lsst.geom import Extent2I as LegacyExtent2I 

50 from lsst.geom import Point2I as LegacyPoint2I 

51 except ImportError: 

52 type LegacyExtent2I = Any # type: ignore[no-redef] 

53 type LegacyPoint2I = Any # type: ignore[no-redef] 

54 

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

56# autodoc-typehints plugin. 

57T = TypeVar("T") 

58 

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

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

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

62 

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

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

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

66# upstream of this package in the future. 

67 

68 

69class YX[T](NamedTuple): 

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

71 

72 Notes 

73 ----- 

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

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

76 arithmetic operations behave as they would on a 

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

78 concatenates). 

79 

80 See Also 

81 -------- 

82 XY 

83 """ 

84 

85 y: T 

86 """The y / row object.""" 

87 

88 x: T 

89 """The x / column object.""" 

90 

91 @property 

92 def xy(self) -> XY: 

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

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

95 

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

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

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

99 

100 def to_legacy_extent(self) -> LegacyExtent2I: 

101 """Convert to a legacy `lsst.geom.Extent2I` object.""" 

102 from lsst.geom import Extent2I as LegacyExtent2I 

103 

104 return LegacyExtent2I(self.x, self.y) 

105 

106 def to_legacy_point(self) -> LegacyPoint2I: 

107 """Convert to a legacy `lsst.geom.Point2I` object.""" 

108 from lsst.geom import Point2I as LegacyPoint2I 

109 

110 return LegacyPoint2I(self.x, self.y) 

111 

112 

113class XY[T](NamedTuple): 

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

115 

116 Notes 

117 ----- 

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

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

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

121 vector (e.g. adding concatenates). 

122 

123 See Also 

124 -------- 

125 YX 

126 """ 

127 

128 x: T 

129 """The x / column object.""" 

130 

131 y: T 

132 """The y / row object.""" 

133 

134 @property 

135 def yx(self) -> YX: 

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

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

138 

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

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

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

142 

143 def to_legacy_extent(self) -> LegacyExtent2I: 

144 """Convert to a legacy `lsst.geom.Extent2I` object.""" 

145 from lsst.geom import Extent2I as LegacyExtent2I 

146 

147 return LegacyExtent2I(self.x, self.y) 

148 

149 def to_legacy_point(self) -> LegacyPoint2I: 

150 """Convert to a legacy `lsst.geom.Point2I` object.""" 

151 from lsst.geom import Point2I as LegacyPoint2I 

152 

153 return LegacyPoint2I(self.x, self.y) 

154 

155 

156class _SerializedInterval(TypedDict): 

157 start: int 

158 stop: int 

159 

160 

161@final 

162class Interval: 

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

164 

165 Parameters 

166 ---------- 

167 start 

168 Inclusive minimum point in the interval. 

169 stop 

170 One past the maximum point in the interval. 

171 

172 Notes 

173 ----- 

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

175 

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

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

178 Pydantic model itself. 

179 """ 

180 

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

182 # Coerce to be defensive against numpy int scalars. 

183 self._start = int(start) 

184 self._stop = int(stop) 

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

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

187 

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

189 

190 factory: ClassVar[IntervalSliceFactory] 

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

192 

193 For example:: 

194 

195 interval = Interval.factory[2:5] 

196 """ 

197 

198 @classmethod 

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

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

201 intervals. 

202 """ 

203 if type(first) is Interval: 

204 rmin = first.min 

205 rmax = first.max 

206 else: 

207 rmin = rmax = first 

208 for arg in args: 

209 if type(arg) is Interval: 

210 rmin = min(rmin, arg.min) 

211 rmax = max(rmax, arg.max) 

212 else: 

213 rmin = min(rmin, arg) 

214 rmax = max(rmax, arg) 

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

216 

217 @classmethod 

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

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

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

221 

222 @property 

223 def start(self) -> int: 

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

225 return self._start 

226 

227 @property 

228 def stop(self) -> int: 

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

230 return self._stop 

231 

232 @property 

233 def min(self) -> int: 

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

235 return self.start 

236 

237 @property 

238 def max(self) -> int: 

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

240 return self.stop - 1 

241 

242 @property 

243 def size(self) -> int: 

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

245 return self.stop - self.start 

246 

247 @property 

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

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

250 (`__builtins__.range`). 

251 """ 

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

253 

254 @property 

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

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

257 

258 Array values are integers. 

259 """ 

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

261 

262 @property 

263 def absolute(self) -> IntervalSliceFactory: 

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

265 syntax and absolute coordinates. 

266 

267 Notes 

268 ----- 

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

270 """ 

271 return IntervalSliceFactory(self, is_local=False) 

272 

273 @property 

274 def local(self) -> IntervalSliceFactory: 

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

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

277 

278 Notes 

279 ----- 

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

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

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

283 """ 

284 return IntervalSliceFactory(self, is_local=True) 

285 

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

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

288 

289 Parameters 

290 ---------- 

291 n 

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

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

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

295 step 

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

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

298 

299 Returns 

300 ------- 

301 numpy.ndarray 

302 Array of `float` values. 

303 

304 See Also 

305 -------- 

306 numpy.linspace 

307 """ 

308 if n is None: 

309 if step is None: 

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

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

312 elif step is not None: 

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

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

315 

316 @property 

317 def center(self) -> float: 

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

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

320 

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

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

323 either side. 

324 """ 

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

326 

327 def __str__(self) -> str: 

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

329 

330 def __repr__(self) -> str: 

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

332 

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

334 if type(other) is Interval: 

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

336 return False 

337 

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

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

340 

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

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

343 

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

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

346 

347 @overload 

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

349 

350 @overload 

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

352 

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

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

355 points. 

356 

357 Parameters 

358 ---------- 

359 other 

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

361 

362 Returns 

363 ------- 

364 `bool` | `numpy.ndarray` 

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

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

367 

368 Notes 

369 ----- 

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

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

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

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

374 appear "on the image". 

375 """ 

376 if isinstance(other, Interval): 

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

378 else: 

379 result = np.logical_and(self.min - 0.5 <= other, other < self.max + 0.5) 

380 if not result.shape: 

381 return bool(result) 

382 return result 

383 

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

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

386 

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

388 raised. 

389 """ 

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

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

392 if new_start < new_stop: 

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

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

395 

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

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

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

399 

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

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

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

403 

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

405 """ 

406 if not other.contains(self): 

407 raise IndexError( 

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

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

410 ) 

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

412 

413 @classmethod 

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

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

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

417 

418 def to_legacy(self) -> Any: 

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

420 from lsst.geom import IntervalI 

421 

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

423 

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

425 return ( 

426 Interval, 

427 ( 

428 self._start, 

429 self._stop, 

430 ), 

431 ) 

432 

433 @classmethod 

434 def __get_pydantic_core_schema__( 

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

436 ) -> pcs.CoreSchema: 

437 from_typed_dict = pcs.chain_schema( 

438 [ 

439 handler(_SerializedInterval), 

440 pcs.no_info_plain_validator_function(cls._validate), 

441 ] 

442 ) 

443 return pcs.json_or_python_schema( 

444 json_schema=from_typed_dict, 

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

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

447 ) 

448 

449 @classmethod 

450 def __get_pydantic_json_schema__( 

451 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler 

452 ) -> JsonSchemaValue: 

453 return handler(pydantic.TypeAdapter(_SerializedInterval).core_schema) 

454 

455 @classmethod 

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

457 return cls(**data) 

458 

459 def _serialize(self) -> _SerializedInterval: 

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

461 

462 

463class IntervalSliceFactory: 

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

465 

466 Notes 

467 ----- 

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

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

470 

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

472 

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

474 not allowed. 

475 

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

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

478 interval's bounds:: 

479 

480 parent = Interval.factory[3:6] 

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

482 

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

484 

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

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

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

488 

489 parent = Interval.factory[3:6] 

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

491 

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

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

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

495 """ 

496 

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

498 self._parent = parent 

499 self._is_local = is_local 

500 

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

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

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

504 if self._is_local: 

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

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

507 start += self._parent.start 

508 stop += self._parent.start 

509 else: 

510 start = s.start 

511 stop = s.stop 

512 if start is None: 

513 if self._parent is None: 

514 start = 0 

515 else: 

516 start = self._parent.start 

517 if stop is None: 

518 if self._parent is None: 

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

520 stop = self._parent.stop 

521 if self._parent is not None: 

522 if start < self._parent.start: 

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

524 if stop > self._parent.stop: 

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

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

527 

528 

529Interval.factory = IntervalSliceFactory() 

530 

531 

532class _SerializedBox(TypedDict): 

533 y: _SerializedInterval 

534 x: _SerializedInterval 

535 

536 

537class Box: 

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

539 

540 Parameters 

541 ---------- 

542 y 

543 Interval for the y dimension. 

544 x 

545 Interval for the x dimension. 

546 

547 Notes 

548 ----- 

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

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

551 Pydantic model itself. 

552 """ 

553 

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

555 self._intervals = YX(y, x) 

556 

557 __slots__ = ("_intervals",) 

558 

559 factory: ClassVar[BoxSliceFactory] 

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

561 

562 For example:: 

563 

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

565 """ 

566 

567 @classmethod 

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

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

570 

571 Parameters 

572 ---------- 

573 shape 

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

575 start 

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

577 """ 

578 if start is None: 

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

580 match shape: 

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

582 pass 

583 case [y_size, x_size]: 

584 pass 

585 case _: 

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

587 match start: 

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

589 pass 

590 case [y_start, x_start]: 

591 pass 

592 case _: 

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

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

595 

596 @property 

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

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

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

600 """ 

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

602 

603 @property 

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

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

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

607 """ 

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

609 

610 @property 

611 def x(self) -> Interval: 

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

613 return self._intervals[-1] 

614 

615 @property 

616 def y(self) -> Interval: 

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

618 return self._intervals[-2] 

619 

620 @property 

621 def min_yx(self) -> YX[int]: 

622 """The minimum bounds of the box as a `YX` tuple.""" 

623 return YX(y=self._intervals.y.min, x=self._intervals.x.min) 

624 

625 @property 

626 def max_yx(self) -> YX[int]: 

627 """The maximum bounds of the box as a `YX` tuple.""" 

628 return YX(y=self._intervals.y.max, x=self._intervals.x.max) 

629 

630 @property 

631 def absolute(self) -> BoxSliceFactory: 

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

633 syntax and absolute coordinates. 

634 

635 Notes 

636 ----- 

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

638 """ 

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

640 

641 @property 

642 def local(self) -> BoxSliceFactory: 

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

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

645 

646 Notes 

647 ----- 

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

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

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

651 """ 

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

653 

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

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

656 

657 Parameters 

658 ---------- 

659 n 

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

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

662 provided. 

663 step 

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

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

666 ``n``. 

667 

668 Returns 

669 ------- 

670 `XY` [`numpy.ndarray`] 

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

672 

673 See Also 

674 -------- 

675 numpy.meshgrid 

676 """ 

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

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

679 match n: 

680 case int(): 

681 ax = self.x.linspace(n) 

682 ay = self.y.linspace(n) 

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

684 ax = self.x.linspace(nx) 

685 ay = self.y.linspace(ny) 

686 case [nx, ny]: 

687 ax = self.x.linspace(nx) 

688 ay = self.y.linspace(ny) 

689 case None: 

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

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

692 case _: 

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

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

695 

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

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

698 all sides. 

699 """ 

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

701 

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

703 if type(other) is Box: 

704 return self._intervals == other._intervals 

705 return False 

706 

707 def __str__(self) -> str: 

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

709 

710 def __repr__(self) -> str: 

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

712 

713 @overload 

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

715 

716 @overload 

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

718 

719 @overload 

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

721 

722 def contains( 

723 self, 

724 other: Box | None = None, 

725 *, 

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

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

728 ) -> bool | np.ndarray: 

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

730 

731 Parameters 

732 ---------- 

733 other 

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

735 arguments. 

736 y 

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

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

739 x 

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

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

742 

743 Returns 

744 ------- 

745 `bool` | `numpy.ndarray` 

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

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

748 array with their broadcasted shape. 

749 

750 Notes 

751 ----- 

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

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

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

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

756 appear "on the image". 

757 """ 

758 if other is not None: 

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

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

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

762 elif x is None or y is None: 

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

764 else: 

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

766 if not result.shape: 

767 return bool(result) 

768 return result 

769 

770 @overload 

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

772 

773 @overload 

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

775 

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

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

778 ``other``. 

779 

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

781 """ 

782 from ._concrete_bounds import _intersect_box 

783 

784 return _intersect_box(self, other) 

785 

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

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

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

789 

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

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

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

793 correspond to ``other``. 

794 

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

796 """ 

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

798 

799 @property 

800 def bbox(self) -> Box: 

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

802 

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

804 """ 

805 return self 

806 

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

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

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

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

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

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

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

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

815 

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

817 return (Box, self._intervals) 

818 

819 @classmethod 

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

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

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

823 

824 def to_legacy(self) -> Any: 

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

826 from lsst.geom import Box2I 

827 

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

829 

830 @classmethod 

831 def __get_pydantic_core_schema__( 

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

833 ) -> pcs.CoreSchema: 

834 from_typed_dict = pcs.chain_schema( 

835 [ 

836 handler(_SerializedBox), 

837 pcs.no_info_plain_validator_function(cls._validate), 

838 ] 

839 ) 

840 return pcs.json_or_python_schema( 

841 json_schema=from_typed_dict, 

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

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

844 ) 

845 

846 @classmethod 

847 def __get_pydantic_json_schema__( 

848 cls, schema: pcs.CoreSchema, handler: GetJsonSchemaHandler 

849 ) -> JsonSchemaValue: 

850 return handler(pydantic.TypeAdapter(_SerializedBox).core_schema) 

851 

852 @classmethod 

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

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

855 

856 def _serialize(self) -> _SerializedBox: 

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

858 

859 def serialize(self) -> Box: 

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

861 

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

863 Pydantic serialization hooks. It exists for compatibility with the 

864 `Bounds` protocol. 

865 """ 

866 return self 

867 

868 def deserialize(self) -> Box: 

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

870 

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

872 Pydantic serialization hooks. It exists for compatibility with the 

873 `Bounds` protocol. 

874 """ 

875 return self 

876 

877 

878class BoxSliceFactory: 

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

880 

881 Notes 

882 ----- 

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

884 `Box` with exactly those bounds:: 

885 

886 assert ( 

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

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

889 ) 

890 

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

892 not allowed. 

893 

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

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

896 box's bounds:: 

897 

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

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

900 

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

902 

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

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

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

906 

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

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

909 """ 

910 

911 def __init__( 

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

913 ): 

914 self._y = y 

915 self._x = x 

916 

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

918 match key: 

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

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

921 case (y, x): 

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

923 case _: 

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

925 

926 

927Box.factory = BoxSliceFactory() 

928 

929 

930class Bounds(Protocol): 

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

932 defined in 2-d pixel coordinates. 

933 

934 Notes 

935 ----- 

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

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

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

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

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

941 intended to handle both of these cases as well. 

942 """ 

943 

944 @property 

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

946 

947 @overload 

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

949 

950 @overload 

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

952 

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

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

955 

956 Parameters 

957 ---------- 

958 x 

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

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

961 y 

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

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

964 

965 Returns 

966 ------- 

967 `bool` | `numpy.ndarray` 

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

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

970 shape. 

971 """ 

972 ... 

973 

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

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

976 ... 

977 

978 def serialize(self) -> SerializableBounds: 

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

980 

981 Notes 

982 ----- 

983 The returned object must support direct nesting with Pydantic models 

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

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

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

987 natively serializable. 

988 """ 

989 ... 

990 

991 

992class BoundsError(ValueError): 

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

994 

995 

996class NoOverlapError(ValueError): 

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