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

419 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-16 15:24 -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 assert_type, 

37 final, 

38 overload, 

39) 

40 

41import numpy as np 

42import numpy.typing as npt 

43import pydantic 

44import pydantic_core.core_schema as pcs 

45from pydantic.json_schema import JsonSchemaValue 

46 

47from .utils import round_half_down, round_half_up 

48 

49if TYPE_CHECKING: 

50 from ._concrete_bounds import BoundsSerializationModel 

51 from ._polygon import Polygon, Region 

52 from ._transforms import Transform 

53 

54 try: 

55 from lsst.geom import Extent2D as LegacyExtent2D 

56 from lsst.geom import Extent2I as LegacyExtent2I 

57 from lsst.geom import Point2D as LegacyPoint2D 

58 from lsst.geom import Point2I as LegacyPoint2I 

59 except ImportError: 

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

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

62 type LegacyExtent2D = Any # type: ignore[no-redef] 

63 type LegacyPoint2D = Any # type: ignore[no-redef] 

64 

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

66# autodoc-typehints plugin. 

67T = TypeVar("T") 

68 

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

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

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

72 

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

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

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

76# upstream of this package in the future. 

77 

78 

79class YX[T](NamedTuple): 

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

81 

82 Notes 

83 ----- 

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

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

86 arithmetic operations behave as they would on a 

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

88 concatenates). 

89 

90 See Also 

91 -------- 

92 XY 

93 """ 

94 

95 y: T 

96 """The y / row object.""" 

97 

98 x: T 

99 """The x / column object.""" 

100 

101 @property 

102 def xy(self) -> XY: 

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

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

105 

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

107 """Apply a function to both objects. 

108 

109 Parameters 

110 ---------- 

111 func 

112 Callable applied to each of the two objects in turn. 

113 """ 

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

115 

116 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

118 from lsst.geom import Extent2I as LegacyExtent2I 

119 

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

121 

122 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

124 from lsst.geom import Point2I as LegacyPoint2I 

125 

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

127 

128 def to_legacy_float_extent(self) -> LegacyExtent2D: 

129 """Convert to a legacy `lsst.geom.Extent2D` object.""" 

130 from lsst.geom import Extent2D as LegacyExtent2D 

131 

132 return LegacyExtent2D(self.x, self.y) 

133 

134 def to_legacy_float_point(self) -> LegacyPoint2D: 

135 """Convert to a legacy `lsst.geom.Point2D` object.""" 

136 from lsst.geom import Point2D as LegacyPoint2D 

137 

138 return LegacyPoint2D(self.x, self.y) 

139 

140 

141class XY[T](NamedTuple): 

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

143 

144 Notes 

145 ----- 

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

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

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

149 vector (e.g. adding concatenates). 

150 

151 See Also 

152 -------- 

153 YX 

154 """ 

155 

156 x: T 

157 """The x / column object.""" 

158 

159 y: T 

160 """The y / row object.""" 

161 

162 @property 

163 def yx(self) -> YX: 

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

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

166 

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

168 """Apply a function to both objects. 

169 

170 Parameters 

171 ---------- 

172 func 

173 Callable applied to each of the two objects in turn. 

174 """ 

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

176 

177 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

179 from lsst.geom import Extent2I as LegacyExtent2I 

180 

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

182 

183 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

185 from lsst.geom import Point2I as LegacyPoint2I 

186 

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

188 

189 def to_legacy_float_extent(self) -> LegacyExtent2D: 

190 """Convert to a legacy `lsst.geom.Extent2D` object.""" 

191 from lsst.geom import Extent2D as LegacyExtent2D 

192 

193 return LegacyExtent2D(self.x, self.y) 

194 

195 def to_legacy_float_point(self) -> LegacyPoint2D: 

196 """Convert to a legacy `lsst.geom.Point2D` object.""" 

197 from lsst.geom import Point2D as LegacyPoint2D 

198 

199 return LegacyPoint2D(self.x, self.y) 

200 

201 

202class _SerializedInterval(TypedDict): 

203 start: int 

204 stop: int 

205 

206 

207@final 

208class Interval: 

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

210 

211 Parameters 

212 ---------- 

213 start 

214 Inclusive minimum point in the interval. 

215 stop 

216 One past the maximum point in the interval. 

217 

218 Notes 

219 ----- 

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

221 

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

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

224 Pydantic model itself. 

225 """ 

226 

227 def __init__(self, start: int, stop: int) -> None: 

228 # Coerce to be defensive against numpy int scalars. 

229 self._start = int(start) 

230 self._stop = int(stop) 

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

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

233 

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

235 

236 factory: ClassVar[IntervalSliceFactory] 

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

238 

239 For example:: 

240 

241 interval = Interval.factory[2:5] 

242 """ 

243 

244 @classmethod 

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

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

247 intervals. 

248 

249 Parameters 

250 ---------- 

251 first 

252 First point or interval to include in the hull. 

253 *args 

254 Additional points and/or intervals to include in the hull. 

255 """ 

256 if type(first) is Interval: 

257 rmin = first.min 

258 rmax = first.max 

259 else: 

260 rmin = rmax = first 

261 for arg in args: 

262 if type(arg) is Interval: 

263 rmin = min(rmin, arg.min) 

264 rmax = max(rmax, arg.max) 

265 else: 

266 rmin = min(rmin, arg) 

267 rmax = max(rmax, arg) 

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

269 

270 @classmethod 

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

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

273 

274 Parameters 

275 ---------- 

276 size 

277 Number of points in the interval. 

278 start 

279 Inclusive minimum point in the interval. 

280 """ 

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

282 

283 @property 

284 def min(self) -> int: 

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

286 return self.start 

287 

288 @property 

289 def max(self) -> int: 

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

291 return self.stop - 1 

292 

293 @property 

294 def start(self) -> int: 

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

296 return self._start 

297 

298 @property 

299 def stop(self) -> int: 

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

301 return self._stop 

302 

303 @property 

304 def size(self) -> int: 

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

306 return self.stop - self.start 

307 

308 @property 

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

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

311 (`__builtins__.range`). 

312 """ 

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

314 

315 @property 

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

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

318 

319 Array values are integers. 

320 """ 

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

322 

323 @property 

324 def absolute(self) -> IntervalSliceFactory: 

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

326 syntax and absolute coordinates. 

327 

328 Notes 

329 ----- 

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

331 """ 

332 return IntervalSliceFactory(self, is_local=False) 

333 

334 @property 

335 def local(self) -> IntervalSliceFactory: 

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

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

338 

339 Notes 

340 ----- 

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

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

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

344 """ 

345 return IntervalSliceFactory(self, is_local=True) 

346 

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

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

349 

350 Parameters 

351 ---------- 

352 n 

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

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

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

356 step 

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

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

359 

360 Returns 

361 ------- 

362 numpy.ndarray 

363 Array of `float` values. 

364 

365 See Also 

366 -------- 

367 numpy.linspace 

368 """ 

369 if n is None: 

370 if step is None: 

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

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

373 elif step is not None: 

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

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

376 

377 @property 

378 def center(self) -> float: 

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

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

381 

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

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

384 either side. 

385 

386 Parameters 

387 ---------- 

388 padding 

389 Number of points to add to each side of the interval. 

390 """ 

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

392 

393 def __str__(self) -> str: 

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

395 

396 def __repr__(self) -> str: 

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

398 

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

400 if type(other) is Interval: 

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

402 return False 

403 

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

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

406 

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

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

409 

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

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

412 

413 @overload 

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

415 

416 @overload 

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

418 

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

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

421 points. 

422 

423 Parameters 

424 ---------- 

425 other 

426 Another interval to compare to, or one or more position values as 

427 a scalar or any array-like. 

428 

429 Returns 

430 ------- 

431 `bool` | `numpy.ndarray` 

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

433 array-like was passed, an array with the broadcasted shape. 

434 

435 Notes 

436 ----- 

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

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

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

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

441 appear "on the image". 

442 """ 

443 if isinstance(other, Interval): 

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

445 else: 

446 other = np.asarray(other) 

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

448 if not result.shape: 

449 return bool(result) 

450 return result 

451 

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

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

454 

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

456 raised. 

457 

458 Parameters 

459 ---------- 

460 other 

461 Interval to intersect with this one. 

462 """ 

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

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

465 if new_start < new_stop: 

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

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

468 

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

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

471 

472 Parameters 

473 ---------- 

474 padding 

475 Number of points to add to each side of the interval. 

476 """ 

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

478 

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

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

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

482 

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

484 

485 Parameters 

486 ---------- 

487 other 

488 Interval whose values correspond to the container being sliced. 

489 """ 

490 if not other.contains(self): 

491 raise IndexError( 

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

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

494 ) 

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

496 

497 @classmethod 

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

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

500 

501 Parameters 

502 ---------- 

503 legacy 

504 Legacy `lsst.geom.IntervalI` instance to convert. 

505 """ 

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

507 

508 def to_legacy(self) -> Any: 

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

510 from lsst.geom import IntervalI 

511 

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

513 

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

515 return ( 

516 Interval, 

517 ( 

518 self._start, 

519 self._stop, 

520 ), 

521 ) 

522 

523 @classmethod 

524 def __get_pydantic_core_schema__( 

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

526 ) -> pcs.CoreSchema: 

527 from_typed_dict = pcs.chain_schema( 

528 [ 

529 handler(_SerializedInterval), 

530 pcs.no_info_plain_validator_function(cls._validate), 

531 ] 

532 ) 

533 return pcs.json_or_python_schema( 

534 json_schema=from_typed_dict, 

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

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

537 ) 

538 

539 @classmethod 

540 def __get_pydantic_json_schema__( 

541 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler 

542 ) -> JsonSchemaValue: 

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

544 

545 @classmethod 

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

547 return cls(**data) 

548 

549 def _serialize(self) -> _SerializedInterval: 

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

551 

552 

553class IntervalSliceFactory: 

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

555 

556 Parameters 

557 ---------- 

558 parent 

559 Interval that constructed intervals must be contained by, or `None` 

560 to allow any bounds. 

561 is_local 

562 Whether slice bounds are interpreted relative to the start of 

563 ``parent`` rather than as absolute coordinates. 

564 

565 Notes 

566 ----- 

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

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

569 

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

571 

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

573 not allowed. 

574 

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

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

577 interval's bounds:: 

578 

579 parent = Interval.factory[3:6] 

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

581 

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

583 

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

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

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

587 

588 parent = Interval.factory[3:6] 

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

590 

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

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

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

594 """ 

595 

596 def __init__(self, parent: Interval | None = None, is_local: bool = False) -> None: 

597 self._parent = parent 

598 self._is_local = is_local 

599 

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

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

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

603 if self._is_local: 

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

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

606 start += self._parent.start 

607 stop += self._parent.start 

608 else: 

609 start = s.start 

610 stop = s.stop 

611 if start is None: 

612 if self._parent is None: 

613 start = 0 

614 else: 

615 start = self._parent.start 

616 if stop is None: 

617 if self._parent is None: 617 ↛ 618line 617 didn't jump to line 618 because the condition on line 617 was never true

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

619 stop = self._parent.stop 

620 if self._parent is not None: 

621 if start < self._parent.start: 

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

623 if stop > self._parent.stop: 

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

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

626 

627 

628Interval.factory = IntervalSliceFactory() 

629 

630 

631class _SerializedBox(TypedDict): 

632 y: _SerializedInterval 

633 x: _SerializedInterval 

634 

635 

636class Box: 

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

638 

639 Parameters 

640 ---------- 

641 y 

642 Interval for the y dimension. 

643 x 

644 Interval for the x dimension. 

645 

646 Raises 

647 ------ 

648 TypeError 

649 Raised if ``y`` or ``x`` is not an `Interval`. 

650 

651 Notes 

652 ----- 

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

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

655 Pydantic model itself. 

656 """ 

657 

658 def __init__(self, y: Interval, x: Interval) -> None: 

659 match (y, x): 

660 case (Interval(), Interval()): 

661 self._intervals = YX(y, x) 

662 case _: 

663 raise TypeError( 

664 f"Box arguments must be Interval instances, ordered (y, x); got ({y!r}, {x!r}). " 

665 "See the Box.factory slice syntax and the from_* class methods for other ways " 

666 "to construct a Box." 

667 ) 

668 

669 __slots__ = ("_intervals",) 

670 

671 factory: ClassVar[BoxSliceFactory] 

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

673 

674 For example:: 

675 

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

677 """ 

678 

679 @classmethod 

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

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

682 

683 Parameters 

684 ---------- 

685 shape 

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

687 start 

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

689 """ 

690 if start is None: 

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

692 match shape: 

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

694 pass 

695 case [y_size, x_size]: 

696 pass 

697 case _: 

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

699 match start: 

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

701 pass 

702 case [y_start, x_start]: 

703 pass 

704 case _: 

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

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

707 

708 @classmethod 

709 def from_float_bounds(cls, *, x_min: float, x_max: float, y_min: float, y_max: float) -> Box: 

710 """Construct a box from floating-point bounds ensuring that all the 

711 are contained in the new box. 

712 

713 Parameters 

714 ---------- 

715 x_min 

716 Minimum X value. 

717 x_max 

718 Maximum X value. 

719 y_min 

720 Minimum Y value. 

721 y_max 

722 Maximum Y value. 

723 

724 Notes 

725 ----- 

726 Uses the same rounding convention as `lsst.images.Region.bbox`, so that 

727 pixels whose centers lie within the bounds are included. 

728 """ 

729 return Box.factory[ 

730 round_half_up(y_min) : round_half_down(y_max) + 1, 

731 round_half_up(x_min) : round_half_down(x_max) + 1, 

732 ] 

733 

734 @property 

735 def min(self) -> YX[int]: 

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

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

738 """ 

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

740 

741 @property 

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

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

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

745 """ 

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

747 

748 @property 

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

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

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

752 

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

754 half-exclusive ranges. 

755 """ 

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

757 

758 @property 

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

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

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

762 

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

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

765 """ 

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

767 

768 @property 

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

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

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

772 """ 

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

774 

775 @property 

776 def x(self) -> Interval: 

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

778 return self._intervals[-1] 

779 

780 @property 

781 def y(self) -> Interval: 

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

783 return self._intervals[-2] 

784 

785 @property 

786 def area(self) -> int: 

787 """The number of pixels in the box (`int`).""" 

788 return self.x.size * self.y.size 

789 

790 @property 

791 def absolute(self) -> BoxSliceFactory: 

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

793 syntax and absolute coordinates. 

794 

795 Notes 

796 ----- 

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

798 """ 

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

800 

801 @property 

802 def local(self) -> BoxSliceFactory: 

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

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

805 

806 Notes 

807 ----- 

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

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

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

811 """ 

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

813 

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

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

816 

817 Parameters 

818 ---------- 

819 n 

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

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

822 provided. 

823 step 

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

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

826 ``n``. 

827 

828 Returns 

829 ------- 

830 `XY` [`numpy.ndarray`] 

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

832 

833 See Also 

834 -------- 

835 numpy.meshgrid 

836 """ 

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

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

839 match n: 

840 case int(): 

841 ax = self.x.linspace(n) 

842 ay = self.y.linspace(n) 

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

844 ax = self.x.linspace(nx) 

845 ay = self.y.linspace(ny) 

846 case [nx, ny]: 

847 ax = self.x.linspace(nx) 

848 ay = self.y.linspace(ny) 

849 case None: 

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

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

852 case _: 

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

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

855 

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

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

858 all sides. 

859 

860 Parameters 

861 ---------- 

862 padding 

863 Number of pixels to expand the box by on every side. 

864 """ 

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

866 

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

868 if type(other) is Box: 

869 return self._intervals == other._intervals 

870 return False 

871 

872 def __str__(self) -> str: 

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

874 

875 def __repr__(self) -> str: 

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

877 

878 @overload 

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

880 

881 @overload 

882 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 882 ↛ exitline 882 didn't return from function 'contains' because

883 

884 @overload 

885 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 885 ↛ exitline 885 didn't return from function 'contains' because

886 

887 @overload 

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

889 

890 @overload 

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

892 

893 def contains( 

894 self, 

895 other: Box | XY[Any] | YX[Any] | None = None, 

896 /, 

897 *, 

898 y: Any = None, 

899 x: Any = None, 

900 ) -> bool | np.ndarray: 

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

902 

903 Parameters 

904 ---------- 

905 other 

906 Another box, or an `XY` or `YX` coordinate pair, to compare to. 

907 Mutually exclusive with ``x`` and ``y``. 

908 y 

909 One or more Y coordinates to test for containment, as a scalar or 

910 any array-like. Results are broadcast against ``x``. 

911 Mutually exclusive with ``other``. 

912 x 

913 One or more X coordinates to test for containment, as a scalar or 

914 any array-like. Results are broadcast against ``y``. 

915 Mutually exclusive with ``other``. 

916 

917 Returns 

918 ------- 

919 `bool` | `numpy.ndarray` 

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

921 single `bool` value. If ``x`` and ``y`` are array-like, a boolean 

922 array with their broadcasted shape. 

923 

924 Notes 

925 ----- 

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

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

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

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

930 appear "on the image". 

931 """ 

932 match other: 

933 case None: 

934 if x is None or y is None: 

935 raise TypeError("Pass a box, a point, or both x= and y= to 'Box.contains'.") 

936 case Box(): 

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

938 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.") 

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

940 case XY() | YX(): 940 ↛ 944line 940 didn't jump to line 944 because the pattern on line 940 always matched

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

942 raise TypeError("'Box.contains' other argument is mutually exclusive with x= and y=.") 

943 x, y = other.x, other.y 

944 case _: 

945 raise TypeError(f"Unexpected positional argument type: {type(other)!r}.") 

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

947 if not result.shape: 

948 return bool(result) 

949 return result 

950 

951 @overload 

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

953 

954 @overload 

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

956 

957 @overload 

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

959 

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

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

962 ``other``. 

963 

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

965 

966 Parameters 

967 ---------- 

968 other 

969 Bounds to intersect with this one. 

970 """ 

971 from ._concrete_bounds import _intersect_box 

972 

973 return _intersect_box(self, other) 

974 

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

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

977 

978 Parameters 

979 ---------- 

980 padding 

981 Number of pixels to pad the box by on every side. 

982 """ 

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

984 

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

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

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

988 correspond to ``other``. 

989 

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

991 

992 Parameters 

993 ---------- 

994 other 

995 Box that the sliced container's items correspond to. 

996 """ 

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

998 

999 @property 

1000 def bbox(self) -> Box: 

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

1002 

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

1004 """ 

1005 return self 

1006 

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

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

1009 

1010 Yields 

1011 ------ 

1012 corner 

1013 Each corner in turn. 

1014 """ 

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

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

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

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

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

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

1021 

1022 def to_polygon(self) -> Polygon: 

1023 """Convert the box to a polygon with floating-point vertices. 

1024 

1025 Notes 

1026 ----- 

1027 Because the integer min and max coordinates of a box are 

1028 interpreted as pixel centers, these are expanded by 0.5 on all sides 

1029 before using them to form the polygon vertices. 

1030 """ 

1031 from ._polygon import Polygon 

1032 

1033 return Polygon.from_box(self) 

1034 

1035 def transform(self, transform: Transform[Any, Any]) -> Polygon: 

1036 """Apply a coordinate transform to the box, returning a polygon. 

1037 

1038 Parameters 

1039 ---------- 

1040 transform 

1041 Coordinate transform to apply (in the forward direction). 

1042 

1043 Notes 

1044 ----- 

1045 This transforms the polygon representation of the box (see 

1046 `to_polygon`), which expands its vertices by 0.5 on all sides to cover 

1047 full pixels before transforming them. 

1048 """ 

1049 return self.to_polygon().transform(transform) 

1050 

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

1052 return (Box, self._intervals) 

1053 

1054 @classmethod 

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

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

1057 

1058 Parameters 

1059 ---------- 

1060 legacy 

1061 Legacy `lsst.geom.Box2I` to convert. 

1062 """ 

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

1064 

1065 def to_legacy(self) -> Any: 

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

1067 from lsst.geom import Box2I 

1068 

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

1070 

1071 @classmethod 

1072 def __get_pydantic_core_schema__( 

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

1074 ) -> pcs.CoreSchema: 

1075 from_typed_dict = pcs.chain_schema( 

1076 [ 

1077 handler(_SerializedBox), 

1078 pcs.no_info_plain_validator_function(cls._validate), 

1079 ] 

1080 ) 

1081 return pcs.json_or_python_schema( 

1082 json_schema=from_typed_dict, 

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

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

1085 ) 

1086 

1087 @classmethod 

1088 def __get_pydantic_json_schema__( 

1089 cls, schema: pcs.CoreSchema, handler: pydantic.GetJsonSchemaHandler 

1090 ) -> JsonSchemaValue: 

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

1092 

1093 @classmethod 

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

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

1096 

1097 def _serialize(self) -> _SerializedBox: 

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

1099 

1100 def serialize(self) -> Box: 

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

1102 

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

1104 Pydantic serialization hooks. It exists for compatibility with the 

1105 `Bounds` protocol. 

1106 """ 

1107 return self 

1108 

1109 def deserialize(self) -> Box: 

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

1111 

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

1113 Pydantic serialization hooks. It exists for compatibility with the 

1114 `Bounds` protocol. 

1115 """ 

1116 return self 

1117 

1118 

1119class BoxSliceFactory: 

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

1121 

1122 Parameters 

1123 ---------- 

1124 y 

1125 Slice factory used for the y axis. 

1126 x 

1127 Slice factory used for the x axis. 

1128 

1129 Notes 

1130 ----- 

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

1132 `Box` with exactly those bounds:: 

1133 

1134 assert ( 

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

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

1137 ) 

1138 

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

1140 not allowed. 

1141 

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

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

1144 box's bounds:: 

1145 

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

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

1148 

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

1150 

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

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

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

1154 

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

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

1157 """ 

1158 

1159 def __init__( 

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

1161 ) -> None: 

1162 self._y = y 

1163 self._x = x 

1164 

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

1166 match key: 

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

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

1169 case (y, x): 

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

1171 case _: 

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

1173 

1174 

1175Box.factory = BoxSliceFactory() 

1176 

1177 

1178class Bounds(Protocol): 

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

1180 defined in 2-d pixel coordinates. 

1181 

1182 Notes 

1183 ----- 

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

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

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

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

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

1189 intended to handle both of these cases as well. 

1190 """ 

1191 

1192 @property 

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

1194 

1195 @overload 

1196 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 1196 ↛ exitline 1196 didn't return from function 'contains' because

1197 

1198 @overload 

1199 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 1199 ↛ exitline 1199 didn't return from function 'contains' because

1200 

1201 @overload 

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

1203 

1204 @overload 

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

1206 

1207 def contains( 

1208 self, 

1209 point: XY[Any] | YX[Any] | None = None, 

1210 /, 

1211 *, 

1212 x: int | float | npt.ArrayLike | None = None, 

1213 y: int | float | npt.ArrayLike | None = None, 

1214 ) -> bool | np.ndarray: 

1215 """Test whether one or more points fall within these bounds. 

1216 

1217 Parameters 

1218 ---------- 

1219 point 

1220 An `XY` or `YX` coordinate pair to test for containment. 

1221 Mutually exclusive with ``x`` and ``y``. 

1222 x 

1223 One or more X coordinates to test for containment, as a scalar or 

1224 any array-like. Results are broadcast against ``y``. 

1225 Mutually exclusive with ``point``. 

1226 y 

1227 One or more Y coordinates to test for containment, as a scalar or 

1228 any array-like. Results are broadcast against ``x``. 

1229 Mutually exclusive with ``point``. 

1230 

1231 Returns 

1232 ------- 

1233 `bool` | `numpy.ndarray` 

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

1235 ``x`` and ``y`` are array-like, a boolean array with their 

1236 broadcasted shape. 

1237 """ 

1238 ... 

1239 

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

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

1242 

1243 Parameters 

1244 ---------- 

1245 other 

1246 Bounds to intersect with this one. 

1247 """ 

1248 ... 

1249 

1250 def serialize(self) -> BoundsSerializationModel: 

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

1252 

1253 Notes 

1254 ----- 

1255 The returned object must support direct nesting with Pydantic models 

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

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

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

1259 natively serializable. 

1260 """ 

1261 ... 

1262 

1263 

1264class BoundsError(ValueError): 

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

1266 

1267 

1268class NoOverlapError(ValueError): 

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

1270 

1271 

1272if TYPE_CHECKING: 

1273 

1274 def _test_types() -> None: 

1275 interval = Interval(0, 10) 

1276 box = Box(y=Interval(0, 10), x=Interval(0, 10)) 

1277 arr = np.zeros(3) 

1278 

1279 # Interval.contains: scalar → bool, array-like → np.ndarray 

1280 assert_type(interval.contains(5), bool) 

1281 assert_type(interval.contains(arr), np.ndarray) 

1282 

1283 # Box.contains: Box/XY/YX or scalar x/y → bool; array-like → np.ndarray 

1284 assert_type(box.contains(box), bool) 

1285 assert_type(box.contains(y=3, x=4), bool) 

1286 assert_type(box.contains(y=3.0, x=4.0), bool) 

1287 assert_type(box.contains(y=arr, x=arr), np.ndarray) 

1288 assert_type(box.contains(XY(3, 4)), bool) 

1289 assert_type(box.contains(YX(4, 3)), bool) 

1290 assert_type(box.contains(XY(arr, arr)), np.ndarray) 

1291 assert_type(box.contains(YX(arr, arr)), np.ndarray) 

1292 

1293 # Bounds.contains (Protocol): XY/YX, scalar, array-like 

1294 bounds: Bounds = box 

1295 assert_type(bounds.contains(x=1, y=1), bool) 

1296 assert_type(bounds.contains(x=1.0, y=1.0), bool) 

1297 assert_type(bounds.contains(x=arr, y=arr), np.ndarray) 

1298 assert_type(bounds.contains(XY(1, 1)), bool) 

1299 assert_type(bounds.contains(YX(1, 1)), bool) 

1300 assert_type(bounds.contains(XY(arr, arr)), np.ndarray) 

1301 assert_type(bounds.contains(YX(arr, arr)), np.ndarray)