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

375 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-26 00:46 -0700

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "XY", 

16 "YX", 

17 "Bounds", 

18 "BoundsError", 

19 "Box", 

20 "BoxSliceFactory", 

21 "Interval", 

22 "IntervalSliceFactory", 

23 "NoOverlapError", 

24) 

25 

26import math 

27from collections.abc import Callable, Iterator, Sequence 

28from typing import ( 

29 TYPE_CHECKING, 

30 Any, 

31 ClassVar, 

32 NamedTuple, 

33 Protocol, 

34 TypedDict, 

35 TypeVar, 

36 final, 

37 overload, 

38) 

39 

40import numpy as np 

41import pydantic 

42import pydantic_core.core_schema as pcs 

43from pydantic.json_schema import GetJsonSchemaHandler, JsonSchemaValue 

44 

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 return YX(y=func(self.y), x=func(self.x)) 

101 

102 def to_legacy_extent(self) -> LegacyExtent2I: 

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

104 from lsst.geom import Extent2I as LegacyExtent2I 

105 

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

107 

108 def to_legacy_point(self) -> LegacyPoint2I: 

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

110 from lsst.geom import Point2I as LegacyPoint2I 

111 

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

113 

114 

115class XY[T](NamedTuple): 

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

117 

118 Notes 

119 ----- 

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

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

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

123 vector (e.g. adding concatenates). 

124 

125 See Also 

126 -------- 

127 YX 

128 """ 

129 

130 x: T 

131 """The x / column object.""" 

132 

133 y: T 

134 """The y / row object.""" 

135 

136 @property 

137 def yx(self) -> YX: 

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

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

140 

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

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

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

144 

145 def to_legacy_extent(self) -> LegacyExtent2I: 

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

147 from lsst.geom import Extent2I as LegacyExtent2I 

148 

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

150 

151 def to_legacy_point(self) -> LegacyPoint2I: 

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

153 from lsst.geom import Point2I as LegacyPoint2I 

154 

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

156 

157 

158class _SerializedInterval(TypedDict): 

159 start: int 

160 stop: int 

161 

162 

163@final 

164class Interval: 

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

166 

167 Parameters 

168 ---------- 

169 start 

170 Inclusive minimum point in the interval. 

171 stop 

172 One past the maximum point in the interval. 

173 

174 Notes 

175 ----- 

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

177 

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

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

180 Pydantic model itself. 

181 """ 

182 

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

184 # Coerce to be defensive against numpy int scalars. 

185 self._start = int(start) 

186 self._stop = int(stop) 

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

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

189 

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

191 

192 factory: ClassVar[IntervalSliceFactory] 

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

194 

195 For example:: 

196 

197 interval = Interval.factory[2:5] 

198 """ 

199 

200 @classmethod 

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

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

203 intervals. 

204 """ 

205 if type(first) is Interval: 

206 rmin = first.min 

207 rmax = first.max 

208 else: 

209 rmin = rmax = first 

210 for arg in args: 

211 if type(arg) is Interval: 

212 rmin = min(rmin, arg.min) 

213 rmax = max(rmax, arg.max) 

214 else: 

215 rmin = min(rmin, arg) 

216 rmax = max(rmax, arg) 

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

218 

219 @classmethod 

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

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

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

223 

224 @property 

225 def min(self) -> int: 

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

227 return self.start 

228 

229 @property 

230 def max(self) -> int: 

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

232 return self.stop - 1 

233 

234 @property 

235 def start(self) -> int: 

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

237 return self._start 

238 

239 @property 

240 def stop(self) -> int: 

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

242 return self._stop 

243 

244 @property 

245 def size(self) -> int: 

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

247 return self.stop - self.start 

248 

249 @property 

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

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

252 (`__builtins__.range`). 

253 """ 

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

255 

256 @property 

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

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

259 

260 Array values are integers. 

261 """ 

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

263 

264 @property 

265 def absolute(self) -> IntervalSliceFactory: 

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

267 syntax and absolute coordinates. 

268 

269 Notes 

270 ----- 

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

272 """ 

273 return IntervalSliceFactory(self, is_local=False) 

274 

275 @property 

276 def local(self) -> IntervalSliceFactory: 

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

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

279 

280 Notes 

281 ----- 

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

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

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

285 """ 

286 return IntervalSliceFactory(self, is_local=True) 

287 

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

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

290 

291 Parameters 

292 ---------- 

293 n 

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

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

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

297 step 

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

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

300 

301 Returns 

302 ------- 

303 numpy.ndarray 

304 Array of `float` values. 

305 

306 See Also 

307 -------- 

308 numpy.linspace 

309 """ 

310 if n is None: 

311 if step is None: 

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

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

314 elif step is not None: 

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

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

317 

318 @property 

319 def center(self) -> float: 

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

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

322 

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

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

325 either side. 

326 """ 

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

328 

329 def __str__(self) -> str: 

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

331 

332 def __repr__(self) -> str: 

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

334 

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

336 if type(other) is Interval: 

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

338 return False 

339 

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

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

342 

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

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

345 

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

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

348 

349 @overload 

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

351 

352 @overload 

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

354 

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

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

357 points. 

358 

359 Parameters 

360 ---------- 

361 other 

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

363 

364 Returns 

365 ------- 

366 `bool` | `numpy.ndarray` 

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

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

369 

370 Notes 

371 ----- 

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

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

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

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

376 appear "on the image". 

377 """ 

378 if isinstance(other, Interval): 

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

380 else: 

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

382 if not result.shape: 

383 return bool(result) 

384 return result 

385 

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

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

388 

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

390 raised. 

391 """ 

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

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

394 if new_start < new_stop: 

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

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

397 

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

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

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

401 

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

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

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

405 

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

407 """ 

408 if not other.contains(self): 

409 raise IndexError( 

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

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

412 ) 

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

414 

415 @classmethod 

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

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

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

419 

420 def to_legacy(self) -> Any: 

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

422 from lsst.geom import IntervalI 

423 

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

425 

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

427 return ( 

428 Interval, 

429 ( 

430 self._start, 

431 self._stop, 

432 ), 

433 ) 

434 

435 @classmethod 

436 def __get_pydantic_core_schema__( 

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

438 ) -> pcs.CoreSchema: 

439 from_typed_dict = pcs.chain_schema( 

440 [ 

441 handler(_SerializedInterval), 

442 pcs.no_info_plain_validator_function(cls._validate), 

443 ] 

444 ) 

445 return pcs.json_or_python_schema( 

446 json_schema=from_typed_dict, 

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

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

449 ) 

450 

451 @classmethod 

452 def __get_pydantic_json_schema__( 

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

454 ) -> JsonSchemaValue: 

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

456 

457 @classmethod 

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

459 return cls(**data) 

460 

461 def _serialize(self) -> _SerializedInterval: 

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

463 

464 

465class IntervalSliceFactory: 

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

467 

468 Notes 

469 ----- 

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

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

472 

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

474 

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

476 not allowed. 

477 

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

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

480 interval's bounds:: 

481 

482 parent = Interval.factory[3:6] 

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

484 

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

486 

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

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

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

490 

491 parent = Interval.factory[3:6] 

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

493 

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

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

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

497 """ 

498 

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

500 self._parent = parent 

501 self._is_local = is_local 

502 

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

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

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

506 if self._is_local: 

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

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

509 start += self._parent.start 

510 stop += self._parent.start 

511 else: 

512 start = s.start 

513 stop = s.stop 

514 if start is None: 

515 if self._parent is None: 

516 start = 0 

517 else: 

518 start = self._parent.start 

519 if stop is None: 

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

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

522 stop = self._parent.stop 

523 if self._parent is not None: 

524 if start < self._parent.start: 

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

526 if stop > self._parent.stop: 

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

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

529 

530 

531Interval.factory = IntervalSliceFactory() 

532 

533 

534class _SerializedBox(TypedDict): 

535 y: _SerializedInterval 

536 x: _SerializedInterval 

537 

538 

539class Box: 

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

541 

542 Parameters 

543 ---------- 

544 y 

545 Interval for the y dimension. 

546 x 

547 Interval for the x dimension. 

548 

549 Notes 

550 ----- 

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

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

553 Pydantic model itself. 

554 """ 

555 

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

557 self._intervals = YX(y, x) 

558 

559 __slots__ = ("_intervals",) 

560 

561 factory: ClassVar[BoxSliceFactory] 

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

563 

564 For example:: 

565 

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

567 """ 

568 

569 @classmethod 

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

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

572 

573 Parameters 

574 ---------- 

575 shape 

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

577 start 

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

579 """ 

580 if start is None: 

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

582 match shape: 

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

584 pass 

585 case [y_size, x_size]: 

586 pass 

587 case _: 

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

589 match start: 

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

591 pass 

592 case [y_start, x_start]: 

593 pass 

594 case _: 

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

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

597 

598 @classmethod 

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

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

601 are contained in the new box. 

602 

603 Parameters 

604 ---------- 

605 x_min 

606 Minimum X value. 

607 x_max 

608 Maximum X value. 

609 y_min 

610 Minimum Y value. 

611 y_max 

612 Maximum Y value. 

613 

614 Notes 

615 ----- 

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

617 pixels whose centers lie within the bounds are included. 

618 """ 

619 return Box.factory[ 

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

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

622 ] 

623 

624 @property 

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

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

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

628 """ 

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

630 

631 @property 

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

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

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

635 """ 

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

637 

638 @property 

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

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

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

642 

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

644 half-exclusive ranges. 

645 """ 

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

647 

648 @property 

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

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

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

652 

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

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

655 """ 

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

657 

658 @property 

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

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

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

662 """ 

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

664 

665 @property 

666 def x(self) -> Interval: 

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

668 return self._intervals[-1] 

669 

670 @property 

671 def y(self) -> Interval: 

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

673 return self._intervals[-2] 

674 

675 @property 

676 def absolute(self) -> BoxSliceFactory: 

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

678 syntax and absolute coordinates. 

679 

680 Notes 

681 ----- 

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

683 """ 

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

685 

686 @property 

687 def local(self) -> BoxSliceFactory: 

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

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

690 

691 Notes 

692 ----- 

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

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

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

696 """ 

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

698 

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

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

701 

702 Parameters 

703 ---------- 

704 n 

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

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

707 provided. 

708 step 

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

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

711 ``n``. 

712 

713 Returns 

714 ------- 

715 `XY` [`numpy.ndarray`] 

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

717 

718 See Also 

719 -------- 

720 numpy.meshgrid 

721 """ 

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

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

724 match n: 

725 case int(): 

726 ax = self.x.linspace(n) 

727 ay = self.y.linspace(n) 

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

729 ax = self.x.linspace(nx) 

730 ay = self.y.linspace(ny) 

731 case [nx, ny]: 

732 ax = self.x.linspace(nx) 

733 ay = self.y.linspace(ny) 

734 case None: 

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

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

737 case _: 

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

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

740 

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

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

743 all sides. 

744 """ 

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

746 

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

748 if type(other) is Box: 

749 return self._intervals == other._intervals 

750 return False 

751 

752 def __str__(self) -> str: 

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

754 

755 def __repr__(self) -> str: 

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

757 

758 @overload 

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

760 

761 @overload 

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

763 

764 @overload 

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

766 

767 def contains( 

768 self, 

769 other: Box | None = None, 

770 *, 

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

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

773 ) -> bool | np.ndarray: 

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

775 

776 Parameters 

777 ---------- 

778 other 

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

780 arguments. 

781 y 

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

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

784 x 

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

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

787 

788 Returns 

789 ------- 

790 `bool` | `numpy.ndarray` 

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

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

793 array with their broadcasted shape. 

794 

795 Notes 

796 ----- 

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

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

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

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

801 appear "on the image". 

802 """ 

803 if other is not None: 

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

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

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

807 elif x is None or y is None: 

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

809 else: 

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

811 if not result.shape: 

812 return bool(result) 

813 return result 

814 

815 @overload 

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

817 

818 @overload 

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

820 

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

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

823 ``other``. 

824 

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

826 """ 

827 from ._concrete_bounds import _intersect_box 

828 

829 return _intersect_box(self, other) 

830 

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

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

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

834 

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

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

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

838 correspond to ``other``. 

839 

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

841 """ 

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

843 

844 @property 

845 def bbox(self) -> Box: 

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

847 

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

849 """ 

850 return self 

851 

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

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

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

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

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

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

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

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

860 

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

862 return (Box, self._intervals) 

863 

864 @classmethod 

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

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

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

868 

869 def to_legacy(self) -> Any: 

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

871 from lsst.geom import Box2I 

872 

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

874 

875 @classmethod 

876 def __get_pydantic_core_schema__( 

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

878 ) -> pcs.CoreSchema: 

879 from_typed_dict = pcs.chain_schema( 

880 [ 

881 handler(_SerializedBox), 

882 pcs.no_info_plain_validator_function(cls._validate), 

883 ] 

884 ) 

885 return pcs.json_or_python_schema( 

886 json_schema=from_typed_dict, 

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

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

889 ) 

890 

891 @classmethod 

892 def __get_pydantic_json_schema__( 

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

894 ) -> JsonSchemaValue: 

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

896 

897 @classmethod 

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

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

900 

901 def _serialize(self) -> _SerializedBox: 

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

903 

904 def serialize(self) -> Box: 

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

906 

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

908 Pydantic serialization hooks. It exists for compatibility with the 

909 `Bounds` protocol. 

910 """ 

911 return self 

912 

913 def deserialize(self) -> Box: 

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

915 

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

917 Pydantic serialization hooks. It exists for compatibility with the 

918 `Bounds` protocol. 

919 """ 

920 return self 

921 

922 

923class BoxSliceFactory: 

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

925 

926 Notes 

927 ----- 

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

929 `Box` with exactly those bounds:: 

930 

931 assert ( 

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

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

934 ) 

935 

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

937 not allowed. 

938 

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

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

941 box's bounds:: 

942 

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

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

945 

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

947 

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

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

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

951 

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

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

954 """ 

955 

956 def __init__( 

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

958 ) -> None: 

959 self._y = y 

960 self._x = x 

961 

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

963 match key: 

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

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

966 case (y, x): 

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

968 case _: 

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

970 

971 

972Box.factory = BoxSliceFactory() 

973 

974 

975class Bounds(Protocol): 

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

977 defined in 2-d pixel coordinates. 

978 

979 Notes 

980 ----- 

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

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

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

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

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

986 intended to handle both of these cases as well. 

987 """ 

988 

989 @property 

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

991 

992 @overload 

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

994 

995 @overload 

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

997 

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

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

1000 

1001 Parameters 

1002 ---------- 

1003 x 

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

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

1006 y 

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

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

1009 

1010 Returns 

1011 ------- 

1012 `bool` | `numpy.ndarray` 

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

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

1015 shape. 

1016 """ 

1017 ... 

1018 

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

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

1021 ... 

1022 

1023 def serialize(self) -> SerializableBounds: 

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

1025 

1026 Notes 

1027 ----- 

1028 The returned object must support direct nesting with Pydantic models 

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

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

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

1032 natively serializable. 

1033 """ 

1034 ... 

1035 

1036 

1037class BoundsError(ValueError): 

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

1039 

1040 

1041class NoOverlapError(ValueError): 

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