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

195 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-24 01:50 -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, ClassVar, TypeVar, final 

23 

24import astropy.io.fits.header 

25import astropy.units as u 

26import numpy as np 

27import pydantic 

28 

29from .._concrete_bounds import SerializableBounds 

30from .._geom import XY, Bounds, Box 

31from ..serialization import ArchiveReadError, ArchiveTree, InputArchive, InvalidParameterError, OutputArchive 

32from . import _ast as astshim 

33from ._frames import Frame, SerializableFrame, SkyFrame 

34 

35if TYPE_CHECKING: 

36 try: 

37 from lsst.afw.geom import TransformPoint2ToPoint2 as LegacyTransform 

38 except ImportError: 

39 type LegacyTransform = Any # type: ignore[no-redef] 

40 

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

42# autodoc-typehints plugin. 

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

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

45P = TypeVar("P", bound=pydantic.BaseModel) 

46 

47 

48class TransformCompositionError(RuntimeError): 

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

50 

51 

52@final 

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

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

55 

56 Notes 

57 ----- 

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

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

60 

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

62 `astropy.wcs.WCS`; 

63 - `then` composes two transforms; 

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

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

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

67 

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

69 `SkyProjection` provides a more natural interface for pixel-to-sky 

70 transforms. 

71 

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

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

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

75 """ 

76 

77 def __init__( 

78 self, 

79 in_frame: I, 

80 out_frame: O, 

81 ast_mapping: astshim.Mapping, 

82 in_bounds: Bounds | None = None, 

83 out_bounds: Bounds | None = None, 

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

85 ) -> None: 

86 self._in_frame = in_frame 

87 self._out_frame = out_frame 

88 self._ast_mapping = ast_mapping 

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

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

91 self._components = list(components) 

92 

93 def __eq__(self, other: Any) -> bool: 

94 if self is other: 

95 # Short circuit for case where you are quickly checking 

96 # that the image WCS and variance WCS are the same object. 

97 return True 

98 if not isinstance(other, Transform): 

99 return NotImplemented 

100 if self._ast_mapping != other._ast_mapping: 

101 return False 

102 if self._in_bounds != other._in_bounds: 

103 return False 

104 if self._out_bounds != other._out_bounds: 

105 return False 

106 if self._in_frame != other._in_frame: 

107 return False 

108 if self._out_frame != other._out_frame: 

109 return False 

110 if self._components != other._components: 

111 return False 

112 return True 

113 

114 @staticmethod 

115 def from_fits_wcs( 

116 fits_wcs: astropy.wcs.WCS, 

117 in_frame: I, 

118 out_frame: O, 

119 in_bounds: Bounds | None = None, 

120 out_bounds: Bounds | None = None, 

121 x0: int = 0, 

122 y0: int = 0, 

123 ) -> Transform[I, O]: 

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

125 

126 Parameters 

127 ---------- 

128 fits_wcs 

129 FITS WCS to convert. 

130 in_frame 

131 Coordinate frame for input points to the forward transform. 

132 out_frame 

133 Coordinate frame for output points from the forward transform. 

134 in_bounds 

135 The region that bounds valid input points. 

136 out_bounds 

137 The region that bounds valid output points. 

138 x0 

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

140 relates to world coordinates. 

141 y0 

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

143 relates to world coordinates. 

144 

145 Notes 

146 ----- 

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

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

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

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

151 integers. 

152 

153 See Also 

154 -------- 

155 SkyProjection.from_fits_wcs 

156 """ 

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

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

159 ast_frame_set = ast_fits_chan.read() 

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

161 return Transform( 

162 in_frame, 

163 out_frame, 

164 ast_frame_set, 

165 in_bounds=in_bounds, 

166 out_bounds=out_bounds, 

167 ) 

168 

169 @staticmethod 

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

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

172 

173 Parameters 

174 ---------- 

175 frame 

176 Frame used for both input and output points. 

177 """ 

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

179 

180 @property 

181 def in_frame(self) -> I: 

182 """Coordinate frame for input points.""" 

183 return self._in_frame 

184 

185 @property 

186 def out_frame(self) -> O: 

187 """Coordinate frame for output points.""" 

188 return self._out_frame 

189 

190 @property 

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

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

193 return self._in_bounds 

194 

195 @property 

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

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

198 return self._out_bounds 

199 

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

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

202 

203 Parameters 

204 ---------- 

205 simplified 

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

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

208 have the same `show` result. If the internal mapping is actually 

209 a frame set (as needed to round-trip legacy 

210 `lsst.afw.geom.SkyWcs` objects), this will also just show the 

211 mapping with no frame set information. 

212 comments 

213 Whether to include descriptive comments. 

214 """ 

215 ast_mapping = self._ast_mapping 

216 if simplified: 

217 if isinstance(ast_mapping, astshim.FrameSet): 

218 ast_mapping = ast_mapping.getMapping() 

219 ast_mapping = ast_mapping.simplified() 

220 return ast_mapping.show(comments) 

221 

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

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

224 

225 Parameters 

226 ---------- 

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

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

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

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

231 

232 Returns 

233 ------- 

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

235 The transformed point or points. 

236 """ 

237 return _standardize_xy( 

238 _ast_apply( 

239 self._ast_mapping.applyForward, 

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

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

242 ), 

243 self._out_frame, 

244 ) 

245 

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

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

248 

249 Parameters 

250 ---------- 

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

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

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

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

255 

256 Returns 

257 ------- 

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

259 The transformed point or points. 

260 """ 

261 return _standardize_xy( 

262 _ast_apply( 

263 self._ast_mapping.applyInverse, 

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

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

266 ), 

267 self._in_frame, 

268 ) 

269 

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

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

272 

273 Parameters 

274 ---------- 

275 x 

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

277 y 

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

279 

280 Returns 

281 ------- 

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

283 The transformed point or points. 

284 """ 

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

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

287 

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

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

290 

291 Parameters 

292 ---------- 

293 x 

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

295 y 

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

297 

298 Returns 

299 ------- 

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

301 The transformed point or points. 

302 """ 

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

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

305 

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

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

308 

309 Notes 

310 ----- 

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

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

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

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

315 """ 

316 if not self._components: 316 ↛ 322line 316 didn't jump to line 322 because the condition on line 316 was always true

317 if self.in_frame == self._out_frame: 317 ↛ 320line 317 didn't jump to line 320 because the condition on line 317 was always true

318 return [] 

319 else: 

320 return [self] 

321 else: 

322 return list(self._components) 

323 

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

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

326 return Transform[O, I]( 

327 self._out_frame, 

328 self._in_frame, 

329 self._ast_mapping.inverted(), 

330 in_bounds=self.out_bounds, 

331 out_bounds=self.in_bounds, 

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

333 ) 

334 

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

336 """Compose two transforms into another. 

337 

338 Parameters 

339 ---------- 

340 next 

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

342 remember_components 

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

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

345 """ 

346 if self._out_frame != next._in_frame: 

347 raise TransformCompositionError( 

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

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

350 ) 

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

352 return Transform( 

353 self._in_frame, 

354 next._out_frame, 

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

356 in_bounds=self.in_bounds, 

357 out_bounds=next.out_bounds, 

358 components=components, 

359 ) 

360 

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

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

363 

364 Parameters 

365 ---------- 

366 bbox 

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

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

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

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

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

372 

373 Notes 

374 ----- 

375 This method assumes the transform maps pixel coordinates to world 

376 coordinates. 

377 

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

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

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

381 not evaluate identically due to small implementation differences in 

382 the order of floating-point operations. 

383 """ 

384 ast_frame_set = self._get_ast_frame_set() 

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

386 ast_stream = astshim.StringStream() 

387 ast_fits_chan = astshim.FitsChan( 

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

389 ) 

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

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

392 n_writes = ast_fits_chan.write(ast_frame_set) 

393 if not n_writes: 

394 return None 

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

396 return astropy.wcs.WCS(header) 

397 

398 def serialize[P: pydantic.BaseModel]( 

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

400 ) -> TransformSerializationModel[P]: 

401 """Serialize a transform to an archive. 

402 

403 Parameters 

404 ---------- 

405 archive 

406 Archive to serialize to. 

407 use_frame_sets 

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

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

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

411 frames (e.g. a `SkyProjection` and its FITS approximation), this 

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

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

414 deserialized before the transform as well. 

415 

416 Returns 

417 ------- 

418 `TransformSerializationModel` 

419 Serialized form of the transform. 

420 """ 

421 model = TransformSerializationModel[P]() 

422 if use_frame_sets: 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 for link in self.decompose(): 

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

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

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

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

428 model.mappings.append(pointer) 

429 break 

430 else: 

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

432 else: 

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

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

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

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

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

438 return model 

439 

440 @staticmethod 

441 def _get_archive_tree_type[P: pydantic.BaseModel]( 

442 pointer_type: type[P], 

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

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

445 type that uses the given pointer type. 

446 """ 

447 return TransformSerializationModel[pointer_type] # type: ignore 

448 

449 @staticmethod 

450 def from_legacy( 

451 legacy: LegacyTransform, 

452 in_frame: I, 

453 out_frame: O, 

454 in_bounds: Bounds | None = None, 

455 out_bounds: Bounds | None = None, 

456 ) -> Transform[I, O]: 

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

458 

459 Parameters 

460 ---------- 

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

462 Legacy transform object. 

463 in_frame 

464 Coordinate frame for input points to the forward transform. 

465 out_frame 

466 Coordinate frame for output points from the forward transform. 

467 in_bounds 

468 The region that bounds valid input points. 

469 out_bounds 

470 The region that bounds valid output points. 

471 """ 

472 return Transform( 

473 in_frame, 

474 out_frame, 

475 legacy.getMapping(), 

476 in_bounds=in_bounds, 

477 out_bounds=out_bounds, 

478 ) 

479 

480 def to_legacy(self) -> LegacyTransform: 

481 """Convert to a legacy `lsst.afw.geom.TransformPoint2ToPoint2` 

482 instance. 

483 """ 

484 from lsst.afw.geom import TransformPoint2ToPoint2 as LegacyTransform 

485 

486 return LegacyTransform(self._ast_mapping, False) 

487 

488 def _get_ast_frame_set(self) -> Any: 

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

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

491 return ast_frame_set 

492 

493 

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

495 # TODO: add bounds argument and check inputs 

496 # TODO: broadcast arrays with different shapes. 

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

498 xy_out = method(xy_in) 

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

500 

501 

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

503 ast_output_frame_id = ast_frame_set.current 

504 ast_frame_set.addFrame( 

505 astshim.FrameSet.BASE, 

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

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

508 ) 

509 ast_frame_set.base = ast_frame_set.current 

510 ast_frame_set.current = ast_output_frame_id 

511 

512 

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

514 if frame is SkyFrame.ICRS: 

515 return astshim.SkyFrame("") 

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

517 if frame.unit is not None: 517 ↛ 521line 517 didn't jump to line 521 because the condition on line 517 was always true

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

519 ast_frame.setUnit(1, fits_unit) 

520 ast_frame.setUnit(2, fits_unit) 

521 ast_frame.setLabel(1, "x") 

522 ast_frame.setLabel(2, "y") 

523 return ast_frame 

524 

525 

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

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

528 

529 

530class MappingSerializationModel(pydantic.BaseModel): 

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

532 

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

534 

535 

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

537 """Serialization model for coordinate transforms.""" 

538 

539 SCHEMA_NAME: ClassVar[str] = "transform" 

540 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

541 MIN_READ_VERSION: ClassVar[int] = 1 

542 PUBLIC_TYPE: ClassVar[type] = Transform 

543 

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

545 default_factory=list, 

546 description=textwrap.dedent( 

547 """ 

548 List of frames that this transform passes through. 

549 

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

551 intermediate frames may be included to facilitate data-sharing 

552 between transforms. 

553 """ 

554 ), 

555 ) 

556 

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

558 default_factory=list, 

559 description=textwrap.dedent( 

560 """ 

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

562 

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

564 """ 

565 ), 

566 ) 

567 

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

569 default_factory=list, 

570 description=textwrap.dedent( 

571 """ 

572 The actual mappings between frames, or archive pointers to 

573 serialized FrameSet objects from which they can be obtained. 

574 

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

576 """ 

577 ), 

578 ) 

579 

580 def deserialize(self, archive: InputArchive[P], **kwargs: Any) -> Transform[Any, Any]: 

581 """Deserialize a transform from an archive. 

582 

583 Parameters 

584 ---------- 

585 archive 

586 Archive to read from. 

587 **kwargs 

588 Unsupported keyword arguments are accepted only to provide better 

589 error messages (raising `serialization.InvalidParameterError`). 

590 """ 

591 if kwargs: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true

592 raise InvalidParameterError(f"Unrecognized parameters for Transform: {set(kwargs.keys())}.") 

593 if len(self.frames) != len(self.bounds): 593 ↛ 594line 593 didn't jump to line 594 because the condition on line 593 was never true

594 raise ArchiveReadError( 

595 f"Inconsistent lengths for 'frames' ({len(self.frames)}) and 'bounds' ({len(self.bounds)})." 

596 ) 

597 if len(self.frames) != len(self.mappings) + 1: 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true

598 raise ArchiveReadError( 

599 f"Inconsistent lengths for 'frames' ({len(self.frames)}) and " 

600 f"'mappings' ({len(self.mappings)}; should be one less)." 

601 ) 

602 # We can't just compose onto an identity Transform if we want to 

603 # preserve the FrameSet-ness of any of these mappings. 

604 transform: Transform | None = None 

605 for n, mapping in enumerate(self.mappings): 

606 match mapping: 

607 case MappingSerializationModel(ast=serialized_mapping): 607 ↛ 618line 607 didn't jump to line 618 because the pattern on line 607 always matched

608 ast_mapping = astshim.Mapping.fromString(serialized_mapping) 

609 in_bounds = self.bounds[n] 

610 out_bounds = self.bounds[n + 1] 

611 new_transform = Transform( 

612 self.frames[n].deserialize(), 

613 self.frames[n + 1].deserialize(), 

614 ast_mapping, 

615 in_bounds.deserialize() if in_bounds is not None else None, 

616 out_bounds.deserialize() if out_bounds is not None else None, 

617 ) 

618 case reference: 

619 frame_set = archive.get_frame_set(reference) 

620 new_transform = frame_set[self.frames[n].deserialize(), self.frames[n + 1].deserialize()] 

621 if transform is None: 621 ↛ 624line 621 didn't jump to line 624 because the condition on line 621 was always true

622 transform = new_transform 

623 else: 

624 transform = transform.then(new_transform) 

625 if transform is None: 625 ↛ 626line 625 didn't jump to line 626 because the condition on line 625 was never true

626 transform = Transform.identity(self.frames[0].deserialize()) 

627 return transform