Coverage for python/lsst/images/_transforms/_frames.py: 18%

154 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +0000

1# This file is part of lsst-images. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "ICRS", 

16 "DetectorFrame", 

17 "FieldAngleFrame", 

18 "FocalPlaneFrame", 

19 "Frame", 

20 "GeneralFrame", 

21 "SerializableFrame", 

22 "SkyFrame", 

23 "TractFrame", 

24) 

25 

26import enum 

27from typing import Annotated, Literal, Protocol, final 

28 

29import astropy.units as u 

30import numpy as np 

31import pydantic 

32 

33from .._geom import Box 

34from ..describe import DescribableMixin, DescribeOptions, FieldRole, Report, ReportField 

35from ..serialization import Unit 

36from ..utils import is_none 

37 

38 

39def _frame_report(frame: pydantic.BaseModel) -> Report: 

40 """Return a `Report` describing a pydantic frame model. 

41 

42 Parameters 

43 ---------- 

44 frame 

45 A frozen pydantic model representing a coordinate frame. 

46 """ 

47 # These are the model's own fields, so they are constructor arguments even 

48 # though pydantic's repr, not the report, is what currently renders them. 

49 fields = [ 

50 ReportField(label=name, value=getattr(frame, name), role=FieldRole.ARG) 

51 for name in type(frame).model_fields 

52 ] 

53 return Report(type_name=type(frame).__name__, fields=fields) 

54 

55 

56class Frame(Protocol): 

57 """A description of a coordinate system.""" 

58 

59 @property 

60 def unit(self) -> u.UnitBase: 

61 """Units of the coordinates in this frame 

62 (`astropy.units.UnitBase`). 

63 """ 

64 ... 

65 

66 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

67 """Coerce ``x`` coordinates into their standard range. 

68 

69 Parameters 

70 ---------- 

71 x 

72 ``x`` coordinate values to standardize. 

73 """ 

74 ... 

75 

76 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

77 """Coerce ``y`` coordinates into their standard range. 

78 

79 Parameters 

80 ---------- 

81 y 

82 ``y`` coordinate values to standardize. 

83 """ 

84 ... 

85 

86 def serialize(self) -> SerializableFrame: 

87 """Return a Pydantic-serializable version of this Frame. 

88 

89 Notes 

90 ----- 

91 The returned object must support direct nesting with Pydantic models 

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

93 back to this `Frame` type. It is common for `serialize` and 

94 ``deserialize`` to just return ``self``, when the frame object is 

95 natively serializable. 

96 """ 

97 ... 

98 

99 @property 

100 def _ast_ident(self) -> str: 

101 """String to use as the 'Ident' attribute of an AST Frame.""" 

102 ... 

103 

104 

105@final 

106class DetectorFrame(pydantic.BaseModel, DescribableMixin, frozen=True): 

107 """A coordinate frame for a particular detector's pixels. 

108 

109 Notes 

110 ----- 

111 This frame is only used for post-assembly images (i.e. not those with 

112 overscan regions still present). 

113 """ 

114 

115 instrument: str = pydantic.Field(description="Name of the instrument.") 

116 visit: int | None = pydantic.Field( 

117 default=None, 

118 description=( 

119 "ID of the visit. May be unset in contexts where there " 

120 "is no visit or only a single relevant visit." 

121 ), 

122 exclude_if=is_none, 

123 ) 

124 detector: int = pydantic.Field(description="ID of the detector.") 

125 bbox: Box = pydantic.Field(description="Bounding box of the detector.") 

126 frame_type: Literal["DETECTOR"] = pydantic.Field( 

127 default="DETECTOR", description="Discriminator for the frame type." 

128 ) 

129 

130 @property 

131 def unit(self) -> u.UnitBase: 

132 """Units of the coordinates in this frame 

133 (`astropy.units.UnitBase`). 

134 """ 

135 return u.pix 

136 

137 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

138 """Coerce ``x`` coordinates into their standard range. 

139 

140 Parameters 

141 ---------- 

142 x 

143 ``x`` coordinate values to standardize. 

144 """ 

145 return x 

146 

147 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

148 """Coerce ``y`` coordinates into their standard range. 

149 

150 Parameters 

151 ---------- 

152 y 

153 ``y`` coordinate values to standardize. 

154 """ 

155 return y 

156 

157 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

158 """Return a `Report` describing this detector frame. 

159 

160 Parameters 

161 ---------- 

162 options : `DescribeOptions`, optional 

163 Unused; accepted for interface compatibility. 

164 """ 

165 return _frame_report(self) 

166 

167 def serialize(self) -> DetectorFrame: 

168 """Return a Pydantic-serializable version of this Frame.""" 

169 return self 

170 

171 def deserialize(self) -> DetectorFrame: 

172 """Convert a serialized frame to an in-memory one.""" 

173 return self 

174 

175 @property 

176 def _ast_ident(self) -> str: 

177 return f"{_camera_ast_ident(self.instrument, self.visit)}/DETECTOR_{self.detector:03d}" 

178 

179 

180@final 

181class FocalPlaneFrame(pydantic.BaseModel, DescribableMixin, frozen=True): 

182 """A Euclidean coordinate frame for the focal plane of a camera.""" 

183 

184 instrument: str = pydantic.Field(description="Name of the instrument.") 

185 visit: int | None = pydantic.Field( 

186 default=None, 

187 description=( 

188 "ID of the visit. May be unset in contexts where there " 

189 "is no visit or only a relevant single visit." 

190 ), 

191 exclude_if=is_none, 

192 ) 

193 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.") 

194 

195 frame_type: Literal["FOCAL_PLANE"] = pydantic.Field( 

196 default="FOCAL_PLANE", description="Discriminator for the frame type." 

197 ) 

198 

199 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

200 """Coerce ``x`` coordinates into their standard range. 

201 

202 Parameters 

203 ---------- 

204 x 

205 Coordinates to standardize. 

206 """ 

207 return x 

208 

209 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

210 """Coerce ``y`` coordinates into their standard range. 

211 

212 Parameters 

213 ---------- 

214 y 

215 Coordinates to standardize. 

216 """ 

217 return y 

218 

219 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

220 """Return a `Report` describing this focal plane frame. 

221 

222 Parameters 

223 ---------- 

224 options : `DescribeOptions`, optional 

225 Unused; accepted for interface compatibility. 

226 """ 

227 return _frame_report(self) 

228 

229 def serialize(self) -> FocalPlaneFrame: 

230 """Return a Pydantic-serializable version of this Frame.""" 

231 return self 

232 

233 def deserialize(self) -> FocalPlaneFrame: 

234 """Convert a serialized frame to an in-memory one.""" 

235 return self 

236 

237 @property 

238 def _ast_ident(self) -> str: 

239 return f"{_camera_ast_ident(self.instrument, self.visit)}/FOCAL_PLANE" 

240 

241 

242@final 

243class FieldAngleFrame(pydantic.BaseModel, DescribableMixin, frozen=True): 

244 """An angular coordinate frame that maps a camera onto the sky about its 

245 boresight. 

246 

247 Notes 

248 ----- 

249 The transform between a `FocalPlaneFrame` and a `FieldAngleFrame` includes 

250 optical distortions but no rotation. It may include a parity flip. 

251 """ 

252 

253 instrument: str = pydantic.Field(description="Name of the instrument.") 

254 visit: int | None = pydantic.Field( 

255 default=None, 

256 description=( 

257 "ID of the visit. May be unset in contexts where there " 

258 "is no visit or only a relevant single visit." 

259 ), 

260 exclude_if=is_none, 

261 ) 

262 frame_type: Literal["FIELD_ANGLE"] = pydantic.Field( 

263 default="FIELD_ANGLE", description="Discriminator for the frame type." 

264 ) 

265 

266 @property 

267 def unit(self) -> u.UnitBase: 

268 """Units of the coordinates in this frame 

269 (`astropy.units.UnitBase`). 

270 """ 

271 return u.rad 

272 

273 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

274 """Coerce ``x`` coordinates into their standard range. 

275 

276 Parameters 

277 ---------- 

278 x 

279 Coordinates to standardize. 

280 """ 

281 return _wrap_symmetric(x) 

282 

283 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

284 """Coerce ``y`` coordinates into their standard range. 

285 

286 Parameters 

287 ---------- 

288 y 

289 Coordinates to standardize. 

290 """ 

291 return _wrap_symmetric(y) 

292 

293 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

294 """Return a `Report` describing this field angle frame. 

295 

296 Parameters 

297 ---------- 

298 options : `DescribeOptions`, optional 

299 Unused; accepted for interface compatibility. 

300 """ 

301 return _frame_report(self) 

302 

303 def serialize(self) -> FieldAngleFrame: 

304 """Return a Pydantic-serializable version of this Frame.""" 

305 return self 

306 

307 def deserialize(self) -> FieldAngleFrame: 

308 """Convert a serialized frame to an in-memory one.""" 

309 return self 

310 

311 @property 

312 def _ast_ident(self) -> str: 

313 return f"{_camera_ast_ident(self.instrument, self.visit)}/FIELD_ANGLE" 

314 

315 

316@final 

317class TractFrame(pydantic.BaseModel, DescribableMixin, frozen=True): 

318 """The pixel coordinates of a tract: a region on the sky used for 

319 coaddition, defined by a 'skymap' and split into 'patches' that share 

320 a common pixel grid. 

321 """ 

322 

323 skymap: str = pydantic.Field(description="Name of the skymap.") 

324 tract: int = pydantic.Field(description="ID of the tract within its skymap.") 

325 bbox: Box = pydantic.Field(description="Bounding box of the full tract.") 

326 frame_type: Literal["TRACT"] = pydantic.Field( 

327 default="TRACT", description="Discriminator for the frame type." 

328 ) 

329 

330 @property 

331 def unit(self) -> u.UnitBase: 

332 """Units of the coordinates in this frame 

333 (`astropy.units.UnitBase`). 

334 """ 

335 return u.pix 

336 

337 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

338 """Coerce ``x`` coordinates into their standard range. 

339 

340 Parameters 

341 ---------- 

342 x 

343 Coordinates to standardize. 

344 """ 

345 return x 

346 

347 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

348 """Coerce ``y`` coordinates into their standard range. 

349 

350 Parameters 

351 ---------- 

352 y 

353 Coordinates to standardize. 

354 """ 

355 return y 

356 

357 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

358 """Return a `Report` describing this tract frame. 

359 

360 Parameters 

361 ---------- 

362 options : `DescribeOptions`, optional 

363 Unused; accepted for interface compatibility. 

364 """ 

365 return _frame_report(self) 

366 

367 def serialize(self) -> TractFrame: 

368 """Return a Pydantic-serializable version of this Frame.""" 

369 return self 

370 

371 def deserialize(self) -> TractFrame: 

372 """Convert a serialized frame to an in-memory one.""" 

373 return self 

374 

375 @property 

376 def _ast_ident(self) -> str: 

377 return f"{self.skymap}@{self.tract}" 

378 

379 

380@final 

381class GeneralFrame(pydantic.BaseModel, DescribableMixin, frozen=True): 

382 """An arbitrary Euclidean coordinate system.""" 

383 

384 unit: Unit = pydantic.Field(description="Units of the coordinates in this frame.") 

385 

386 frame_type: Literal["GENERAL"] = pydantic.Field( 

387 default="GENERAL", description="Discriminator for the frame type." 

388 ) 

389 

390 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

391 """Coerce ``x`` coordinates into their standard range. 

392 

393 Parameters 

394 ---------- 

395 x 

396 Coordinates to standardize. 

397 """ 

398 return x 

399 

400 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

401 """Coerce ``y`` coordinates into their standard range. 

402 

403 Parameters 

404 ---------- 

405 y 

406 Coordinates to standardize. 

407 """ 

408 return y 

409 

410 def _describe(self, options: DescribeOptions = DescribeOptions(), /) -> Report: 

411 """Return a `Report` describing this general frame. 

412 

413 Parameters 

414 ---------- 

415 options : `DescribeOptions`, optional 

416 Unused; accepted for interface compatibility. 

417 """ 

418 return _frame_report(self) 

419 

420 def serialize(self) -> GeneralFrame: 

421 """Return a Pydantic-serializable version of this Frame.""" 

422 return self 

423 

424 def deserialize(self) -> GeneralFrame: 

425 """Convert a serialized frame to an in-memory one.""" 

426 return self 

427 

428 @property 

429 def _ast_ident(self) -> str: 

430 return "GENERAL" 

431 

432 

433class SkyFrame(enum.StrEnum): 

434 """The special frame that represents the sky, in ICRS coordinates.""" 

435 

436 ICRS = "ICRS" 

437 

438 @property 

439 def unit(self) -> u.UnitBase: 

440 """Units of the coordinates in this frame 

441 (`astropy.units.UnitBase`). 

442 """ 

443 return u.rad 

444 

445 def standardize_x[T: float | np.ndarray](self, x: T) -> T: 

446 """Coerce ``x`` coordinates into their standard range. 

447 

448 Parameters 

449 ---------- 

450 x 

451 Coordinates to standardize. 

452 """ 

453 return _wrap_positive(x) 

454 

455 def standardize_y[T: float | np.ndarray](self, y: T) -> T: 

456 """Coerce ``y`` coordinates into their standard range. 

457 

458 Parameters 

459 ---------- 

460 y 

461 Coordinates to standardize. 

462 """ 

463 return _wrap_symmetric(y) 

464 

465 def serialize(self) -> SkyFrame: 

466 """Return a Pydantic-serializable version of this Frame.""" 

467 return self 

468 

469 def deserialize(self) -> SkyFrame: 

470 """Convert a serialized frame to an in-memory one.""" 

471 return self 

472 

473 @property 

474 def _ast_ident(self) -> str: 

475 return self.value 

476 

477 

478ICRS = SkyFrame.ICRS 

479 

480 

481type SerializableFrame = ( 

482 SkyFrame 

483 | Annotated[ 

484 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame, 

485 pydantic.Field(discriminator="frame_type"), 

486 ] 

487) 

488 

489 

490_TWOPI: float = np.pi * 2 

491 

492 

493def _camera_ast_ident(instrument: str, visit: int | None) -> str: 

494 return f"{instrument}@{visit}" if visit is not None else instrument 

495 

496 

497def _wrap_positive[T: float | np.ndarray](a: T) -> T: 

498 return a % _TWOPI # type: ignore[return-value] 

499 

500 

501def _wrap_symmetric[T: float | np.ndarray](a: T) -> T: 

502 return (a + np.pi) % _TWOPI - np.pi # type: ignore[return-value]