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

397 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-04 17:19 +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 JsonSchemaValue 

44 

45from .utils import round_half_down, round_half_up 

46 

47if TYPE_CHECKING: 

48 from ._concrete_bounds import BoundsSerializationModel 

49 from ._polygon import Polygon, Region 

50 from ._transforms import Transform 

51 

52 try: 

53 from lsst.geom import Extent2D as LegacyExtent2D 

54 from lsst.geom import Extent2I as LegacyExtent2I 

55 from lsst.geom import Point2D as LegacyPoint2D 

56 from lsst.geom import Point2I as LegacyPoint2I 

57 except ImportError: 

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

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

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

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

62 

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

64# autodoc-typehints plugin. 

65T = TypeVar("T") 

66 

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

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

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

70 

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

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

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

74# upstream of this package in the future. 

75 

76 

77class YX[T](NamedTuple): 

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

79 

80 Notes 

81 ----- 

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

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

84 arithmetic operations behave as they would on a 

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

86 concatenates). 

87 

88 See Also 

89 -------- 

90 XY 

91 """ 

92 

93 y: T 

94 """The y / row object.""" 

95 

96 x: T 

97 """The x / column object.""" 

98 

99 @property 

100 def xy(self) -> XY: 

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

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

103 

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

105 """Apply a function to both objects. 

106 

107 Parameters 

108 ---------- 

109 func 

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

111 """ 

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

113 

114 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

116 from lsst.geom import Extent2I as LegacyExtent2I 

117 

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

119 

120 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

122 from lsst.geom import Point2I as LegacyPoint2I 

123 

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

125 

126 def to_legacy_float_extent(self) -> LegacyExtent2D: 

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

128 from lsst.geom import Extent2D as LegacyExtent2D 

129 

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

131 

132 def to_legacy_float_point(self) -> LegacyPoint2D: 

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

134 from lsst.geom import Point2D as LegacyPoint2D 

135 

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

137 

138 

139class XY[T](NamedTuple): 

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

141 

142 Notes 

143 ----- 

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

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

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

147 vector (e.g. adding concatenates). 

148 

149 See Also 

150 -------- 

151 YX 

152 """ 

153 

154 x: T 

155 """The x / column object.""" 

156 

157 y: T 

158 """The y / row object.""" 

159 

160 @property 

161 def yx(self) -> YX: 

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

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

164 

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

166 """Apply a function to both objects. 

167 

168 Parameters 

169 ---------- 

170 func 

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

172 """ 

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

174 

175 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

177 from lsst.geom import Extent2I as LegacyExtent2I 

178 

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

180 

181 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

183 from lsst.geom import Point2I as LegacyPoint2I 

184 

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

186 

187 def to_legacy_float_extent(self) -> LegacyExtent2D: 

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

189 from lsst.geom import Extent2D as LegacyExtent2D 

190 

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

192 

193 def to_legacy_float_point(self) -> LegacyPoint2D: 

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

195 from lsst.geom import Point2D as LegacyPoint2D 

196 

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

198 

199 

200class _SerializedInterval(TypedDict): 

201 start: int 

202 stop: int 

203 

204 

205@final 

206class Interval: 

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

208 

209 Parameters 

210 ---------- 

211 start 

212 Inclusive minimum point in the interval. 

213 stop 

214 One past the maximum point in the interval. 

215 

216 Notes 

217 ----- 

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

219 

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

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

222 Pydantic model itself. 

223 """ 

224 

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

226 # Coerce to be defensive against numpy int scalars. 

227 self._start = int(start) 

228 self._stop = int(stop) 

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

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

231 

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

233 

234 factory: ClassVar[IntervalSliceFactory] 

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

236 

237 For example:: 

238 

239 interval = Interval.factory[2:5] 

240 """ 

241 

242 @classmethod 

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

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

245 intervals. 

246 

247 Parameters 

248 ---------- 

249 first 

250 First point or interval to include in the hull. 

251 *args 

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

253 """ 

254 if type(first) is Interval: 

255 rmin = first.min 

256 rmax = first.max 

257 else: 

258 rmin = rmax = first 

259 for arg in args: 

260 if type(arg) is Interval: 

261 rmin = min(rmin, arg.min) 

262 rmax = max(rmax, arg.max) 

263 else: 

264 rmin = min(rmin, arg) 

265 rmax = max(rmax, arg) 

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

267 

268 @classmethod 

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

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

271 

272 Parameters 

273 ---------- 

274 size 

275 Number of points in the interval. 

276 start 

277 Inclusive minimum point in the interval. 

278 """ 

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

280 

281 @property 

282 def min(self) -> int: 

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

284 return self.start 

285 

286 @property 

287 def max(self) -> int: 

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

289 return self.stop - 1 

290 

291 @property 

292 def start(self) -> int: 

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

294 return self._start 

295 

296 @property 

297 def stop(self) -> int: 

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

299 return self._stop 

300 

301 @property 

302 def size(self) -> int: 

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

304 return self.stop - self.start 

305 

306 @property 

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

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

309 (`__builtins__.range`). 

310 """ 

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

312 

313 @property 

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

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

316 

317 Array values are integers. 

318 """ 

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

320 

321 @property 

322 def absolute(self) -> IntervalSliceFactory: 

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

324 syntax and absolute coordinates. 

325 

326 Notes 

327 ----- 

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

329 """ 

330 return IntervalSliceFactory(self, is_local=False) 

331 

332 @property 

333 def local(self) -> IntervalSliceFactory: 

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

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

336 

337 Notes 

338 ----- 

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

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

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

342 """ 

343 return IntervalSliceFactory(self, is_local=True) 

344 

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

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

347 

348 Parameters 

349 ---------- 

350 n 

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

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

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

354 step 

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

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

357 

358 Returns 

359 ------- 

360 numpy.ndarray 

361 Array of `float` values. 

362 

363 See Also 

364 -------- 

365 numpy.linspace 

366 """ 

367 if n is None: 

368 if step is None: 

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

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

371 elif step is not None: 

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

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

374 

375 @property 

376 def center(self) -> float: 

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

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

379 

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

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

382 either side. 

383 

384 Parameters 

385 ---------- 

386 padding 

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

388 """ 

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

390 

391 def __str__(self) -> str: 

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

393 

394 def __repr__(self) -> str: 

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

396 

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

398 if type(other) is Interval: 

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

400 return False 

401 

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

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

404 

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

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

407 

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

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

410 

411 @overload 

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

413 

414 @overload 

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

416 

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

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

419 points. 

420 

421 Parameters 

422 ---------- 

423 other 

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

425 

426 Returns 

427 ------- 

428 `bool` | `numpy.ndarray` 

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

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

431 

432 Notes 

433 ----- 

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

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

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

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

438 appear "on the image". 

439 """ 

440 if isinstance(other, Interval): 

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

442 else: 

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

444 if not result.shape: 

445 return bool(result) 

446 return result 

447 

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

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

450 

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

452 raised. 

453 

454 Parameters 

455 ---------- 

456 other 

457 Interval to intersect with this one. 

458 """ 

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

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

461 if new_start < new_stop: 

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

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

464 

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

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

467 

468 Parameters 

469 ---------- 

470 padding 

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

472 """ 

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

474 

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

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

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

478 

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

480 

481 Parameters 

482 ---------- 

483 other 

484 Interval whose values correspond to the container being sliced. 

485 """ 

486 if not other.contains(self): 

487 raise IndexError( 

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

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

490 ) 

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

492 

493 @classmethod 

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

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

496 

497 Parameters 

498 ---------- 

499 legacy 

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

501 """ 

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

503 

504 def to_legacy(self) -> Any: 

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

506 from lsst.geom import IntervalI 

507 

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

509 

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

511 return ( 

512 Interval, 

513 ( 

514 self._start, 

515 self._stop, 

516 ), 

517 ) 

518 

519 @classmethod 

520 def __get_pydantic_core_schema__( 

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

522 ) -> pcs.CoreSchema: 

523 from_typed_dict = pcs.chain_schema( 

524 [ 

525 handler(_SerializedInterval), 

526 pcs.no_info_plain_validator_function(cls._validate), 

527 ] 

528 ) 

529 return pcs.json_or_python_schema( 

530 json_schema=from_typed_dict, 

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

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

533 ) 

534 

535 @classmethod 

536 def __get_pydantic_json_schema__( 

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

538 ) -> JsonSchemaValue: 

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

540 

541 @classmethod 

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

543 return cls(**data) 

544 

545 def _serialize(self) -> _SerializedInterval: 

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

547 

548 

549class IntervalSliceFactory: 

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

551 

552 Parameters 

553 ---------- 

554 parent 

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

556 to allow any bounds. 

557 is_local 

558 Whether slice bounds are interpreted relative to the start of 

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

560 

561 Notes 

562 ----- 

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

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

565 

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

567 

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

569 not allowed. 

570 

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

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

573 interval's bounds:: 

574 

575 parent = Interval.factory[3:6] 

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

577 

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

579 

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

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

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

583 

584 parent = Interval.factory[3:6] 

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

586 

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

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

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

590 """ 

591 

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

593 self._parent = parent 

594 self._is_local = is_local 

595 

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

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

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

599 if self._is_local: 

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

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

602 start += self._parent.start 

603 stop += self._parent.start 

604 else: 

605 start = s.start 

606 stop = s.stop 

607 if start is None: 

608 if self._parent is None: 

609 start = 0 

610 else: 

611 start = self._parent.start 

612 if stop is None: 

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

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

615 stop = self._parent.stop 

616 if self._parent is not None: 

617 if start < self._parent.start: 

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

619 if stop > self._parent.stop: 

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

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

622 

623 

624Interval.factory = IntervalSliceFactory() 

625 

626 

627class _SerializedBox(TypedDict): 

628 y: _SerializedInterval 

629 x: _SerializedInterval 

630 

631 

632class Box: 

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

634 

635 Parameters 

636 ---------- 

637 y 

638 Interval for the y dimension. 

639 x 

640 Interval for the x dimension. 

641 

642 Notes 

643 ----- 

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

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

646 Pydantic model itself. 

647 """ 

648 

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

650 self._intervals = YX(y, x) 

651 

652 __slots__ = ("_intervals",) 

653 

654 factory: ClassVar[BoxSliceFactory] 

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

656 

657 For example:: 

658 

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

660 """ 

661 

662 @classmethod 

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

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

665 

666 Parameters 

667 ---------- 

668 shape 

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

670 start 

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

672 """ 

673 if start is None: 

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

675 match shape: 

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

677 pass 

678 case [y_size, x_size]: 

679 pass 

680 case _: 

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

682 match start: 

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

684 pass 

685 case [y_start, x_start]: 

686 pass 

687 case _: 

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

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

690 

691 @classmethod 

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

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

694 are contained in the new box. 

695 

696 Parameters 

697 ---------- 

698 x_min 

699 Minimum X value. 

700 x_max 

701 Maximum X value. 

702 y_min 

703 Minimum Y value. 

704 y_max 

705 Maximum Y value. 

706 

707 Notes 

708 ----- 

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

710 pixels whose centers lie within the bounds are included. 

711 """ 

712 return Box.factory[ 

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

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

715 ] 

716 

717 @property 

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

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

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

721 """ 

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

723 

724 @property 

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

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

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

728 """ 

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

730 

731 @property 

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

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

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

735 

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

737 half-exclusive ranges. 

738 """ 

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

740 

741 @property 

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

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

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

745 

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

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

748 """ 

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

750 

751 @property 

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

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

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

755 """ 

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

757 

758 @property 

759 def x(self) -> Interval: 

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

761 return self._intervals[-1] 

762 

763 @property 

764 def y(self) -> Interval: 

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

766 return self._intervals[-2] 

767 

768 @property 

769 def area(self) -> int: 

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

771 return self.x.size * self.y.size 

772 

773 @property 

774 def absolute(self) -> BoxSliceFactory: 

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

776 syntax and absolute coordinates. 

777 

778 Notes 

779 ----- 

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

781 """ 

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

783 

784 @property 

785 def local(self) -> BoxSliceFactory: 

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

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

788 

789 Notes 

790 ----- 

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

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

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

794 """ 

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

796 

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

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

799 

800 Parameters 

801 ---------- 

802 n 

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

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

805 provided. 

806 step 

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

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

809 ``n``. 

810 

811 Returns 

812 ------- 

813 `XY` [`numpy.ndarray`] 

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

815 

816 See Also 

817 -------- 

818 numpy.meshgrid 

819 """ 

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

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

822 match n: 

823 case int(): 

824 ax = self.x.linspace(n) 

825 ay = self.y.linspace(n) 

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

827 ax = self.x.linspace(nx) 

828 ay = self.y.linspace(ny) 

829 case [nx, ny]: 

830 ax = self.x.linspace(nx) 

831 ay = self.y.linspace(ny) 

832 case None: 

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

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

835 case _: 

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

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

838 

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

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

841 all sides. 

842 

843 Parameters 

844 ---------- 

845 padding 

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

847 """ 

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

849 

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

851 if type(other) is Box: 

852 return self._intervals == other._intervals 

853 return False 

854 

855 def __str__(self) -> str: 

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

857 

858 def __repr__(self) -> str: 

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

860 

861 @overload 

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

863 

864 @overload 

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

866 

867 @overload 

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

869 

870 def contains( 

871 self, 

872 other: Box | None = None, 

873 *, 

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

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

876 ) -> bool | np.ndarray: 

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

878 

879 Parameters 

880 ---------- 

881 other 

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

883 arguments. 

884 y 

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

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

887 x 

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

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

890 

891 Returns 

892 ------- 

893 `bool` | `numpy.ndarray` 

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

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

896 array with their broadcasted shape. 

897 

898 Notes 

899 ----- 

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

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

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

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

904 appear "on the image". 

905 """ 

906 if other is not None: 

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

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

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

910 elif x is None or y is None: 

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

912 else: 

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

914 if not result.shape: 

915 return bool(result) 

916 return result 

917 

918 @overload 

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

920 

921 @overload 

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

923 

924 @overload 

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

926 

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

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

929 ``other``. 

930 

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

932 

933 Parameters 

934 ---------- 

935 other 

936 Bounds to intersect with this one. 

937 """ 

938 from ._concrete_bounds import _intersect_box 

939 

940 return _intersect_box(self, other) 

941 

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

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

944 

945 Parameters 

946 ---------- 

947 padding 

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

949 """ 

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

951 

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

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

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

955 correspond to ``other``. 

956 

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

958 

959 Parameters 

960 ---------- 

961 other 

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

963 """ 

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

965 

966 @property 

967 def bbox(self) -> Box: 

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

969 

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

971 """ 

972 return self 

973 

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

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

976 

977 Yields 

978 ------ 

979 corner 

980 Each corner in turn. 

981 """ 

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

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

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

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

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

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

988 

989 def to_polygon(self) -> Polygon: 

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

991 

992 Notes 

993 ----- 

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

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

996 before using them to form the polygon vertices. 

997 """ 

998 from ._polygon import Polygon 

999 

1000 return Polygon.from_box(self) 

1001 

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

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

1004 

1005 Parameters 

1006 ---------- 

1007 transform 

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

1009 

1010 Notes 

1011 ----- 

1012 This transforms the polygon representation of the box (see 

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

1014 full pixels before transforming them. 

1015 """ 

1016 return self.to_polygon().transform(transform) 

1017 

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

1019 return (Box, self._intervals) 

1020 

1021 @classmethod 

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

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

1024 

1025 Parameters 

1026 ---------- 

1027 legacy 

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

1029 """ 

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

1031 

1032 def to_legacy(self) -> Any: 

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

1034 from lsst.geom import Box2I 

1035 

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

1037 

1038 @classmethod 

1039 def __get_pydantic_core_schema__( 

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

1041 ) -> pcs.CoreSchema: 

1042 from_typed_dict = pcs.chain_schema( 

1043 [ 

1044 handler(_SerializedBox), 

1045 pcs.no_info_plain_validator_function(cls._validate), 

1046 ] 

1047 ) 

1048 return pcs.json_or_python_schema( 

1049 json_schema=from_typed_dict, 

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

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

1052 ) 

1053 

1054 @classmethod 

1055 def __get_pydantic_json_schema__( 

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

1057 ) -> JsonSchemaValue: 

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

1059 

1060 @classmethod 

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

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

1063 

1064 def _serialize(self) -> _SerializedBox: 

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

1066 

1067 def serialize(self) -> Box: 

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

1069 

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

1071 Pydantic serialization hooks. It exists for compatibility with the 

1072 `Bounds` protocol. 

1073 """ 

1074 return self 

1075 

1076 def deserialize(self) -> Box: 

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

1078 

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

1080 Pydantic serialization hooks. It exists for compatibility with the 

1081 `Bounds` protocol. 

1082 """ 

1083 return self 

1084 

1085 

1086class BoxSliceFactory: 

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

1088 

1089 Parameters 

1090 ---------- 

1091 y 

1092 Slice factory used for the y axis. 

1093 x 

1094 Slice factory used for the x axis. 

1095 

1096 Notes 

1097 ----- 

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

1099 `Box` with exactly those bounds:: 

1100 

1101 assert ( 

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

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

1104 ) 

1105 

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

1107 not allowed. 

1108 

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

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

1111 box's bounds:: 

1112 

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

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

1115 

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

1117 

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

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

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

1121 

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

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

1124 """ 

1125 

1126 def __init__( 

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

1128 ) -> None: 

1129 self._y = y 

1130 self._x = x 

1131 

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

1133 match key: 

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

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

1136 case (y, x): 

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

1138 case _: 

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

1140 

1141 

1142Box.factory = BoxSliceFactory() 

1143 

1144 

1145class Bounds(Protocol): 

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

1147 defined in 2-d pixel coordinates. 

1148 

1149 Notes 

1150 ----- 

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

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

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

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

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

1156 intended to handle both of these cases as well. 

1157 """ 

1158 

1159 @property 

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

1161 

1162 @overload 

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

1164 

1165 @overload 

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

1167 

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

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

1170 

1171 Parameters 

1172 ---------- 

1173 x 

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

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

1176 y 

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

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

1179 

1180 Returns 

1181 ------- 

1182 `bool` | `numpy.ndarray` 

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

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

1185 shape. 

1186 """ 

1187 ... 

1188 

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

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

1191 

1192 Parameters 

1193 ---------- 

1194 other 

1195 Bounds to intersect with this one. 

1196 """ 

1197 ... 

1198 

1199 def serialize(self) -> BoundsSerializationModel: 

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

1201 

1202 Notes 

1203 ----- 

1204 The returned object must support direct nesting with Pydantic models 

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

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

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

1208 natively serializable. 

1209 """ 

1210 ... 

1211 

1212 

1213class BoundsError(ValueError): 

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

1215 

1216 

1217class NoOverlapError(ValueError): 

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