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

415 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-15 02:03 -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 Notes 

647 ----- 

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

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

650 Pydantic model itself. 

651 """ 

652 

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

654 self._intervals = YX(y, x) 

655 

656 __slots__ = ("_intervals",) 

657 

658 factory: ClassVar[BoxSliceFactory] 

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

660 

661 For example:: 

662 

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

664 """ 

665 

666 @classmethod 

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

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

669 

670 Parameters 

671 ---------- 

672 shape 

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

674 start 

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

676 """ 

677 if start is None: 

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

679 match shape: 

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

681 pass 

682 case [y_size, x_size]: 

683 pass 

684 case _: 

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

686 match start: 

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

688 pass 

689 case [y_start, x_start]: 

690 pass 

691 case _: 

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

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

694 

695 @classmethod 

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

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

698 are contained in the new box. 

699 

700 Parameters 

701 ---------- 

702 x_min 

703 Minimum X value. 

704 x_max 

705 Maximum X value. 

706 y_min 

707 Minimum Y value. 

708 y_max 

709 Maximum Y value. 

710 

711 Notes 

712 ----- 

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

714 pixels whose centers lie within the bounds are included. 

715 """ 

716 return Box.factory[ 

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

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

719 ] 

720 

721 @property 

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

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

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

725 """ 

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

727 

728 @property 

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

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

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

732 """ 

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

734 

735 @property 

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

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

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

739 

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

741 half-exclusive ranges. 

742 """ 

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

744 

745 @property 

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

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

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

749 

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

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

752 """ 

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

754 

755 @property 

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

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

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

759 """ 

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

761 

762 @property 

763 def x(self) -> Interval: 

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

765 return self._intervals[-1] 

766 

767 @property 

768 def y(self) -> Interval: 

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

770 return self._intervals[-2] 

771 

772 @property 

773 def area(self) -> int: 

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

775 return self.x.size * self.y.size 

776 

777 @property 

778 def absolute(self) -> BoxSliceFactory: 

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

780 syntax and absolute coordinates. 

781 

782 Notes 

783 ----- 

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

785 """ 

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

787 

788 @property 

789 def local(self) -> BoxSliceFactory: 

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

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

792 

793 Notes 

794 ----- 

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

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

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

798 """ 

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

800 

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

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

803 

804 Parameters 

805 ---------- 

806 n 

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

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

809 provided. 

810 step 

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

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

813 ``n``. 

814 

815 Returns 

816 ------- 

817 `XY` [`numpy.ndarray`] 

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

819 

820 See Also 

821 -------- 

822 numpy.meshgrid 

823 """ 

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

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

826 match n: 

827 case int(): 

828 ax = self.x.linspace(n) 

829 ay = self.y.linspace(n) 

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

831 ax = self.x.linspace(nx) 

832 ay = self.y.linspace(ny) 

833 case [nx, ny]: 

834 ax = self.x.linspace(nx) 

835 ay = self.y.linspace(ny) 

836 case None: 

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

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

839 case _: 

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

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

842 

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

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

845 all sides. 

846 

847 Parameters 

848 ---------- 

849 padding 

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

851 """ 

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

853 

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

855 if type(other) is Box: 

856 return self._intervals == other._intervals 

857 return False 

858 

859 def __str__(self) -> str: 

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

861 

862 def __repr__(self) -> str: 

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

864 

865 @overload 

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

867 

868 @overload 

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

870 

871 @overload 

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

873 

874 @overload 

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

876 

877 @overload 

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

879 

880 def contains( 

881 self, 

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

883 /, 

884 *, 

885 y: Any = None, 

886 x: Any = None, 

887 ) -> bool | np.ndarray: 

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

889 

890 Parameters 

891 ---------- 

892 other 

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

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

895 y 

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

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

898 Mutually exclusive with ``other``. 

899 x 

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

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

902 Mutually exclusive with ``other``. 

903 

904 Returns 

905 ------- 

906 `bool` | `numpy.ndarray` 

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

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

909 array with their broadcasted shape. 

910 

911 Notes 

912 ----- 

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

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

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

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

917 appear "on the image". 

918 """ 

919 match other: 

920 case None: 

921 if x is None or y is None: 

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

923 case Box(): 

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

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

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

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

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

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

930 x, y = other.x, other.y 

931 case _: 

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

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

934 if not result.shape: 

935 return bool(result) 

936 return result 

937 

938 @overload 

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

940 

941 @overload 

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

943 

944 @overload 

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

946 

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

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

949 ``other``. 

950 

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

952 

953 Parameters 

954 ---------- 

955 other 

956 Bounds to intersect with this one. 

957 """ 

958 from ._concrete_bounds import _intersect_box 

959 

960 return _intersect_box(self, other) 

961 

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

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

964 

965 Parameters 

966 ---------- 

967 padding 

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

969 """ 

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

971 

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

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

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

975 correspond to ``other``. 

976 

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

978 

979 Parameters 

980 ---------- 

981 other 

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

983 """ 

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

985 

986 @property 

987 def bbox(self) -> Box: 

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

989 

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

991 """ 

992 return self 

993 

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

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

996 

997 Yields 

998 ------ 

999 corner 

1000 Each corner in turn. 

1001 """ 

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

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

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

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

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

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

1008 

1009 def to_polygon(self) -> Polygon: 

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

1011 

1012 Notes 

1013 ----- 

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

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

1016 before using them to form the polygon vertices. 

1017 """ 

1018 from ._polygon import Polygon 

1019 

1020 return Polygon.from_box(self) 

1021 

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

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

1024 

1025 Parameters 

1026 ---------- 

1027 transform 

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

1029 

1030 Notes 

1031 ----- 

1032 This transforms the polygon representation of the box (see 

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

1034 full pixels before transforming them. 

1035 """ 

1036 return self.to_polygon().transform(transform) 

1037 

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

1039 return (Box, self._intervals) 

1040 

1041 @classmethod 

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

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

1044 

1045 Parameters 

1046 ---------- 

1047 legacy 

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

1049 """ 

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

1051 

1052 def to_legacy(self) -> Any: 

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

1054 from lsst.geom import Box2I 

1055 

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

1057 

1058 @classmethod 

1059 def __get_pydantic_core_schema__( 

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

1061 ) -> pcs.CoreSchema: 

1062 from_typed_dict = pcs.chain_schema( 

1063 [ 

1064 handler(_SerializedBox), 

1065 pcs.no_info_plain_validator_function(cls._validate), 

1066 ] 

1067 ) 

1068 return pcs.json_or_python_schema( 

1069 json_schema=from_typed_dict, 

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

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

1072 ) 

1073 

1074 @classmethod 

1075 def __get_pydantic_json_schema__( 

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

1077 ) -> JsonSchemaValue: 

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

1079 

1080 @classmethod 

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

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

1083 

1084 def _serialize(self) -> _SerializedBox: 

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

1086 

1087 def serialize(self) -> Box: 

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

1089 

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

1091 Pydantic serialization hooks. It exists for compatibility with the 

1092 `Bounds` protocol. 

1093 """ 

1094 return self 

1095 

1096 def deserialize(self) -> Box: 

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

1098 

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

1100 Pydantic serialization hooks. It exists for compatibility with the 

1101 `Bounds` protocol. 

1102 """ 

1103 return self 

1104 

1105 

1106class BoxSliceFactory: 

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

1108 

1109 Parameters 

1110 ---------- 

1111 y 

1112 Slice factory used for the y axis. 

1113 x 

1114 Slice factory used for the x axis. 

1115 

1116 Notes 

1117 ----- 

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

1119 `Box` with exactly those bounds:: 

1120 

1121 assert ( 

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

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

1124 ) 

1125 

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

1127 not allowed. 

1128 

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

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

1131 box's bounds:: 

1132 

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

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

1135 

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

1137 

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

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

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

1141 

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

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

1144 """ 

1145 

1146 def __init__( 

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

1148 ) -> None: 

1149 self._y = y 

1150 self._x = x 

1151 

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

1153 match key: 

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

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

1156 case (y, x): 

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

1158 case _: 

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

1160 

1161 

1162Box.factory = BoxSliceFactory() 

1163 

1164 

1165class Bounds(Protocol): 

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

1167 defined in 2-d pixel coordinates. 

1168 

1169 Notes 

1170 ----- 

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

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

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

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

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

1176 intended to handle both of these cases as well. 

1177 """ 

1178 

1179 @property 

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

1181 

1182 @overload 

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

1184 

1185 @overload 

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

1187 

1188 @overload 

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

1190 

1191 @overload 

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

1193 

1194 def contains( 

1195 self, 

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

1197 /, 

1198 *, 

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

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

1201 ) -> bool | np.ndarray: 

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

1203 

1204 Parameters 

1205 ---------- 

1206 point 

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

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

1209 x 

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

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

1212 Mutually exclusive with ``point``. 

1213 y 

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

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

1216 Mutually exclusive with ``point``. 

1217 

1218 Returns 

1219 ------- 

1220 `bool` | `numpy.ndarray` 

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

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

1223 broadcasted shape. 

1224 """ 

1225 ... 

1226 

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

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

1229 

1230 Parameters 

1231 ---------- 

1232 other 

1233 Bounds to intersect with this one. 

1234 """ 

1235 ... 

1236 

1237 def serialize(self) -> BoundsSerializationModel: 

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

1239 

1240 Notes 

1241 ----- 

1242 The returned object must support direct nesting with Pydantic models 

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

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

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

1246 natively serializable. 

1247 """ 

1248 ... 

1249 

1250 

1251class BoundsError(ValueError): 

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

1253 

1254 

1255class NoOverlapError(ValueError): 

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

1257 

1258 

1259if TYPE_CHECKING: 

1260 

1261 def _test_types() -> None: 

1262 interval = Interval(0, 10) 

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

1264 arr = np.zeros(3) 

1265 

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

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

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

1269 

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

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

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

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

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

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

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

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

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

1279 

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

1281 bounds: Bounds = box 

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

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

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

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

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

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

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