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

461 statements  

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

25 "SerializableYX", 

26) 

27 

28import math 

29from collections.abc import Callable, Iterator, Sequence 

30from typing import ( 

31 TYPE_CHECKING, 

32 Annotated, 

33 Any, 

34 ClassVar, 

35 NamedTuple, 

36 Protocol, 

37 TypedDict, 

38 TypeVar, 

39 assert_type, 

40 final, 

41 get_args, 

42 overload, 

43) 

44 

45import numpy as np 

46import numpy.typing as npt 

47import pydantic 

48import pydantic_core.core_schema as pcs 

49from pydantic.json_schema import JsonSchemaValue 

50 

51from .utils import round_half_down, round_half_up 

52 

53if TYPE_CHECKING: 

54 from ._concrete_bounds import BoundsSerializationModel 

55 from ._polygon import Polygon, Region 

56 from ._transforms import Transform 

57 

58 try: 

59 from lsst.geom import Extent2D as LegacyExtent2D 

60 from lsst.geom import Extent2I as LegacyExtent2I 

61 from lsst.geom import Point2D as LegacyPoint2D 

62 from lsst.geom import Point2I as LegacyPoint2I 

63 except ImportError: 

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

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

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

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

68 

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

70# autodoc-typehints plugin. 

71T = TypeVar("T") 

72 

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

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

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

76 

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

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

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

80# upstream of this package in the future. 

81 

82 

83class _SerializedYX[T](TypedDict): 

84 y: T 

85 x: T 

86 

87 

88class _SerializedXY[T](TypedDict): 

89 x: T 

90 y: T 

91 

92 

93class YX[T](NamedTuple): 

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

95 

96 Notes 

97 ----- 

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

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

100 arithmetic operations behave as they would on a 

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

102 concatenates). 

103 

104 In Pydantic models `YX` is serialized as a JSON object with ``y`` and 

105 ``x`` keys rather than an array, so the dimension order is explicit in 

106 the serialized form. Validation also accepts a two-element ``[y, x]`` 

107 array, the form emitted before the object form was introduced and still 

108 present in files that cannot be rewritten. Pydantic only invokes the 

109 schema hooks that 

110 provide this for bare ``YX`` annotations, however, because it handles 

111 parameterized named tuples specially; model fields that need a member 

112 type (e.g. ``YX[int]``) must be annotated with `SerializableYX` instead. 

113 

114 See Also 

115 -------- 

116 XY 

117 """ 

118 

119 y: T 

120 """The y / row object.""" 

121 

122 x: T 

123 """The x / column object.""" 

124 

125 @property 

126 def xy(self) -> XY: 

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

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

129 

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

131 """Apply a function to both objects. 

132 

133 Parameters 

134 ---------- 

135 func 

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

137 """ 

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

139 

140 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

142 from lsst.geom import Extent2I as LegacyExtent2I 

143 

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

145 

146 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

148 from lsst.geom import Point2I as LegacyPoint2I 

149 

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

151 

152 def to_legacy_float_extent(self) -> LegacyExtent2D: 

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

154 from lsst.geom import Extent2D as LegacyExtent2D 

155 

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

157 

158 def to_legacy_float_point(self) -> LegacyPoint2D: 

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

160 from lsst.geom import Point2D as LegacyPoint2D 

161 

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

163 

164 @classmethod 

165 def __get_pydantic_core_schema__( 

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

167 ) -> pcs.CoreSchema: 

168 item_type = args[0] if (args := get_args(source_type)) else Any 

169 typed_dict = handler(_SerializedYX[item_type]) # type: ignore[valid-type] 

170 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict) 

171 # Files written before the object form was introduced (including the 

172 # DP2 cell coadds, which cannot be rewritten) store pairs as ``[y, x]`` 

173 # arrays, so validation must accept that form for as long as those 

174 # files remain readable, even if a future schema version stops 

175 # documenting it. This is a strict list schema so that a stray `XY` 

176 # (a tuple) assigned to a `YX` field fails to validate instead of 

177 # being silently transposed. 

178 from_list = pcs.no_info_after_validator_function( 

179 cls._from_list, 

180 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True), 

181 ) 

182 validation = pcs.union_schema([from_typed_dict, from_list]) 

183 return pcs.json_or_python_schema( 

184 json_schema=validation, 

185 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]), 

186 serialization=pcs.plain_serializer_function_ser_schema( 

187 cls._serialize, info_arg=False, return_schema=typed_dict 

188 ), 

189 ) 

190 

191 @classmethod 

192 def _validate(cls, data: _SerializedYX[T]) -> YX[T]: 

193 return cls(**data) 

194 

195 @classmethod 

196 def _from_list(cls, data: list[T]) -> YX[T]: 

197 return cls(*data) 

198 

199 def _serialize(self) -> _SerializedYX[T]: 

200 return {"y": self.y, "x": self.x} 

201 

202 

203class XY[T](NamedTuple): 

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

205 

206 Notes 

207 ----- 

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

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

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

211 vector (e.g. adding concatenates). 

212 

213 In Pydantic models `XY` is serialized as a JSON object with ``x`` and 

214 ``y`` keys rather than an array, so the dimension order is explicit in 

215 the serialized form. Validation also accepts a two-element ``[x, y]`` 

216 array, the form emitted before the object form was introduced and still 

217 present in files that cannot be rewritten. Pydantic only invokes the 

218 schema hooks that 

219 provide this for bare ``XY`` annotations, however, because it handles 

220 parameterized named tuples specially; model fields that need a member 

221 type (e.g. ``XY[float]``) must be annotated with `SerializableXY` instead. 

222 

223 See Also 

224 -------- 

225 YX 

226 """ 

227 

228 x: T 

229 """The x / column object.""" 

230 

231 y: T 

232 """The y / row object.""" 

233 

234 @property 

235 def yx(self) -> YX: 

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

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

238 

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

240 """Apply a function to both objects. 

241 

242 Parameters 

243 ---------- 

244 func 

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

246 """ 

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

248 

249 def to_legacy_int_extent(self) -> LegacyExtent2I: 

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

251 from lsst.geom import Extent2I as LegacyExtent2I 

252 

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

254 

255 def to_legacy_int_point(self) -> LegacyPoint2I: 

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

257 from lsst.geom import Point2I as LegacyPoint2I 

258 

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

260 

261 def to_legacy_float_extent(self) -> LegacyExtent2D: 

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

263 from lsst.geom import Extent2D as LegacyExtent2D 

264 

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

266 

267 def to_legacy_float_point(self) -> LegacyPoint2D: 

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

269 from lsst.geom import Point2D as LegacyPoint2D 

270 

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

272 

273 @classmethod 

274 def __get_pydantic_core_schema__( 

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

276 ) -> pcs.CoreSchema: 

277 item_type = args[0] if (args := get_args(source_type)) else Any 

278 typed_dict = handler(_SerializedXY[item_type]) # type: ignore[valid-type] 

279 from_typed_dict = pcs.no_info_after_validator_function(cls._validate, typed_dict) 

280 # Files written before the object form was introduced store pairs as 

281 # ``[x, y]`` arrays, so validation must accept that form for as long 

282 # as those files remain readable, even if a future schema version 

283 # stops documenting it. This is a strict list schema so that a stray 

284 # `YX` (a tuple) assigned to an `XY` field fails to validate instead 

285 # of being silently transposed. 

286 from_list = pcs.no_info_after_validator_function( 

287 cls._from_list, 

288 pcs.list_schema(handler(item_type), min_length=2, max_length=2, strict=True), 

289 ) 

290 validation = pcs.union_schema([from_typed_dict, from_list]) 

291 return pcs.json_or_python_schema( 

292 json_schema=validation, 

293 python_schema=pcs.union_schema([pcs.is_instance_schema(cls), validation]), 

294 serialization=pcs.plain_serializer_function_ser_schema( 

295 cls._serialize, info_arg=False, return_schema=typed_dict 

296 ), 

297 ) 

298 

299 @classmethod 

300 def _validate(cls, data: _SerializedXY[T]) -> XY[T]: 

301 return cls(**data) 

302 

303 @classmethod 

304 def _from_list(cls, data: list[T]) -> XY[T]: 

305 return cls(*data) 

306 

307 def _serialize(self) -> _SerializedXY[T]: 

308 return {"x": self.x, "y": self.y} 

309 

310 

311SerializableXY = Annotated[XY[T], pydantic.GetPydanticSchema(XY.__get_pydantic_core_schema__)] 

312"""An `XY` annotation for Pydantic model fields that serializes as a JSON 

313object with ``x`` and ``y`` keys (`XY` alone serializes as an array when 

314parameterized, because Pydantic handles named tuples specially). 

315""" 

316 

317SerializableYX = Annotated[YX[T], pydantic.GetPydanticSchema(YX.__get_pydantic_core_schema__)] 

318"""A `YX` annotation for Pydantic model fields that serializes as a JSON 

319object with ``y`` and ``x`` keys (`YX` alone serializes as an array when 

320parameterized, because Pydantic handles named tuples specially). 

321""" 

322 

323 

324class _SerializedInterval(TypedDict): 

325 start: int 

326 stop: int 

327 

328 

329@final 

330class Interval: 

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

332 

333 Parameters 

334 ---------- 

335 start 

336 Inclusive minimum point in the interval. 

337 stop 

338 One past the maximum point in the interval. 

339 

340 Notes 

341 ----- 

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

343 

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

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

346 Pydantic model itself. 

347 """ 

348 

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

350 # Coerce to be defensive against numpy int scalars. 

351 self._start = int(start) 

352 self._stop = int(stop) 

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

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

355 

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

357 

358 factory: ClassVar[IntervalSliceFactory] 

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

360 

361 For example:: 

362 

363 interval = Interval.factory[2:5] 

364 """ 

365 

366 @classmethod 

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

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

369 intervals. 

370 

371 Parameters 

372 ---------- 

373 first 

374 First point or interval to include in the hull. 

375 *args 

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

377 """ 

378 if type(first) is Interval: 

379 rmin = first.min 

380 rmax = first.max 

381 else: 

382 rmin = rmax = first 

383 for arg in args: 

384 if type(arg) is Interval: 

385 rmin = min(rmin, arg.min) 

386 rmax = max(rmax, arg.max) 

387 else: 

388 rmin = min(rmin, arg) 

389 rmax = max(rmax, arg) 

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

391 

392 @classmethod 

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

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

395 

396 Parameters 

397 ---------- 

398 size 

399 Number of points in the interval. 

400 start 

401 Inclusive minimum point in the interval. 

402 """ 

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

404 

405 @property 

406 def min(self) -> int: 

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

408 return self.start 

409 

410 @property 

411 def max(self) -> int: 

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

413 return self.stop - 1 

414 

415 @property 

416 def start(self) -> int: 

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

418 return self._start 

419 

420 @property 

421 def stop(self) -> int: 

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

423 return self._stop 

424 

425 @property 

426 def size(self) -> int: 

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

428 return self.stop - self.start 

429 

430 @property 

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

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

433 (`__builtins__.range`). 

434 """ 

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

436 

437 @property 

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

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

440 

441 Array values are integers. 

442 """ 

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

444 

445 @property 

446 def absolute(self) -> IntervalSliceFactory: 

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

448 syntax and absolute coordinates. 

449 

450 Notes 

451 ----- 

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

453 """ 

454 return IntervalSliceFactory(self, is_local=False) 

455 

456 @property 

457 def local(self) -> IntervalSliceFactory: 

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

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

460 

461 Notes 

462 ----- 

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

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

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

466 """ 

467 return IntervalSliceFactory(self, is_local=True) 

468 

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

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

471 

472 Parameters 

473 ---------- 

474 n 

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

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

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

478 step 

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

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

481 

482 Returns 

483 ------- 

484 numpy.ndarray 

485 Array of `float` values. 

486 

487 See Also 

488 -------- 

489 numpy.linspace 

490 """ 

491 if n is None: 

492 if step is None: 

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

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

495 elif step is not None: 

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

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

498 

499 @property 

500 def center(self) -> float: 

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

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

503 

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

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

506 either side. 

507 

508 Parameters 

509 ---------- 

510 padding 

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

512 """ 

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

514 

515 def __str__(self) -> str: 

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

517 

518 def __repr__(self) -> str: 

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

520 

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

522 if type(other) is Interval: 

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

524 return False 

525 

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

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

528 

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

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

531 

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

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

534 

535 @overload 

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

537 

538 @overload 

539 def contains(self, other: npt.ArrayLike) -> np.ndarray: ... 

540 

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

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

543 points. 

544 

545 Parameters 

546 ---------- 

547 other 

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

549 a scalar or any array-like. 

550 

551 Returns 

552 ------- 

553 `bool` | `numpy.ndarray` 

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

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

556 

557 Notes 

558 ----- 

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

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

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

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

563 appear "on the image". 

564 """ 

565 if isinstance(other, Interval): 

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

567 else: 

568 other = np.asarray(other) 

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

570 if not result.shape: 

571 return bool(result) 

572 return result 

573 

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

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

576 

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

578 raised. 

579 

580 Parameters 

581 ---------- 

582 other 

583 Interval to intersect with this one. 

584 """ 

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

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

587 if new_start < new_stop: 

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

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

590 

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

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

593 

594 Parameters 

595 ---------- 

596 padding 

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

598 """ 

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

600 

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

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

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

604 

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

606 

607 Parameters 

608 ---------- 

609 other 

610 Interval whose values correspond to the container being sliced. 

611 """ 

612 if not other.contains(self): 

613 raise IndexError( 

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

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

616 ) 

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

618 

619 @classmethod 

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

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

622 

623 Parameters 

624 ---------- 

625 legacy 

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

627 """ 

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

629 

630 def to_legacy(self) -> Any: 

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

632 from lsst.geom import IntervalI 

633 

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

635 

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

637 return ( 

638 Interval, 

639 ( 

640 self._start, 

641 self._stop, 

642 ), 

643 ) 

644 

645 @classmethod 

646 def __get_pydantic_core_schema__( 

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

648 ) -> pcs.CoreSchema: 

649 from_typed_dict = pcs.chain_schema( 

650 [ 

651 handler(_SerializedInterval), 

652 pcs.no_info_plain_validator_function(cls._validate), 

653 ] 

654 ) 

655 return pcs.json_or_python_schema( 

656 json_schema=from_typed_dict, 

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

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

659 ) 

660 

661 @classmethod 

662 def __get_pydantic_json_schema__( 

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

664 ) -> JsonSchemaValue: 

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

666 

667 @classmethod 

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

669 return cls(**data) 

670 

671 def _serialize(self) -> _SerializedInterval: 

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

673 

674 

675class IntervalSliceFactory: 

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

677 

678 Parameters 

679 ---------- 

680 parent 

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

682 to allow any bounds. 

683 is_local 

684 Whether slice bounds are interpreted relative to the start of 

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

686 

687 Notes 

688 ----- 

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

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

691 

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

693 

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

695 not allowed. 

696 

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

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

699 interval's bounds:: 

700 

701 parent = Interval.factory[3:6] 

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

703 

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

705 

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

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

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

709 

710 parent = Interval.factory[3:6] 

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

712 

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

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

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

716 """ 

717 

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

719 self._parent = parent 

720 self._is_local = is_local 

721 

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

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

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

725 if self._is_local: 

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

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

728 start += self._parent.start 

729 stop += self._parent.start 

730 else: 

731 start = s.start 

732 stop = s.stop 

733 if start is None: 

734 if self._parent is None: 

735 start = 0 

736 else: 

737 start = self._parent.start 

738 if stop is None: 

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

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

741 stop = self._parent.stop 

742 if self._parent is not None: 

743 if start < self._parent.start: 

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

745 if stop > self._parent.stop: 

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

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

748 

749 

750Interval.factory = IntervalSliceFactory() 

751 

752 

753class _SerializedBox(TypedDict): 

754 y: _SerializedInterval 

755 x: _SerializedInterval 

756 

757 

758class Box: 

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

760 

761 Parameters 

762 ---------- 

763 y 

764 Interval for the y dimension. 

765 x 

766 Interval for the x dimension. 

767 

768 Raises 

769 ------ 

770 TypeError 

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

772 

773 Notes 

774 ----- 

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

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

777 Pydantic model itself. 

778 """ 

779 

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

781 match (y, x): 

782 case (Interval(), Interval()): 

783 self._intervals = YX(y, x) 

784 case _: 

785 raise TypeError( 

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

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

788 "to construct a Box." 

789 ) 

790 

791 __slots__ = ("_intervals",) 

792 

793 factory: ClassVar[BoxSliceFactory] 

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

795 

796 For example:: 

797 

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

799 """ 

800 

801 @classmethod 

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

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

804 

805 Parameters 

806 ---------- 

807 shape 

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

809 start 

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

811 """ 

812 if start is None: 

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

814 match shape: 

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

816 pass 

817 case [y_size, x_size]: 

818 pass 

819 case _: 

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

821 match start: 

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

823 pass 

824 case [y_start, x_start]: 

825 pass 

826 case _: 

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

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

829 

830 @classmethod 

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

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

833 are contained in the new box. 

834 

835 Parameters 

836 ---------- 

837 x_min 

838 Minimum X value. 

839 x_max 

840 Maximum X value. 

841 y_min 

842 Minimum Y value. 

843 y_max 

844 Maximum Y value. 

845 

846 Notes 

847 ----- 

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

849 pixels whose centers lie within the bounds are included. 

850 """ 

851 return Box.factory[ 

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

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

854 ] 

855 

856 @property 

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

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

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

860 """ 

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

862 

863 @property 

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

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

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

867 """ 

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

869 

870 @property 

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

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

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

874 

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

876 half-exclusive ranges. 

877 """ 

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

879 

880 @property 

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

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

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

884 

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

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

887 """ 

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

889 

890 @property 

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

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

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

894 """ 

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

896 

897 @property 

898 def x(self) -> Interval: 

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

900 return self._intervals[-1] 

901 

902 @property 

903 def y(self) -> Interval: 

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

905 return self._intervals[-2] 

906 

907 @property 

908 def area(self) -> int: 

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

910 return self.x.size * self.y.size 

911 

912 @property 

913 def absolute(self) -> BoxSliceFactory: 

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

915 syntax and absolute coordinates. 

916 

917 Notes 

918 ----- 

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

920 """ 

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

922 

923 @property 

924 def local(self) -> BoxSliceFactory: 

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

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

927 

928 Notes 

929 ----- 

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

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

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

933 """ 

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

935 

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

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

938 

939 Parameters 

940 ---------- 

941 n 

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

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

944 provided. 

945 step 

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

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

948 ``n``. 

949 

950 Returns 

951 ------- 

952 `XY` [`numpy.ndarray`] 

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

954 

955 See Also 

956 -------- 

957 numpy.meshgrid 

958 """ 

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

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

961 match n: 

962 case int(): 

963 ax = self.x.linspace(n) 

964 ay = self.y.linspace(n) 

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

966 ax = self.x.linspace(nx) 

967 ay = self.y.linspace(ny) 

968 case [nx, ny]: 

969 ax = self.x.linspace(nx) 

970 ay = self.y.linspace(ny) 

971 case None: 

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

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

974 case _: 

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

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

977 

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

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

980 all sides. 

981 

982 Parameters 

983 ---------- 

984 padding 

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

986 """ 

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

988 

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

990 if type(other) is Box: 

991 return self._intervals == other._intervals 

992 return False 

993 

994 def __str__(self) -> str: 

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

996 

997 def __repr__(self) -> str: 

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

999 

1000 @overload 

1001 def contains(self, other: Box, /) -> bool: ... 

1002 

1003 @overload 

1004 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 

1005 

1006 @overload 

1007 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 

1008 

1009 @overload 

1010 def contains(self, /, *, y: int | float, x: int | float) -> bool: ... 

1011 

1012 @overload 

1013 def contains(self, /, *, y: npt.ArrayLike, x: npt.ArrayLike) -> np.ndarray: ... 

1014 

1015 def contains( 

1016 self, 

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

1018 /, 

1019 *, 

1020 y: Any = None, 

1021 x: Any = None, 

1022 ) -> bool | np.ndarray: 

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

1024 

1025 Parameters 

1026 ---------- 

1027 other 

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

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

1030 y 

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

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

1033 Mutually exclusive with ``other``. 

1034 x 

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

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

1037 Mutually exclusive with ``other``. 

1038 

1039 Returns 

1040 ------- 

1041 `bool` | `numpy.ndarray` 

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

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

1044 array with their broadcasted shape. 

1045 

1046 Notes 

1047 ----- 

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

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

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

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

1052 appear "on the image". 

1053 """ 

1054 match other: 

1055 case None: 

1056 if x is None or y is None: 

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

1058 case Box(): 

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

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

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

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

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

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

1065 x, y = other.x, other.y 

1066 case _: 

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

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

1069 if not result.shape: 

1070 return bool(result) 

1071 return result 

1072 

1073 @overload 

1074 def intersection(self, other: Box) -> Box: ... 

1075 

1076 @overload 

1077 def intersection(self, other: Region) -> Region | Box: ... 

1078 

1079 @overload 

1080 def intersection(self, other: Bounds) -> Bounds: ... 

1081 

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

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

1084 ``other``. 

1085 

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

1087 

1088 Parameters 

1089 ---------- 

1090 other 

1091 Bounds to intersect with this one. 

1092 """ 

1093 from ._concrete_bounds import _intersect_box 

1094 

1095 return _intersect_box(self, other) 

1096 

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

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

1099 

1100 Parameters 

1101 ---------- 

1102 padding 

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

1104 """ 

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

1106 

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

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

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

1110 correspond to ``other``. 

1111 

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

1113 

1114 Parameters 

1115 ---------- 

1116 other 

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

1118 """ 

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

1120 

1121 @property 

1122 def bbox(self) -> Box: 

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

1124 

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

1126 """ 

1127 return self 

1128 

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

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

1131 

1132 Yields 

1133 ------ 

1134 corner 

1135 Each corner in turn. 

1136 """ 

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

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

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

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

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

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

1143 

1144 def to_polygon(self) -> Polygon: 

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

1146 

1147 Notes 

1148 ----- 

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

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

1151 before using them to form the polygon vertices. 

1152 """ 

1153 from ._polygon import Polygon 

1154 

1155 return Polygon.from_box(self) 

1156 

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

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

1159 

1160 Parameters 

1161 ---------- 

1162 transform 

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

1164 

1165 Notes 

1166 ----- 

1167 This transforms the polygon representation of the box (see 

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

1169 full pixels before transforming them. 

1170 """ 

1171 return self.to_polygon().transform(transform) 

1172 

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

1174 return (Box, self._intervals) 

1175 

1176 @classmethod 

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

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

1179 

1180 Parameters 

1181 ---------- 

1182 legacy 

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

1184 """ 

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

1186 

1187 def to_legacy(self) -> Any: 

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

1189 from lsst.geom import Box2I 

1190 

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

1192 

1193 @classmethod 

1194 def __get_pydantic_core_schema__( 

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

1196 ) -> pcs.CoreSchema: 

1197 from_typed_dict = pcs.chain_schema( 

1198 [ 

1199 handler(_SerializedBox), 

1200 pcs.no_info_plain_validator_function(cls._validate), 

1201 ] 

1202 ) 

1203 return pcs.json_or_python_schema( 

1204 json_schema=from_typed_dict, 

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

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

1207 ) 

1208 

1209 @classmethod 

1210 def __get_pydantic_json_schema__( 

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

1212 ) -> JsonSchemaValue: 

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

1214 

1215 @classmethod 

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

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

1218 

1219 def _serialize(self) -> _SerializedBox: 

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

1221 

1222 def serialize(self) -> Box: 

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

1224 

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

1226 Pydantic serialization hooks. It exists for compatibility with the 

1227 `Bounds` protocol. 

1228 """ 

1229 return self 

1230 

1231 def deserialize(self) -> Box: 

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

1233 

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

1235 Pydantic serialization hooks. It exists for compatibility with the 

1236 `Bounds` protocol. 

1237 """ 

1238 return self 

1239 

1240 

1241class BoxSliceFactory: 

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

1243 

1244 Parameters 

1245 ---------- 

1246 y 

1247 Slice factory used for the y axis. 

1248 x 

1249 Slice factory used for the x axis. 

1250 

1251 Notes 

1252 ----- 

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

1254 `Box` with exactly those bounds:: 

1255 

1256 assert ( 

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

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

1259 ) 

1260 

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

1262 not allowed. 

1263 

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

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

1266 box's bounds:: 

1267 

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

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

1270 

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

1272 

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

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

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

1276 

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

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

1279 """ 

1280 

1281 def __init__( 

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

1283 ) -> None: 

1284 self._y = y 

1285 self._x = x 

1286 

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

1288 match key: 

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

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

1291 case (y, x): 

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

1293 case _: 

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

1295 

1296 

1297Box.factory = BoxSliceFactory() 

1298 

1299 

1300class Bounds(Protocol): 

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

1302 defined in 2-d pixel coordinates. 

1303 

1304 Notes 

1305 ----- 

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

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

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

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

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

1311 intended to handle both of these cases as well. 

1312 """ 

1313 

1314 @property 

1315 def bbox(self) -> Box: ... 

1316 

1317 @overload 

1318 def contains(self, point: XY[int | float] | YX[int | float], /) -> bool: ... 

1319 

1320 @overload 

1321 def contains(self, point: XY[npt.ArrayLike] | YX[npt.ArrayLike], /) -> np.ndarray: ... 

1322 

1323 @overload 

1324 def contains(self, /, *, x: int | float, y: int | float) -> bool: ... 

1325 

1326 @overload 

1327 def contains(self, /, *, x: npt.ArrayLike, y: npt.ArrayLike) -> np.ndarray: ... 

1328 

1329 def contains( 

1330 self, 

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

1332 /, 

1333 *, 

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

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

1336 ) -> bool | np.ndarray: 

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

1338 

1339 Parameters 

1340 ---------- 

1341 point 

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

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

1344 x 

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

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

1347 Mutually exclusive with ``point``. 

1348 y 

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

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

1351 Mutually exclusive with ``point``. 

1352 

1353 Returns 

1354 ------- 

1355 `bool` | `numpy.ndarray` 

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

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

1358 broadcasted shape. 

1359 """ 

1360 ... 

1361 

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

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

1364 

1365 Parameters 

1366 ---------- 

1367 other 

1368 Bounds to intersect with this one. 

1369 """ 

1370 ... 

1371 

1372 def serialize(self) -> BoundsSerializationModel: 

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

1374 

1375 Notes 

1376 ----- 

1377 The returned object must support direct nesting with Pydantic models 

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

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

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

1381 natively serializable. 

1382 """ 

1383 ... 

1384 

1385 

1386class BoundsError(ValueError): 

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

1388 

1389 

1390class NoOverlapError(ValueError): 

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

1392 

1393 

1394if TYPE_CHECKING: 

1395 

1396 def _test_types() -> None: 

1397 interval = Interval(0, 10) 

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

1399 arr = np.zeros(3) 

1400 

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

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

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

1404 

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

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

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

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

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

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

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

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

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

1414 

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

1416 bounds: Bounds = box 

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

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

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

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

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

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

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