Coverage for python / lsst / images / _transforms / _transform.py: 29%

169 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-24 02:25 -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 "Transform", 

16 "TransformCompositionError", 

17 "TransformSerializationModel", 

18) 

19 

20import textwrap 

21from collections.abc import Iterable 

22from typing import TYPE_CHECKING, Any, TypeVar, final 

23 

24import astropy.io.fits.header 

25import astropy.units as u 

26import numpy as np 

27import pydantic 

28 

29from .._geom import XY, Bounds, Box, SerializableBounds 

30from ..serialization import ArchiveReadError, ArchiveTree, InputArchive, OutputArchive 

31from ._frames import Frame, SerializableFrame, SkyFrame 

32 

33if TYPE_CHECKING: 

34 from ._projection import Projection 

35 

36# These pre-python-3.12 declaration are needed by Sphinx (probably the 

37# autodoc-typehints plugin. 

38I = TypeVar("I", bound=Frame) # noqa: E741 

39O = TypeVar("O", bound=Frame) # noqa: E741 

40 

41 

42class TransformCompositionError(RuntimeError): 

43 """Exception raised when two transforms cannot be composed.""" 

44 

45 

46@final 

47class Transform[I: Frame, O: Frame]: 

48 """A transform that maps two coordinate frames. 

49 

50 Notes 

51 ----- 

52 The `Transform` class constructor is considered a private implementation 

53 detail. Instead of using this, various factory methods are available: 

54 

55 - `from_fits_wcs` constructs a transform from a FITS WCS, as represented 

56 `astropy.wcs.WCS`; 

57 - `then` composes two transforms; 

58 - `identity` constructs a trivial transform that does nothing; 

59 - `inverted` returns the inverse of a transform; 

60 - `from_legacy` converts an `lsst.afw.geom.Transform` instance. 

61 

62 When applied to celestial coordinate systems, ``x=ra`` and ``y=dec``. 

63 `Projection` provides a more natural interface for pixel-to-sky transforms. 

64 

65 `Transform` is conceptually immutable (the internal AST Mapping should 

66 never be modified in-place after construction), and hence does not need to 

67 be copied when any object that holds it is copied. 

68 """ 

69 

70 def __init__( 

71 self, 

72 in_frame: I, 

73 out_frame: O, 

74 ast_mapping: Any, 

75 in_bounds: Bounds | None = None, 

76 out_bounds: Bounds | None = None, 

77 components: Iterable[Transform[Any, Any]] = (), 

78 ): 

79 self._in_frame = in_frame 

80 self._out_frame = out_frame 

81 self._ast_mapping = ast_mapping 

82 self._in_bounds = in_bounds or getattr(in_frame, "bbox", None) 

83 self._out_bounds = out_bounds or getattr(out_frame, "bbox", None) 

84 self._components = list(components) 

85 

86 @staticmethod 

87 def from_fits_wcs( 

88 fits_wcs: astropy.wcs.WCS, 

89 in_frame: I, 

90 out_frame: O, 

91 in_bounds: Bounds | None = None, 

92 out_bounds: Bounds | None = None, 

93 x0: int = 0, 

94 y0: int = 0, 

95 ) -> Transform[I, O]: 

96 """Construct a transform from a FITS WCS. 

97 

98 Parameters 

99 ---------- 

100 fits_wcs 

101 FITS WCS to convert. 

102 in_frame 

103 Coordinate frame for input points to the forward transform. 

104 out_frame 

105 Coordinate frame for output points from the forward transform. 

106 in_bounds 

107 The region that bounds valid input points. 

108 out_bounds 

109 The region that bounds valid output points. 

110 x0 

111 Logical coordinate of the first column in the array this WCS 

112 relates to world coordinates. 

113 y0 

114 Logical coordinate of the first column in the array this WCS 

115 relates to world coordinates. 

116 

117 Notes 

118 ----- 

119 The ``x0`` and ``y0`` parameters reflect the fact that for FITS, the 

120 first row and column are always labeled ``(1, 1)``, while in Astropy 

121 and most other Python libraries, they are ``(0, 0)``. The `types` in 

122 this package (e.g. `Image`, `Mask`) allow them to be any pair of 

123 integers. 

124 

125 See Also 

126 -------- 

127 Projection.from_fits_wcs 

128 """ 

129 import astshim 

130 

131 ast_stream = astshim.StringStream(fits_wcs.to_header_string(relax=True)) 

132 ast_fits_chan = astshim.FitsChan(ast_stream, "Encoding=FITS-WCS, SipReplace=0") 

133 ast_frame_set = ast_fits_chan.read() 

134 _prepend_ast_shift(ast_frame_set, x=x0 - 1.0, y=y0 - 1.0, ast_domain="PIXEL") 

135 return Transform( 

136 in_frame, 

137 out_frame, 

138 ast_frame_set.getMapping(), 

139 in_bounds=in_bounds, 

140 out_bounds=out_bounds, 

141 ) 

142 

143 @staticmethod 

144 def identity(frame: I) -> Transform[I, I]: 

145 """Construct a trivial transform that maps a frame to itelf. 

146 

147 Parameters 

148 ---------- 

149 frame 

150 Frame used for both input and output points. 

151 """ 

152 import astshim 

153 

154 return Transform(frame, frame, astshim.UnitMap(2)) 

155 

156 @property 

157 def in_frame(self) -> I: 

158 """Coordinate frame for input points.""" 

159 return self._in_frame 

160 

161 @property 

162 def out_frame(self) -> O: 

163 """Coordinate frame for output points.""" 

164 return self._out_frame 

165 

166 @property 

167 def in_bounds(self) -> Bounds | None: 

168 """The region that bounds valid input points (`Bounds` | `None`).""" 

169 return self._in_bounds 

170 

171 @property 

172 def out_bounds(self) -> Bounds | None: 

173 """The region that bounds valid output points (`Bounds` | `None`).""" 

174 return self._out_bounds 

175 

176 def show(self, simplified: bool = False, comments: bool = False) -> str: 

177 """Return the AST native representation of the transform. 

178 

179 Parameters 

180 ---------- 

181 simplified 

182 Whether to ask AST to simplify the mapping before showing it. 

183 This will make it much more likely that two equivalent transforms 

184 have the same `show` result. 

185 comments 

186 Whether to include descriptive comments. 

187 """ 

188 ast_mapping = self._ast_mapping 

189 if simplified: 

190 ast_mapping = ast_mapping.simplified() 

191 return ast_mapping.show(comments) 

192 

193 def apply_forward[T: np.ndarray | float](self, *, x: T, y: T) -> XY[T]: 

194 """Apply the forward transform to one or more points. 

195 

196 Parameters 

197 ---------- 

198 x : `numpy.ndarray` | `float` 

199 ``x`` values of the points to transform. 

200 y : `numpy.ndarray` | `float` 

201 ``y`` values of the points to transform. 

202 

203 Returns 

204 ------- 

205 `XY` [`numpy.ndarray` | `float`] 

206 The transformed point or points. 

207 """ 

208 return _standardize_xy( 

209 _ast_apply( 

210 self._ast_mapping.applyForward, 

211 x=self._in_frame.standardize_x(x), 

212 y=self._in_frame.standardize_y(y), 

213 ), 

214 self._out_frame, 

215 ) 

216 

217 def apply_inverse[T: np.ndarray | float](self, *, x: T, y: T) -> XY[T]: 

218 """Apply the inverse transform to one or more points. 

219 

220 Parameters 

221 ---------- 

222 x : `numpy.ndarray` | `float` 

223 ``x`` values of the points to transform. 

224 y : `numpy.ndarray` | `float` 

225 ``y`` values of the points to transform. 

226 

227 Returns 

228 ------- 

229 `XY` [`numpy.ndarray` | `float`] 

230 The transformed point or points. 

231 """ 

232 return _standardize_xy( 

233 _ast_apply( 

234 self._ast_mapping.applyInverse, 

235 x=self._out_frame.standardize_x(x), 

236 y=self._out_frame.standardize_y(y), 

237 ), 

238 self._in_frame, 

239 ) 

240 

241 def apply_forward_q(self, *, x: u.Quantity, y: u.Quantity) -> XY[u.Quantity]: 

242 """Apply the forward transform to one or more unit-aware points. 

243 

244 Parameters 

245 ---------- 

246 x 

247 ``x`` values of the points to transform. 

248 y 

249 ``y`` values of the points to transform. 

250 

251 Returns 

252 ------- 

253 `XY` [`astropy.units.Quantity`] 

254 The transformed point or points. 

255 """ 

256 xy = self.apply_forward(x=x.to_value(self._in_frame.unit), y=y.to_value(self._in_frame.unit)) 

257 return XY(xy.x * self._out_frame.unit, xy.y * self._out_frame.unit) 

258 

259 def apply_inverse_q(self, *, x: u.Quantity, y: u.Quantity) -> XY[u.Quantity]: 

260 """Apply the inverse transform to one or more unit-aware points. 

261 

262 Parameters 

263 ---------- 

264 x 

265 ``x`` values of the points to transform. 

266 y 

267 ``y`` values of the points to transform. 

268 

269 Returns 

270 ------- 

271 `XY` [`astropy.units.Quantity`] 

272 The transformed point or points. 

273 """ 

274 xy = self.apply_inverse(x=x.to_value(self._out_frame.unit), y=y.to_value(self._out_frame.unit)) 

275 return XY(xy.x * self._in_frame.unit, xy.y * self._in_frame.unit) 

276 

277 def decompose(self) -> list[Transform[Any, Any]]: 

278 """Deconstruct a composed transform into its constituent parts. 

279 

280 Notes 

281 ----- 

282 Most transforms will just return a single-element list holding 

283 ``self``. Identity transform will return an empty list, and 

284 transforms composed with `then` will return the original transforms. 

285 Transforms constructed by `FrameSet` may or may not be decomposable. 

286 """ 

287 if not self._components: 

288 if self.in_frame == self._out_frame: 

289 return [] 

290 else: 

291 return [self] 

292 else: 

293 return list(self._components) 

294 

295 def inverted(self) -> Transform[O, I]: 

296 """Return the inverse of this transform.""" 

297 return Transform[O, I]( 

298 self._out_frame, 

299 self._in_frame, 

300 self._ast_mapping.inverted(), 

301 in_bounds=self.out_bounds, 

302 out_bounds=self.in_bounds, 

303 components=[t.inverted() for t in reversed(self._components)], 

304 ) 

305 

306 def then[F: Frame](self, next: Transform[O, F], remember_components: bool = True) -> Transform[I, F]: 

307 """Compose two transforms into another. 

308 

309 Parameters 

310 ---------- 

311 next 

312 Another transform to apply after ``self``. 

313 remember_components 

314 If `True`, the returned composed transform will remember ``self`` 

315 and ``other`` so they can be returned by `decompose`. 

316 """ 

317 if self._out_frame != next._in_frame: 

318 raise TransformCompositionError( 

319 "Cannot compose transforms that do not share a common intermediate frame: " 

320 f"{self._out_frame} != {next._in_frame}." 

321 ) 

322 components = self.decompose() + next.decompose() if remember_components else () 

323 return Transform( 

324 self._in_frame, 

325 next._out_frame, 

326 self._ast_mapping.then(next._ast_mapping), 

327 in_bounds=self.in_bounds, 

328 out_bounds=next.out_bounds, 

329 components=components, 

330 ) 

331 

332 def as_projection(self: Transform[I, SkyFrame]) -> Projection[I]: 

333 """Return a `Projection` view of this transform. 

334 

335 This is only valid when `out_frame` is `~SkyFrame.ICRS`. 

336 """ 

337 from ._projection import Projection 

338 

339 return Projection(self) 

340 

341 def as_fits_wcs(self, bbox: Box) -> astropy.wcs.WCS | None: 

342 """Return a FITS WCS representation of this transform, if possible. 

343 

344 Parameters 

345 ---------- 

346 bbox 

347 Bounding box of the array the FITS WCS will describe. This 

348 transform object is assumed to work on the same coordinate system 

349 in which ``bbox`` is defined, while the FITS WCS will consider the 

350 first row and column in that box to be ``(0, 0)`` (in Astropy 

351 interfaces) or ``(1, 1)`` (in the FITS representation itself). 

352 

353 Notes 

354 ----- 

355 This method assumes the transform maps pixel coordinates to world 

356 coordinates. 

357 

358 Not all transforms can be represented exactly; when a FITS 

359 represention is not possible, `None` is returned. When the returned 

360 WCS is not `None`, it will have the same functional form, but it may 

361 not evaluate identically due to small implementation differences in 

362 the order of floating-point operations. 

363 """ 

364 import astshim 

365 

366 ast_frame_set = self._get_ast_frame_set() 

367 _prepend_ast_shift(ast_frame_set, x=1.0 - bbox.x.start, y=1.0 - bbox.y.start, ast_domain="GRID") 

368 ast_stream = astshim.StringStream() 

369 ast_fits_chan = astshim.FitsChan( 

370 ast_stream, "Encoding=FITS-WCS, CDMatrix=1, FitsAxisOrder=<copy>, FitsTol=0.0001" 

371 ) 

372 ast_fits_chan.setFitsI("NAXIS1", bbox.x.size) 

373 ast_fits_chan.setFitsI("NAXIS2", bbox.y.size) 

374 n_writes = ast_fits_chan.write(ast_frame_set) 

375 if not n_writes: 

376 return None 

377 header = astropy.io.fits.Header(astropy.io.fits.Card.fromstring(c) for c in ast_fits_chan) 

378 return astropy.wcs.WCS(header) 

379 

380 def serialize[P: pydantic.BaseModel]( 

381 self, archive: OutputArchive[P], *, use_frame_sets: bool = False 

382 ) -> TransformSerializationModel[P]: 

383 """Serialize a transform to an archive. 

384 

385 Parameters 

386 ---------- 

387 archive 

388 Archive to serialize to. 

389 use_frame_sets 

390 If `True`, decompose the transform and try to reference component 

391 mappings that were already serialized into a `FrameSet` in the 

392 archive. Note that if multiple transforms exist between a pair of 

393 frames (e.g. a `Projection` and its FITS approximation), this may 

394 cause the wrong one to be saved. When this option is used, the 

395 frame set must be saved before the transform, and it must be 

396 deserialized before the transform as well. 

397 

398 Returns 

399 ------- 

400 `TransformSerializationModel` 

401 Serialized form of the transform. 

402 """ 

403 model = TransformSerializationModel[P]() 

404 if use_frame_sets: 

405 for link in self.decompose(): 

406 model.frames.append(link.in_frame.serialize()) 

407 model.bounds.append(link.in_bounds.serialize() if link.in_bounds is not None else None) 

408 for frame_set, pointer in archive.iter_frame_sets(): 

409 if link.in_frame in frame_set and link.out_frame in frame_set: 

410 model.mappings.append(pointer) 

411 break 

412 else: 

413 model.mappings.append(MappingSerializationModel(ast=link._ast_mapping.show())) 

414 else: 

415 model.frames.append(self.in_frame.serialize()) 

416 model.bounds.append(self.in_bounds.serialize() if self.in_bounds is not None else None) 

417 model.mappings.append(MappingSerializationModel(ast=self._ast_mapping.show())) 

418 model.frames.append(self.out_frame.serialize()) 

419 model.bounds.append(self.out_bounds.serialize() if self.out_bounds is not None else None) 

420 return model 

421 

422 @staticmethod 

423 def deserialize[P: pydantic.BaseModel]( 

424 model: TransformSerializationModel[P], archive: InputArchive[P] 

425 ) -> Transform[Any, Any]: 

426 """Deserialize a transform from an archive. 

427 

428 Parameters 

429 ---------- 

430 model 

431 Seralized form of the transform. 

432 archive 

433 Archive to read from. 

434 """ 

435 import astshim 

436 

437 if len(model.frames) != len(model.bounds): 

438 raise ArchiveReadError( 

439 f"Inconsistent lengths for 'frames' ({len(model.frames)}) and 'bounds' ({len(model.bounds)})." 

440 ) 

441 if len(model.frames) != len(model.mappings) + 1: 

442 raise ArchiveReadError( 

443 f"Inconsistent lengths for 'frames' ({len(model.frames)}) and " 

444 f"'mappings' ({len(model.mappings)}; should be one less)." 

445 ) 

446 transform = Transform.identity(Frame.deserialize(model.frames[0])) 

447 for n, mapping in enumerate(model.mappings): 

448 match mapping: 

449 case MappingSerializationModel(ast=serialized_mapping): 

450 ast_mapping = astshim.Mapping.fromString(serialized_mapping) 

451 in_bounds = model.bounds[n] 

452 out_bounds = model.bounds[n + 1] 

453 transform = transform.then( 

454 Transform( 

455 Frame.deserialize(model.frames[n]), 

456 Frame.deserialize(model.frames[n + 1]), 

457 ast_mapping, 

458 Bounds.deserialize(in_bounds) if in_bounds is not None else None, 

459 Bounds.deserialize(out_bounds) if out_bounds is not None else None, 

460 ) 

461 ) 

462 case reference: 

463 frame_set = archive.get_frame_set(reference) 

464 transform = transform.then( 

465 frame_set[Frame.deserialize(model.frames[n]), Frame.deserialize(model.frames[n + 1])] 

466 ) 

467 return transform 

468 

469 @staticmethod 

470 def _get_archive_tree_type[P: pydantic.BaseModel]( 

471 pointer_type: type[P], 

472 ) -> type[TransformSerializationModel[P]]: 

473 """Return the serialization model type for this object for an archive 

474 type that uses the given pointer type. 

475 """ 

476 return TransformSerializationModel[pointer_type] # type: ignore 

477 

478 @staticmethod 

479 def from_legacy( 

480 legacy: Any, 

481 in_frame: I, 

482 out_frame: O, 

483 in_bounds: Bounds | None = None, 

484 out_bounds: Bounds | None = None, 

485 ) -> Transform[I, O]: 

486 """Construct a transform from a legacy `lsst.afw.geom.Transform`. 

487 

488 Parameters 

489 ---------- 

490 legacy : `lsst.afw.geom.Transform` 

491 Legacy transform object. 

492 in_frame 

493 Coordinate frame for input points to the forward transform. 

494 out_frame 

495 Coordinate frame for output points from the forward transform. 

496 in_bounds 

497 The region that bounds valid input points. 

498 out_bounds 

499 The region that bounds valid output points. 

500 """ 

501 return Transform( 

502 in_frame, 

503 out_frame, 

504 legacy.getMapping(), 

505 in_bounds=in_bounds, 

506 out_bounds=out_bounds, 

507 ) 

508 

509 def _get_ast_frame_set(self) -> Any: 

510 import astshim 

511 

512 ast_frame_set = astshim.FrameSet(_make_ast_frame(self._in_frame)) 

513 ast_frame_set.addFrame(astshim.FrameSet.BASE, self._ast_mapping, _make_ast_frame(self._out_frame)) 

514 return ast_frame_set 

515 

516 

517def _ast_apply[T: np.ndarray | float](method: Any, *, x: T, y: T) -> XY[T]: 

518 # TODO: add bounds argument and check inputs 

519 # TODO: broadcast arrays with different shapes. 

520 xy_in = np.vstack([x, y]).astype(np.float64) 

521 xy_out = method(xy_in) 

522 return XY(xy_out[0, :], xy_out[1, :]) 

523 

524 

525def _prepend_ast_shift(ast_frame_set: Any, x: float, y: float, ast_domain: str) -> None: 

526 import astshim 

527 

528 ast_output_frame_id = ast_frame_set.current 

529 ast_frame_set.addFrame( 

530 astshim.FrameSet.BASE, 

531 astshim.ShiftMap([x, y]), 

532 astshim.Frame(2, f"Domain={ast_domain}"), 

533 ) 

534 ast_frame_set.base = ast_frame_set.current 

535 ast_frame_set.current = ast_output_frame_id 

536 

537 

538def _make_ast_frame(frame: Frame) -> Any: 

539 import astshim 

540 

541 if frame is SkyFrame.ICRS: 

542 return astshim.SkyFrame("") 

543 ast_frame = astshim.Frame(2, f"Ident={frame._ast_ident}") 

544 if frame.unit is not None: 

545 fits_unit = frame.unit.to_string(format="fits") 

546 ast_frame.setUnit(1, fits_unit) 

547 ast_frame.setUnit(2, fits_unit) 

548 ast_frame.setLabel(1, "x") 

549 ast_frame.setLabel(2, "y") 

550 return ast_frame 

551 

552 

553def _standardize_xy[T: np.ndarray | float](xy: XY[T], frame: Frame) -> XY[T]: 

554 return XY(x=frame.standardize_x(xy.x), y=frame.standardize_y(xy.y)) 

555 

556 

557class MappingSerializationModel(ArchiveTree): 

558 """Serialization model for an AST Mapping.""" 

559 

560 ast: str = pydantic.Field(description="A serialized Starlink AST Mapping, using the AST native encoding.") 

561 

562 

563class TransformSerializationModel[P: pydantic.BaseModel](ArchiveTree): 

564 """Serialization model for coordinate transforms.""" 

565 

566 frames: list[SerializableFrame] = pydantic.Field( 

567 default_factory=list, 

568 description=textwrap.dedent( 

569 """ 

570 List of frames that this transform passes through. 

571 

572 All transforms include at least two frames (the endpoints). Others 

573 intermediate frames may be included to facilitate data-sharing 

574 between transforms. 

575 """ 

576 ), 

577 ) 

578 

579 bounds: list[SerializableBounds | None] = pydantic.Field( 

580 default_factory=list, 

581 description=textwrap.dedent( 

582 """ 

583 List of the bounds of the ``frames`` for this transform. 

584 

585 This always has the same number of elements as ``frames``. 

586 """ 

587 ), 

588 ) 

589 

590 mappings: list[P | MappingSerializationModel] = pydantic.Field( 

591 default_factory=list, 

592 description=textwrap.dedent( 

593 """ 

594 The actual mappings between frames, or archive pointers to 

595 serialized FrameSet objects from which they can be obtained. 

596 

597 This always has one fewer element than ``frames``. 

598 """ 

599 ), 

600 )