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

375 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-06-27 08:32 +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 

45from .utils import round_half_down, round_half_up 

46 

47if TYPE_CHECKING: 

48 from ._concrete_bounds import SerializableBounds 

49 

50 try: 

51 from lsst.geom import Extent2I as LegacyExtent2I 

52 from lsst.geom import Point2I as LegacyPoint2I 

53 except ImportError: 

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

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

56 

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

58# autodoc-typehints plugin. 

59T = TypeVar("T") 

60 

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

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

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

64 

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

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

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

68# upstream of this package in the future. 

69 

70 

71class YX[T](NamedTuple): 

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

73 

74 Notes 

75 ----- 

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

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

78 arithmetic operations behave as they would on a 

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

80 concatenates). 

81 

82 See Also 

83 -------- 

84 XY 

85 """ 

86 

87 y: T 

88 """The y / row object.""" 

89 

90 x: T 

91 """The x / column object.""" 

92 

93 @property 

94 def xy(self) -> XY: 

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

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

97 

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

99 """Apply a function to both objects. 

100 

101 Parameters 

102 ---------- 

103 func 

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

105 """ 

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

107 

108 def to_legacy_extent(self) -> LegacyExtent2I: 

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

110 from lsst.geom import Extent2I as LegacyExtent2I 

111 

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

113 

114 def to_legacy_point(self) -> LegacyPoint2I: 

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

116 from lsst.geom import Point2I as LegacyPoint2I 

117 

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

119 

120 

121class XY[T](NamedTuple): 

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

123 

124 Notes 

125 ----- 

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

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

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

129 vector (e.g. adding concatenates). 

130 

131 See Also 

132 -------- 

133 YX 

134 """ 

135 

136 x: T 

137 """The x / column object.""" 

138 

139 y: T 

140 """The y / row object.""" 

141 

142 @property 

143 def yx(self) -> YX: 

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

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

146 

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

148 """Apply a function to both objects. 

149 

150 Parameters 

151 ---------- 

152 func 

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

154 """ 

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

156 

157 def to_legacy_extent(self) -> LegacyExtent2I: 

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

159 from lsst.geom import Extent2I as LegacyExtent2I 

160 

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

162 

163 def to_legacy_point(self) -> LegacyPoint2I: 

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

165 from lsst.geom import Point2I as LegacyPoint2I 

166 

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

168 

169 

170class _SerializedInterval(TypedDict): 

171 start: int 

172 stop: int 

173 

174 

175@final 

176class Interval: 

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

178 

179 Parameters 

180 ---------- 

181 start 

182 Inclusive minimum point in the interval. 

183 stop 

184 One past the maximum point in the interval. 

185 

186 Notes 

187 ----- 

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

189 

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

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

192 Pydantic model itself. 

193 """ 

194 

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

196 # Coerce to be defensive against numpy int scalars. 

197 self._start = int(start) 

198 self._stop = int(stop) 

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

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

201 

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

203 

204 factory: ClassVar[IntervalSliceFactory] 

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

206 

207 For example:: 

208 

209 interval = Interval.factory[2:5] 

210 """ 

211 

212 @classmethod 

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

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

215 intervals. 

216 

217 Parameters 

218 ---------- 

219 first 

220 First point or interval to include in the hull. 

221 *args 

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

223 """ 

224 if type(first) is Interval: 

225 rmin = first.min 

226 rmax = first.max 

227 else: 

228 rmin = rmax = first 

229 for arg in args: 

230 if type(arg) is Interval: 

231 rmin = min(rmin, arg.min) 

232 rmax = max(rmax, arg.max) 

233 else: 

234 rmin = min(rmin, arg) 

235 rmax = max(rmax, arg) 

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

237 

238 @classmethod 

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

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

241 

242 Parameters 

243 ---------- 

244 size 

245 Number of points in the interval. 

246 start 

247 Inclusive minimum point in the interval. 

248 """ 

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

250 

251 @property 

252 def min(self) -> int: 

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

254 return self.start 

255 

256 @property 

257 def max(self) -> int: 

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

259 return self.stop - 1 

260 

261 @property 

262 def start(self) -> int: 

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

264 return self._start 

265 

266 @property 

267 def stop(self) -> int: 

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

269 return self._stop 

270 

271 @property 

272 def size(self) -> int: 

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

274 return self.stop - self.start 

275 

276 @property 

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

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

279 (`__builtins__.range`). 

280 """ 

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

282 

283 @property 

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

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

286 

287 Array values are integers. 

288 """ 

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

290 

291 @property 

292 def absolute(self) -> IntervalSliceFactory: 

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

294 syntax and absolute coordinates. 

295 

296 Notes 

297 ----- 

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

299 """ 

300 return IntervalSliceFactory(self, is_local=False) 

301 

302 @property 

303 def local(self) -> IntervalSliceFactory: 

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

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

306 

307 Notes 

308 ----- 

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

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

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

312 """ 

313 return IntervalSliceFactory(self, is_local=True) 

314 

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

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

317 

318 Parameters 

319 ---------- 

320 n 

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

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

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

324 step 

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

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

327 

328 Returns 

329 ------- 

330 numpy.ndarray 

331 Array of `float` values. 

332 

333 See Also 

334 -------- 

335 numpy.linspace 

336 """ 

337 if n is None: 

338 if step is None: 

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

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

341 elif step is not None: 

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

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

344 

345 @property 

346 def center(self) -> float: 

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

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

349 

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

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

352 either side. 

353 

354 Parameters 

355 ---------- 

356 padding 

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

358 """ 

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

360 

361 def __str__(self) -> str: 

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

363 

364 def __repr__(self) -> str: 

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

366 

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

368 if type(other) is Interval: 

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

370 return False 

371 

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

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

374 

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

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

377 

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

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

380 

381 @overload 

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

383 

384 @overload 

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

386 

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

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

389 points. 

390 

391 Parameters 

392 ---------- 

393 other 

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

395 

396 Returns 

397 ------- 

398 `bool` | `numpy.ndarray` 

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

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

401 

402 Notes 

403 ----- 

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

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

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

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

408 appear "on the image". 

409 """ 

410 if isinstance(other, Interval): 

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

412 else: 

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

414 if not result.shape: 

415 return bool(result) 

416 return result 

417 

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

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

420 

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

422 raised. 

423 

424 Parameters 

425 ---------- 

426 other 

427 Interval to intersect with this one. 

428 """ 

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

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

431 if new_start < new_stop: 

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

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

434 

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

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

437 

438 Parameters 

439 ---------- 

440 padding 

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

442 """ 

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

444 

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

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

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

448 

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

450 

451 Parameters 

452 ---------- 

453 other 

454 Interval whose values correspond to the container being sliced. 

455 """ 

456 if not other.contains(self): 

457 raise IndexError( 

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

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

460 ) 

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

462 

463 @classmethod 

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

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

466 

467 Parameters 

468 ---------- 

469 legacy 

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

471 """ 

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

473 

474 def to_legacy(self) -> Any: 

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

476 from lsst.geom import IntervalI 

477 

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

479 

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

481 return ( 

482 Interval, 

483 ( 

484 self._start, 

485 self._stop, 

486 ), 

487 ) 

488 

489 @classmethod 

490 def __get_pydantic_core_schema__( 

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

492 ) -> pcs.CoreSchema: 

493 from_typed_dict = pcs.chain_schema( 

494 [ 

495 handler(_SerializedInterval), 

496 pcs.no_info_plain_validator_function(cls._validate), 

497 ] 

498 ) 

499 return pcs.json_or_python_schema( 

500 json_schema=from_typed_dict, 

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

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

503 ) 

504 

505 @classmethod 

506 def __get_pydantic_json_schema__( 

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

508 ) -> JsonSchemaValue: 

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

510 

511 @classmethod 

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

513 return cls(**data) 

514 

515 def _serialize(self) -> _SerializedInterval: 

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

517 

518 

519class IntervalSliceFactory: 

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

521 

522 Parameters 

523 ---------- 

524 parent 

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

526 to allow any bounds. 

527 is_local 

528 Whether slice bounds are interpreted relative to the start of 

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

530 

531 Notes 

532 ----- 

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

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

535 

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

537 

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

539 not allowed. 

540 

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

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

543 interval's bounds:: 

544 

545 parent = Interval.factory[3:6] 

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

547 

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

549 

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

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

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

553 

554 parent = Interval.factory[3:6] 

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

556 

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

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

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

560 """ 

561 

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

563 self._parent = parent 

564 self._is_local = is_local 

565 

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

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

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

569 if self._is_local: 

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

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

572 start += self._parent.start 

573 stop += self._parent.start 

574 else: 

575 start = s.start 

576 stop = s.stop 

577 if start is None: 

578 if self._parent is None: 

579 start = 0 

580 else: 

581 start = self._parent.start 

582 if stop is None: 

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

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

585 stop = self._parent.stop 

586 if self._parent is not None: 

587 if start < self._parent.start: 

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

589 if stop > self._parent.stop: 

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

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

592 

593 

594Interval.factory = IntervalSliceFactory() 

595 

596 

597class _SerializedBox(TypedDict): 

598 y: _SerializedInterval 

599 x: _SerializedInterval 

600 

601 

602class Box: 

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

604 

605 Parameters 

606 ---------- 

607 y 

608 Interval for the y dimension. 

609 x 

610 Interval for the x dimension. 

611 

612 Notes 

613 ----- 

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

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

616 Pydantic model itself. 

617 """ 

618 

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

620 self._intervals = YX(y, x) 

621 

622 __slots__ = ("_intervals",) 

623 

624 factory: ClassVar[BoxSliceFactory] 

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

626 

627 For example:: 

628 

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

630 """ 

631 

632 @classmethod 

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

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

635 

636 Parameters 

637 ---------- 

638 shape 

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

640 start 

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

642 """ 

643 if start is None: 

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

645 match shape: 

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

647 pass 

648 case [y_size, x_size]: 

649 pass 

650 case _: 

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

652 match start: 

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

654 pass 

655 case [y_start, x_start]: 

656 pass 

657 case _: 

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

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

660 

661 @classmethod 

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

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

664 are contained in the new box. 

665 

666 Parameters 

667 ---------- 

668 x_min 

669 Minimum X value. 

670 x_max 

671 Maximum X value. 

672 y_min 

673 Minimum Y value. 

674 y_max 

675 Maximum Y value. 

676 

677 Notes 

678 ----- 

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

680 pixels whose centers lie within the bounds are included. 

681 """ 

682 return Box.factory[ 

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

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

685 ] 

686 

687 @property 

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

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

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

691 """ 

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

693 

694 @property 

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

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

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

698 """ 

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

700 

701 @property 

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

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

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

705 

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

707 half-exclusive ranges. 

708 """ 

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

710 

711 @property 

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

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

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

715 

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

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

718 """ 

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

720 

721 @property 

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

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

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

725 """ 

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

727 

728 @property 

729 def x(self) -> Interval: 

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

731 return self._intervals[-1] 

732 

733 @property 

734 def y(self) -> Interval: 

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

736 return self._intervals[-2] 

737 

738 @property 

739 def absolute(self) -> BoxSliceFactory: 

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

741 syntax and absolute coordinates. 

742 

743 Notes 

744 ----- 

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

746 """ 

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

748 

749 @property 

750 def local(self) -> BoxSliceFactory: 

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

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

753 

754 Notes 

755 ----- 

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

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

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

759 """ 

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

761 

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

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

764 

765 Parameters 

766 ---------- 

767 n 

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

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

770 provided. 

771 step 

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

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

774 ``n``. 

775 

776 Returns 

777 ------- 

778 `XY` [`numpy.ndarray`] 

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

780 

781 See Also 

782 -------- 

783 numpy.meshgrid 

784 """ 

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

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

787 match n: 

788 case int(): 

789 ax = self.x.linspace(n) 

790 ay = self.y.linspace(n) 

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

792 ax = self.x.linspace(nx) 

793 ay = self.y.linspace(ny) 

794 case [nx, ny]: 

795 ax = self.x.linspace(nx) 

796 ay = self.y.linspace(ny) 

797 case None: 

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

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

800 case _: 

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

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

803 

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

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

806 all sides. 

807 

808 Parameters 

809 ---------- 

810 padding 

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

812 """ 

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

814 

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

816 if type(other) is Box: 

817 return self._intervals == other._intervals 

818 return False 

819 

820 def __str__(self) -> str: 

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

822 

823 def __repr__(self) -> str: 

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

825 

826 @overload 

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

828 

829 @overload 

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

831 

832 @overload 

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

834 

835 def contains( 

836 self, 

837 other: Box | None = None, 

838 *, 

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

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

841 ) -> bool | np.ndarray: 

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

843 

844 Parameters 

845 ---------- 

846 other 

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

848 arguments. 

849 y 

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

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

852 x 

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

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

855 

856 Returns 

857 ------- 

858 `bool` | `numpy.ndarray` 

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

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

861 array with their broadcasted shape. 

862 

863 Notes 

864 ----- 

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

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

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

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

869 appear "on the image". 

870 """ 

871 if other is not None: 

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

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

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

875 elif x is None or y is None: 

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

877 else: 

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

879 if not result.shape: 

880 return bool(result) 

881 return result 

882 

883 @overload 

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

885 

886 @overload 

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

888 

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

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

891 ``other``. 

892 

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

894 

895 Parameters 

896 ---------- 

897 other 

898 Bounds to intersect with this one. 

899 """ 

900 from ._concrete_bounds import _intersect_box 

901 

902 return _intersect_box(self, other) 

903 

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

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

906 

907 Parameters 

908 ---------- 

909 padding 

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

911 """ 

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

913 

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

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

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

917 correspond to ``other``. 

918 

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

920 

921 Parameters 

922 ---------- 

923 other 

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

925 """ 

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

927 

928 @property 

929 def bbox(self) -> Box: 

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

931 

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

933 """ 

934 return self 

935 

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

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

938 

939 Yields 

940 ------ 

941 corner 

942 Each corner in turn. 

943 """ 

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

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

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

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

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

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

950 

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

952 return (Box, self._intervals) 

953 

954 @classmethod 

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

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

957 

958 Parameters 

959 ---------- 

960 legacy 

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

962 """ 

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

964 

965 def to_legacy(self) -> Any: 

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

967 from lsst.geom import Box2I 

968 

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

970 

971 @classmethod 

972 def __get_pydantic_core_schema__( 

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

974 ) -> pcs.CoreSchema: 

975 from_typed_dict = pcs.chain_schema( 

976 [ 

977 handler(_SerializedBox), 

978 pcs.no_info_plain_validator_function(cls._validate), 

979 ] 

980 ) 

981 return pcs.json_or_python_schema( 

982 json_schema=from_typed_dict, 

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

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

985 ) 

986 

987 @classmethod 

988 def __get_pydantic_json_schema__( 

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

990 ) -> JsonSchemaValue: 

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

992 

993 @classmethod 

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

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

996 

997 def _serialize(self) -> _SerializedBox: 

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

999 

1000 def serialize(self) -> Box: 

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

1002 

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

1004 Pydantic serialization hooks. It exists for compatibility with the 

1005 `Bounds` protocol. 

1006 """ 

1007 return self 

1008 

1009 def deserialize(self) -> Box: 

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

1011 

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

1013 Pydantic serialization hooks. It exists for compatibility with the 

1014 `Bounds` protocol. 

1015 """ 

1016 return self 

1017 

1018 

1019class BoxSliceFactory: 

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

1021 

1022 Parameters 

1023 ---------- 

1024 y 

1025 Slice factory used for the y axis. 

1026 x 

1027 Slice factory used for the x axis. 

1028 

1029 Notes 

1030 ----- 

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

1032 `Box` with exactly those bounds:: 

1033 

1034 assert ( 

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

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

1037 ) 

1038 

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

1040 not allowed. 

1041 

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

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

1044 box's bounds:: 

1045 

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

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

1048 

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

1050 

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

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

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

1054 

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

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

1057 """ 

1058 

1059 def __init__( 

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

1061 ) -> None: 

1062 self._y = y 

1063 self._x = x 

1064 

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

1066 match key: 

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

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

1069 case (y, x): 

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

1071 case _: 

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

1073 

1074 

1075Box.factory = BoxSliceFactory() 

1076 

1077 

1078class Bounds(Protocol): 

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

1080 defined in 2-d pixel coordinates. 

1081 

1082 Notes 

1083 ----- 

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

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

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

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

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

1089 intended to handle both of these cases as well. 

1090 """ 

1091 

1092 @property 

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

1094 

1095 @overload 

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

1097 

1098 @overload 

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

1100 

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

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

1103 

1104 Parameters 

1105 ---------- 

1106 x 

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

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

1109 y 

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

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

1112 

1113 Returns 

1114 ------- 

1115 `bool` | `numpy.ndarray` 

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

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

1118 shape. 

1119 """ 

1120 ... 

1121 

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

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

1124 

1125 Parameters 

1126 ---------- 

1127 other 

1128 Bounds to intersect with this one. 

1129 """ 

1130 ... 

1131 

1132 def serialize(self) -> SerializableBounds: 

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

1134 

1135 Notes 

1136 ----- 

1137 The returned object must support direct nesting with Pydantic models 

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

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

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

1141 natively serializable. 

1142 """ 

1143 ... 

1144 

1145 

1146class BoundsError(ValueError): 

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

1148 

1149 

1150class NoOverlapError(ValueError): 

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