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

350 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-27 08:25 +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# This pre-python-3.12 declaration is needed by Sphinx (probably the 

49# autodoc-typehints plugin. 

50T = TypeVar("T") 

51 

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

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

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

55 

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

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

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

59# upstream of this package in the future. 

60 

61 

62class YX[T](NamedTuple): 

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

64 

65 Notes 

66 ----- 

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

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

69 arithmetic operations behave as they would on a 

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

71 concatenates). 

72 

73 See Also 

74 -------- 

75 XY 

76 """ 

77 

78 y: T 

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

80 

81 x: T 

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

83 

84 @property 

85 def xy(self) -> XY: 

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

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

88 

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

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

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

92 

93 

94class XY[T](NamedTuple): 

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

96 

97 Notes 

98 ----- 

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

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

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

102 vector (e.g. adding concatenates). 

103 

104 See Also 

105 -------- 

106 YX 

107 """ 

108 

109 x: T 

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

111 

112 y: T 

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

114 

115 @property 

116 def yx(self) -> YX: 

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

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

119 

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

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

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

123 

124 

125class _SerializedInterval(TypedDict): 

126 start: int 

127 stop: int 

128 

129 

130@final 

131class Interval: 

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

133 

134 Parameters 

135 ---------- 

136 start 

137 Inclusive minimum point in the interval. 

138 stop 

139 One past the maximum point in the interval. 

140 

141 Notes 

142 ----- 

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

144 

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

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

147 Pydantic model itself. 

148 """ 

149 

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

151 # Coerce to be defensive against numpy int scalars. 

152 self._start = int(start) 

153 self._stop = int(stop) 

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

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

156 

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

158 

159 factory: ClassVar[IntervalSliceFactory] 

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

161 

162 For example:: 

163 

164 interval = Interval.factory[2:5] 

165 """ 

166 

167 @classmethod 

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

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

170 intervals. 

171 """ 

172 if type(first) is Interval: 

173 rmin = first.min 

174 rmax = first.max 

175 else: 

176 rmin = rmax = first 

177 for arg in args: 

178 if type(arg) is Interval: 

179 rmin = min(rmin, arg.min) 

180 rmax = max(rmax, arg.max) 

181 else: 

182 rmin = min(rmin, arg) 

183 rmax = max(rmax, arg) 

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

185 

186 @classmethod 

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

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

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

190 

191 @property 

192 def start(self) -> int: 

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

194 return self._start 

195 

196 @property 

197 def stop(self) -> int: 

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

199 return self._stop 

200 

201 @property 

202 def min(self) -> int: 

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

204 return self.start 

205 

206 @property 

207 def max(self) -> int: 

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

209 return self.stop - 1 

210 

211 @property 

212 def size(self) -> int: 

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

214 return self.stop - self.start 

215 

216 @property 

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

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

219 (`__builtins__.range`). 

220 """ 

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

222 

223 @property 

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

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

226 

227 Array values are integers. 

228 """ 

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

230 

231 @property 

232 def absolute(self) -> IntervalSliceFactory: 

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

234 syntax and absolute coordinates. 

235 

236 Notes 

237 ----- 

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

239 """ 

240 return IntervalSliceFactory(self, is_local=False) 

241 

242 @property 

243 def local(self) -> IntervalSliceFactory: 

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

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

246 

247 Notes 

248 ----- 

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

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

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

252 """ 

253 return IntervalSliceFactory(self, is_local=True) 

254 

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

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

257 

258 Parameters 

259 ---------- 

260 n 

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

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

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

264 step 

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

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

267 

268 Returns 

269 ------- 

270 numpy.ndarray 

271 Array of `float` values. 

272 

273 See Also 

274 -------- 

275 numpy.linspace 

276 """ 

277 if n is None: 

278 if step is None: 

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

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

281 elif step is not None: 

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

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

284 

285 @property 

286 def center(self) -> float: 

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

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

289 

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

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

292 either side. 

293 """ 

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

295 

296 def __str__(self) -> str: 

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

298 

299 def __repr__(self) -> str: 

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

301 

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

303 if type(other) is Interval: 

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

305 return False 

306 

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

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

309 

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

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

312 

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

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

315 

316 @overload 

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

318 

319 @overload 

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

321 

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

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

324 points. 

325 

326 Parameters 

327 ---------- 

328 other 

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

330 

331 Returns 

332 ------- 

333 `bool` | `numpy.ndarray` 

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

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

336 

337 Notes 

338 ----- 

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

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

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

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

343 appear "on the image". 

344 """ 

345 if isinstance(other, Interval): 

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

347 else: 

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

349 if not result.shape: 

350 return bool(result) 

351 return result 

352 

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

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

355 

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

357 raised. 

358 """ 

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

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

361 if new_start < new_stop: 

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

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

364 

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

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

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

368 

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

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

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

372 

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

374 """ 

375 if not other.contains(self): 

376 raise IndexError( 

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

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

379 ) 

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

381 

382 @classmethod 

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

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

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

386 

387 def to_legacy(self) -> Any: 

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

389 from lsst.geom import IntervalI 

390 

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

392 

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

394 return ( 

395 Interval, 

396 ( 

397 self._start, 

398 self._stop, 

399 ), 

400 ) 

401 

402 @classmethod 

403 def __get_pydantic_core_schema__( 

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

405 ) -> pcs.CoreSchema: 

406 from_typed_dict = pcs.chain_schema( 

407 [ 

408 handler(_SerializedInterval), 

409 pcs.no_info_plain_validator_function(cls._validate), 

410 ] 

411 ) 

412 return pcs.json_or_python_schema( 

413 json_schema=from_typed_dict, 

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

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

416 ) 

417 

418 @classmethod 

419 def __get_pydantic_json_schema__( 

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

421 ) -> JsonSchemaValue: 

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

423 

424 @classmethod 

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

426 return cls(**data) 

427 

428 def _serialize(self) -> _SerializedInterval: 

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

430 

431 

432class IntervalSliceFactory: 

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

434 

435 Notes 

436 ----- 

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

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

439 

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

441 

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

443 not allowed. 

444 

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

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

447 interval's bounds:: 

448 

449 parent = Interval.factory[3:6] 

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

451 

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

453 

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

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

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

457 

458 parent = Interval.factory[3:6] 

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

460 

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

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

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

464 """ 

465 

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

467 self._parent = parent 

468 self._is_local = is_local 

469 

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

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

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

473 if self._is_local: 

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

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

476 start += self._parent.start 

477 stop += self._parent.start 

478 else: 

479 start = s.start 

480 stop = s.stop 

481 if start is None: 

482 if self._parent is None: 

483 start = 0 

484 else: 

485 start = self._parent.start 

486 if stop is None: 

487 if self._parent is None: 

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

489 stop = self._parent.stop 

490 if self._parent is not None: 

491 if start < self._parent.start: 

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

493 if stop > self._parent.stop: 

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

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

496 

497 

498Interval.factory = IntervalSliceFactory() 

499 

500 

501class _SerializedBox(TypedDict): 

502 y: _SerializedInterval 

503 x: _SerializedInterval 

504 

505 

506class Box: 

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

508 

509 Parameters 

510 ---------- 

511 y 

512 Interval for the y dimension. 

513 x 

514 Interval for the x dimension. 

515 

516 Notes 

517 ----- 

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

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

520 Pydantic model itself. 

521 """ 

522 

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

524 self._intervals = YX(y, x) 

525 

526 __slots__ = ("_intervals",) 

527 

528 factory: ClassVar[BoxSliceFactory] 

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

530 

531 For example:: 

532 

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

534 """ 

535 

536 @classmethod 

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

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

539 

540 Parameters 

541 ---------- 

542 shape 

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

544 start 

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

546 """ 

547 if start is None: 

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

549 match shape: 

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

551 pass 

552 case [y_size, x_size]: 

553 pass 

554 case _: 

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

556 match start: 

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

558 pass 

559 case [y_start, x_start]: 

560 pass 

561 case _: 

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

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

564 

565 @property 

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

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

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

569 """ 

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

571 

572 @property 

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

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

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

576 """ 

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

578 

579 @property 

580 def x(self) -> Interval: 

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

582 return self._intervals[-1] 

583 

584 @property 

585 def y(self) -> Interval: 

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

587 return self._intervals[-2] 

588 

589 @property 

590 def absolute(self) -> BoxSliceFactory: 

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

592 syntax and absolute coordinates. 

593 

594 Notes 

595 ----- 

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

597 """ 

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

599 

600 @property 

601 def local(self) -> BoxSliceFactory: 

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

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

604 

605 Notes 

606 ----- 

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

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

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

610 """ 

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

612 

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

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

615 

616 Parameters 

617 ---------- 

618 n 

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

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

621 provided. 

622 step 

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

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

625 ``n``. 

626 

627 Returns 

628 ------- 

629 `XY` [`numpy.ndarray`] 

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

631 

632 See Also 

633 -------- 

634 numpy.meshgrid 

635 """ 

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

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

638 match n: 

639 case int(): 

640 ax = self.x.linspace(n) 

641 ay = self.y.linspace(n) 

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

643 ax = self.x.linspace(nx) 

644 ay = self.y.linspace(ny) 

645 case [nx, ny]: 

646 ax = self.x.linspace(nx) 

647 ay = self.y.linspace(ny) 

648 case None: 

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

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

651 case _: 

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

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

654 

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

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

657 all sides. 

658 """ 

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

660 

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

662 if type(other) is Box: 

663 return self._intervals == other._intervals 

664 return False 

665 

666 def __str__(self) -> str: 

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

668 

669 def __repr__(self) -> str: 

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

671 

672 @overload 

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

674 

675 @overload 

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

677 

678 @overload 

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

680 

681 def contains( 

682 self, 

683 other: Box | None = None, 

684 *, 

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

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

687 ) -> bool | np.ndarray: 

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

689 

690 Parameters 

691 ---------- 

692 other 

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

694 arguments. 

695 y 

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

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

698 x 

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

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

701 

702 Returns 

703 ------- 

704 `bool` | `numpy.ndarray` 

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

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

707 array with their broadcasted shape. 

708 

709 Notes 

710 ----- 

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

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

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

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

715 appear "on the image". 

716 """ 

717 if other is not None: 

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

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

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

721 elif x is None or y is None: 

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

723 else: 

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

725 if not result.shape: 

726 return bool(result) 

727 return result 

728 

729 @overload 

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

731 

732 @overload 

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

734 

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

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

737 ``other``. 

738 

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

740 """ 

741 from ._concrete_bounds import _intersect_box 

742 

743 return _intersect_box(self, other) 

744 

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

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

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

748 

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

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

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

752 correspond to ``other``. 

753 

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

755 """ 

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

757 

758 @property 

759 def bbox(self) -> Box: 

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

761 

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

763 """ 

764 return self 

765 

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

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

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

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

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

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

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

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

774 

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

776 return (Box, self._intervals) 

777 

778 @classmethod 

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

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

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

782 

783 def to_legacy(self) -> Any: 

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

785 from lsst.geom import Box2I 

786 

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

788 

789 @classmethod 

790 def __get_pydantic_core_schema__( 

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

792 ) -> pcs.CoreSchema: 

793 from_typed_dict = pcs.chain_schema( 

794 [ 

795 handler(_SerializedBox), 

796 pcs.no_info_plain_validator_function(cls._validate), 

797 ] 

798 ) 

799 return pcs.json_or_python_schema( 

800 json_schema=from_typed_dict, 

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

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

803 ) 

804 

805 @classmethod 

806 def __get_pydantic_json_schema__( 

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

808 ) -> JsonSchemaValue: 

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

810 

811 @classmethod 

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

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

814 

815 def _serialize(self) -> _SerializedBox: 

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

817 

818 def serialize(self) -> Box: 

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

820 

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

822 Pydantic serialization hooks. It exists for compatibility with the 

823 `Bounds` protocol. 

824 """ 

825 return self 

826 

827 def deserialize(self) -> Box: 

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

829 

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

831 Pydantic serialization hooks. It exists for compatibility with the 

832 `Bounds` protocol. 

833 """ 

834 return self 

835 

836 

837class BoxSliceFactory: 

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

839 

840 Notes 

841 ----- 

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

843 `Box` with exactly those bounds:: 

844 

845 assert ( 

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

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

848 ) 

849 

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

851 not allowed. 

852 

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

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

855 box's bounds:: 

856 

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

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

859 

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

861 

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

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

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

865 

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

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

868 """ 

869 

870 def __init__( 

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

872 ): 

873 self._y = y 

874 self._x = x 

875 

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

877 match key: 

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

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

880 case (y, x): 

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

882 case _: 

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

884 

885 

886Box.factory = BoxSliceFactory() 

887 

888 

889class Bounds(Protocol): 

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

891 defined in 2-d pixel coordinates. 

892 

893 Notes 

894 ----- 

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

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

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

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

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

900 intended to handle both of these cases as well. 

901 """ 

902 

903 @property 

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

905 

906 @overload 

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

908 

909 @overload 

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

911 

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

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

914 

915 Parameters 

916 ---------- 

917 x 

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

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

920 y 

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

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

923 

924 Returns 

925 ------- 

926 `bool` | `numpy.ndarray` 

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

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

929 shape. 

930 """ 

931 ... 

932 

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

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

935 ... 

936 

937 def serialize(self) -> SerializableBounds: 

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

939 

940 Notes 

941 ----- 

942 The returned object must support direct nesting with Pydantic models 

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

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

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

946 natively serializable. 

947 """ 

948 ... 

949 

950 

951class BoundsError(ValueError): 

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

953 

954 

955class NoOverlapError(ValueError): 

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