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

140 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-29 08:43 +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 

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

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

54 ... 

55 

56 def serialize(self) -> SerializableFrame: 

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

58 

59 Notes 

60 ----- 

61 The returned object must support direct nesting with Pydantic models 

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

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

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

65 natively serializable. 

66 """ 

67 ... 

68 

69 @property 

70 def _ast_ident(self) -> str: 

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

72 ... 

73 

74 

75@final 

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

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

78 

79 Notes 

80 ----- 

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

82 overscan regions still present). 

83 """ 

84 

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

86 visit: int | None = pydantic.Field( 

87 default=None, 

88 description=( 

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

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

91 ), 

92 exclude_if=is_none, 

93 ) 

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

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

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

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

98 ) 

99 

100 @property 

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

102 """Units of the coordinates in this frame 

103 (`astropy.units.UnitBase`). 

104 """ 

105 return u.pix 

106 

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

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

109 return x 

110 

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

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

113 return y 

114 

115 def serialize(self) -> DetectorFrame: 

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

117 return self 

118 

119 def deserialize(self) -> DetectorFrame: 

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

121 return self 

122 

123 @property 

124 def _ast_ident(self) -> str: 

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

126 

127 

128@final 

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

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

131 

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

133 visit: int | None = pydantic.Field( 

134 default=None, 

135 description=( 

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

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

138 ), 

139 exclude_if=is_none, 

140 ) 

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

142 

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

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

145 ) 

146 

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

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

149 return x 

150 

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

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

153 return y 

154 

155 def serialize(self) -> FocalPlaneFrame: 

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

157 return self 

158 

159 def deserialize(self) -> FocalPlaneFrame: 

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

161 return self 

162 

163 @property 

164 def _ast_ident(self) -> str: 

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

166 

167 

168@final 

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

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

171 boresight. 

172 

173 Notes 

174 ----- 

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

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

177 """ 

178 

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

180 visit: int | None = pydantic.Field( 

181 default=None, 

182 description=( 

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

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

185 ), 

186 exclude_if=is_none, 

187 ) 

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

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

190 ) 

191 

192 @property 

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

194 """Units of the coordinates in this frame 

195 (`astropy.units.UnitBase`). 

196 """ 

197 return u.rad 

198 

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

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

201 return _wrap_symmetric(x) 

202 

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

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

205 return _wrap_symmetric(y) 

206 

207 def serialize(self) -> FieldAngleFrame: 

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

209 return self 

210 

211 def deserialize(self) -> FieldAngleFrame: 

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

213 return self 

214 

215 @property 

216 def _ast_ident(self) -> str: 

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

218 

219 

220@final 

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

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

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

224 a common pixel grid. 

225 """ 

226 

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

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

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

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

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

232 ) 

233 

234 @property 

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

236 """Units of the coordinates in this frame 

237 (`astropy.units.UnitBase`). 

238 """ 

239 return u.pix 

240 

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

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

243 return x 

244 

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

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

247 return y 

248 

249 def serialize(self) -> TractFrame: 

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

251 return self 

252 

253 def deserialize(self) -> TractFrame: 

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

255 return self 

256 

257 @property 

258 def _ast_ident(self) -> str: 

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

260 

261 

262@final 

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

264 """An arbitrary Euclidean coordinate system.""" 

265 

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

267 

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

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

270 ) 

271 

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

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

274 return x 

275 

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

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

278 return y 

279 

280 def serialize(self) -> GeneralFrame: 

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

282 return self 

283 

284 def deserialize(self) -> GeneralFrame: 

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

286 return self 

287 

288 @property 

289 def _ast_ident(self) -> str: 

290 return "GENERAL" 

291 

292 

293class SkyFrame(enum.StrEnum): 

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

295 

296 ICRS = "ICRS" 

297 

298 @property 

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

300 """Units of the coordinates in this frame 

301 (`astropy.units.UnitBase`). 

302 """ 

303 return u.rad 

304 

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

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

307 return _wrap_positive(x) 

308 

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

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

311 return _wrap_symmetric(y) 

312 

313 def serialize(self) -> SkyFrame: 

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

315 return self 

316 

317 def deserialize(self) -> SkyFrame: 

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

319 return self 

320 

321 @property 

322 def _ast_ident(self) -> str: 

323 return self.value 

324 

325 

326ICRS = SkyFrame.ICRS 

327 

328 

329type SerializableFrame = ( 

330 SkyFrame 

331 | Annotated[ 

332 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame, 

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

334 ] 

335) 

336 

337 

338_TWOPI: float = np.pi * 2 

339 

340 

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

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

343 

344 

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

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

347 

348 

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

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