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

140 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-01 09:12 +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 ..serialization import Unit 

35from ..utils import is_none 

36 

37 

38class Frame(Protocol): 

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

40 

41 @property 

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

43 """Units of the coordinates in this frame 

44 (`astropy.units.UnitBase`). 

45 """ 

46 ... 

47 

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

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

50 

51 Parameters 

52 ---------- 

53 x 

54 ``x`` coordinate values to standardize. 

55 """ 

56 ... 

57 

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

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

60 

61 Parameters 

62 ---------- 

63 y 

64 ``y`` coordinate values to standardize. 

65 """ 

66 ... 

67 

68 def serialize(self) -> SerializableFrame: 

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

70 

71 Notes 

72 ----- 

73 The returned object must support direct nesting with Pydantic models 

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

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

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

77 natively serializable. 

78 """ 

79 ... 

80 

81 @property 

82 def _ast_ident(self) -> str: 

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

84 ... 

85 

86 

87@final 

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

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

90 

91 Notes 

92 ----- 

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

94 overscan regions still present). 

95 """ 

96 

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

98 visit: int | None = pydantic.Field( 

99 default=None, 

100 description=( 

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

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

103 ), 

104 exclude_if=is_none, 

105 ) 

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

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

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

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

110 ) 

111 

112 @property 

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

114 """Units of the coordinates in this frame 

115 (`astropy.units.UnitBase`). 

116 """ 

117 return u.pix 

118 

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

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

121 

122 Parameters 

123 ---------- 

124 x 

125 ``x`` coordinate values to standardize. 

126 """ 

127 return x 

128 

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

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

131 

132 Parameters 

133 ---------- 

134 y 

135 ``y`` coordinate values to standardize. 

136 """ 

137 return y 

138 

139 def serialize(self) -> DetectorFrame: 

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

141 return self 

142 

143 def deserialize(self) -> DetectorFrame: 

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

145 return self 

146 

147 @property 

148 def _ast_ident(self) -> str: 

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

150 

151 

152@final 

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

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

155 

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

157 visit: int | None = pydantic.Field( 

158 default=None, 

159 description=( 

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

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

162 ), 

163 exclude_if=is_none, 

164 ) 

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

166 

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

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

169 ) 

170 

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

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

173 

174 Parameters 

175 ---------- 

176 x 

177 Coordinates to standardize. 

178 """ 

179 return x 

180 

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

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

183 

184 Parameters 

185 ---------- 

186 y 

187 Coordinates to standardize. 

188 """ 

189 return y 

190 

191 def serialize(self) -> FocalPlaneFrame: 

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

193 return self 

194 

195 def deserialize(self) -> FocalPlaneFrame: 

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

197 return self 

198 

199 @property 

200 def _ast_ident(self) -> str: 

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

202 

203 

204@final 

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

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

207 boresight. 

208 

209 Notes 

210 ----- 

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

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

213 """ 

214 

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

216 visit: int | None = pydantic.Field( 

217 default=None, 

218 description=( 

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

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

221 ), 

222 exclude_if=is_none, 

223 ) 

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

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

226 ) 

227 

228 @property 

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

230 """Units of the coordinates in this frame 

231 (`astropy.units.UnitBase`). 

232 """ 

233 return u.rad 

234 

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

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

237 

238 Parameters 

239 ---------- 

240 x 

241 Coordinates to standardize. 

242 """ 

243 return _wrap_symmetric(x) 

244 

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

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

247 

248 Parameters 

249 ---------- 

250 y 

251 Coordinates to standardize. 

252 """ 

253 return _wrap_symmetric(y) 

254 

255 def serialize(self) -> FieldAngleFrame: 

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

257 return self 

258 

259 def deserialize(self) -> FieldAngleFrame: 

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

261 return self 

262 

263 @property 

264 def _ast_ident(self) -> str: 

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

266 

267 

268@final 

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

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

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

272 a common pixel grid. 

273 """ 

274 

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

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

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

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

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

280 ) 

281 

282 @property 

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

284 """Units of the coordinates in this frame 

285 (`astropy.units.UnitBase`). 

286 """ 

287 return u.pix 

288 

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

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

291 

292 Parameters 

293 ---------- 

294 x 

295 Coordinates to standardize. 

296 """ 

297 return x 

298 

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

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

301 

302 Parameters 

303 ---------- 

304 y 

305 Coordinates to standardize. 

306 """ 

307 return y 

308 

309 def serialize(self) -> TractFrame: 

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

311 return self 

312 

313 def deserialize(self) -> TractFrame: 

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

315 return self 

316 

317 @property 

318 def _ast_ident(self) -> str: 

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

320 

321 

322@final 

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

324 """An arbitrary Euclidean coordinate system.""" 

325 

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

327 

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

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

330 ) 

331 

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

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

334 

335 Parameters 

336 ---------- 

337 x 

338 Coordinates to standardize. 

339 """ 

340 return x 

341 

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

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

344 

345 Parameters 

346 ---------- 

347 y 

348 Coordinates to standardize. 

349 """ 

350 return y 

351 

352 def serialize(self) -> GeneralFrame: 

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

354 return self 

355 

356 def deserialize(self) -> GeneralFrame: 

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

358 return self 

359 

360 @property 

361 def _ast_ident(self) -> str: 

362 return "GENERAL" 

363 

364 

365class SkyFrame(enum.StrEnum): 

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

367 

368 ICRS = "ICRS" 

369 

370 @property 

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

372 """Units of the coordinates in this frame 

373 (`astropy.units.UnitBase`). 

374 """ 

375 return u.rad 

376 

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

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

379 

380 Parameters 

381 ---------- 

382 x 

383 Coordinates to standardize. 

384 """ 

385 return _wrap_positive(x) 

386 

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

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

389 

390 Parameters 

391 ---------- 

392 y 

393 Coordinates to standardize. 

394 """ 

395 return _wrap_symmetric(y) 

396 

397 def serialize(self) -> SkyFrame: 

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

399 return self 

400 

401 def deserialize(self) -> SkyFrame: 

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

403 return self 

404 

405 @property 

406 def _ast_ident(self) -> str: 

407 return self.value 

408 

409 

410ICRS = SkyFrame.ICRS 

411 

412 

413type SerializableFrame = ( 

414 SkyFrame 

415 | Annotated[ 

416 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame, 

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

418 ] 

419) 

420 

421 

422_TWOPI: float = np.pi * 2 

423 

424 

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

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

427 

428 

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

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

431 

432 

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

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