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

371 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-14 20:37 -0700

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "XY", 

16 "YX", 

17 "Bounds", 

18 "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 min(self) -> int: 

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

225 return self.start 

226 

227 @property 

228 def max(self) -> int: 

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

230 return self.stop - 1 

231 

232 @property 

233 def start(self) -> int: 

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

235 return self._start 

236 

237 @property 

238 def stop(self) -> int: 

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

240 return self._stop 

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: 518 ↛ 519line 518 didn't jump to line 519 because the condition on line 518 was never true

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 min(self) -> YX[int]: 

598 """The inclusive minimum bounds of the box, ordered ``(y, x)`` 

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

600 """ 

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

602 

603 @property 

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

605 """The inclusive maximum bounds of the box, ordered ``(y, x)`` 

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

607 """ 

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

609 

610 @property 

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

612 """Tuple holding the inclusive `Interval.start` bvound, ordered 

613 ``(y, x)`` (`YX` [`int`]). 

614 

615 This is an alias for `min`, typically paired with `stop` for 

616 half-exclusive ranges. 

617 """ 

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

619 

620 @property 

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

622 """Tuple holding the exclusive `Interval.stop` bound, ordered 

623 ``(y, x)`` (`YX` [`int`]). 

624 

625 The values in this tuple are one greater than those in `max`. It is 

626 typically paired with `start` for half-exclusive ranges. 

627 """ 

628 return YX(self.y.stop, self.x.stop) 

629 

630 @property 

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

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

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

634 """ 

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

636 

637 @property 

638 def x(self) -> Interval: 

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

640 return self._intervals[-1] 

641 

642 @property 

643 def y(self) -> Interval: 

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

645 return self._intervals[-2] 

646 

647 @property 

648 def absolute(self) -> BoxSliceFactory: 

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

650 syntax and absolute coordinates. 

651 

652 Notes 

653 ----- 

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

655 """ 

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

657 

658 @property 

659 def local(self) -> BoxSliceFactory: 

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

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

662 

663 Notes 

664 ----- 

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

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

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

668 """ 

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

670 

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

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

673 

674 Parameters 

675 ---------- 

676 n 

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

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

679 provided. 

680 step 

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

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

683 ``n``. 

684 

685 Returns 

686 ------- 

687 `XY` [`numpy.ndarray`] 

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

689 

690 See Also 

691 -------- 

692 numpy.meshgrid 

693 """ 

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

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

696 match n: 

697 case int(): 

698 ax = self.x.linspace(n) 

699 ay = self.y.linspace(n) 

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

701 ax = self.x.linspace(nx) 

702 ay = self.y.linspace(ny) 

703 case [nx, ny]: 

704 ax = self.x.linspace(nx) 

705 ay = self.y.linspace(ny) 

706 case None: 

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

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

709 case _: 

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

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

712 

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

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

715 all sides. 

716 """ 

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

718 

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

720 if type(other) is Box: 

721 return self._intervals == other._intervals 

722 return False 

723 

724 def __str__(self) -> str: 

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

726 

727 def __repr__(self) -> str: 

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

729 

730 @overload 

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

732 

733 @overload 

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

735 

736 @overload 

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

738 

739 def contains( 

740 self, 

741 other: Box | None = None, 

742 *, 

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

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

745 ) -> bool | np.ndarray: 

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

747 

748 Parameters 

749 ---------- 

750 other 

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

752 arguments. 

753 y 

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

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

756 x 

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

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

759 

760 Returns 

761 ------- 

762 `bool` | `numpy.ndarray` 

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

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

765 array with their broadcasted shape. 

766 

767 Notes 

768 ----- 

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

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

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

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

773 appear "on the image". 

774 """ 

775 if other is not None: 

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

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

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

779 elif x is None or y is None: 

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

781 else: 

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

783 if not result.shape: 

784 return bool(result) 

785 return result 

786 

787 @overload 

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

789 

790 @overload 

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

792 

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

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

795 ``other``. 

796 

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

798 """ 

799 from ._concrete_bounds import _intersect_box 

800 

801 return _intersect_box(self, other) 

802 

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

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

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

806 

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

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

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

810 correspond to ``other``. 

811 

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

813 """ 

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

815 

816 @property 

817 def bbox(self) -> Box: 

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

819 

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

821 """ 

822 return self 

823 

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

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

826 if len(self._intervals) != 2: 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true

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

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

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

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

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

832 

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

834 return (Box, self._intervals) 

835 

836 @classmethod 

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

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

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

840 

841 def to_legacy(self) -> Any: 

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

843 from lsst.geom import Box2I 

844 

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

846 

847 @classmethod 

848 def __get_pydantic_core_schema__( 

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

850 ) -> pcs.CoreSchema: 

851 from_typed_dict = pcs.chain_schema( 

852 [ 

853 handler(_SerializedBox), 

854 pcs.no_info_plain_validator_function(cls._validate), 

855 ] 

856 ) 

857 return pcs.json_or_python_schema( 

858 json_schema=from_typed_dict, 

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

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

861 ) 

862 

863 @classmethod 

864 def __get_pydantic_json_schema__( 

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

866 ) -> JsonSchemaValue: 

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

868 

869 @classmethod 

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

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

872 

873 def _serialize(self) -> _SerializedBox: 

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

875 

876 def serialize(self) -> Box: 

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

878 

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

880 Pydantic serialization hooks. It exists for compatibility with the 

881 `Bounds` protocol. 

882 """ 

883 return self 

884 

885 def deserialize(self) -> Box: 

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

887 

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

889 Pydantic serialization hooks. It exists for compatibility with the 

890 `Bounds` protocol. 

891 """ 

892 return self 

893 

894 

895class BoxSliceFactory: 

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

897 

898 Notes 

899 ----- 

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

901 `Box` with exactly those bounds:: 

902 

903 assert ( 

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

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

906 ) 

907 

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

909 not allowed. 

910 

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

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

913 box's bounds:: 

914 

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

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

917 

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

919 

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

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

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

923 

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

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

926 """ 

927 

928 def __init__( 

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

930 ): 

931 self._y = y 

932 self._x = x 

933 

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

935 match key: 

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

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

938 case (y, x): 

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

940 case _: 

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

942 

943 

944Box.factory = BoxSliceFactory() 

945 

946 

947class Bounds(Protocol): 

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

949 defined in 2-d pixel coordinates. 

950 

951 Notes 

952 ----- 

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

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

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

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

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

958 intended to handle both of these cases as well. 

959 """ 

960 

961 @property 

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

963 

964 @overload 

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

966 

967 @overload 

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

969 

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

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

972 

973 Parameters 

974 ---------- 

975 x 

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

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

978 y 

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

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

981 

982 Returns 

983 ------- 

984 `bool` | `numpy.ndarray` 

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

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

987 shape. 

988 """ 

989 ... 

990 

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

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

993 ... 

994 

995 def serialize(self) -> SerializableBounds: 

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

997 

998 Notes 

999 ----- 

1000 The returned object must support direct nesting with Pydantic models 

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

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

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

1004 natively serializable. 

1005 """ 

1006 ... 

1007 

1008 

1009class BoundsError(ValueError): 

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

1011 

1012 

1013class NoOverlapError(ValueError): 

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