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

149 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-07 08:34 +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, Self, cast, final 

28 

29import astropy.units as u 

30import numpy as np 

31import pydantic 

32 

33from .._geom import Box 

34from ..serialization import ArchiveTree, 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 # At present all Frames are members of the same serializable Union, 

57 # so their serialized form is just the original frame. But this may not 

58 # always be the case. 

59 

60 def serialize(self) -> SerializableFrame: 

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

62 ... 

63 

64 @classmethod 

65 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

67 return cast(Self, serialized) 

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(ArchiveTree, 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) -> SerializableFrame: 

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

117 return cast(SerializableFrame, self) 

118 

119 @classmethod 

120 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

122 return cast(Self, serialized) 

123 

124 @property 

125 def _ast_ident(self) -> str: 

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

127 

128 

129@final 

130class FocalPlaneFrame(ArchiveTree, frozen=True): 

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

132 

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

134 visit: int | None = pydantic.Field( 

135 default=None, 

136 description=( 

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

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

139 ), 

140 exclude_if=is_none, 

141 ) 

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

143 

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

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

146 ) 

147 

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

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

150 return x 

151 

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

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

154 return y 

155 

156 def serialize(self) -> SerializableFrame: 

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

158 return cast(SerializableFrame, self) 

159 

160 @classmethod 

161 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

163 return cast(Self, serialized) 

164 

165 @property 

166 def _ast_ident(self) -> str: 

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

168 

169 

170@final 

171class FieldAngleFrame(ArchiveTree, frozen=True): 

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

173 boresight. 

174 

175 Notes 

176 ----- 

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

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

179 """ 

180 

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

182 visit: int | None = pydantic.Field( 

183 default=None, 

184 description=( 

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

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

187 ), 

188 exclude_if=is_none, 

189 ) 

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

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

192 ) 

193 

194 @property 

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

196 """Units of the coordinates in this frame 

197 (`astropy.units.UnitBase`). 

198 """ 

199 return u.rad 

200 

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

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

203 return _wrap_symmetric(x) 

204 

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

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

207 return _wrap_symmetric(y) 

208 

209 def serialize(self) -> SerializableFrame: 

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

211 return cast(SerializableFrame, self) 

212 

213 @classmethod 

214 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

216 return cast(Self, serialized) 

217 

218 @property 

219 def _ast_ident(self) -> str: 

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

221 

222 

223@final 

224class TractFrame(ArchiveTree, frozen=True): 

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

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

227 a common pixel grid. 

228 """ 

229 

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

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

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

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

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

235 ) 

236 

237 @property 

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

239 """Units of the coordinates in this frame 

240 (`astropy.units.UnitBase`). 

241 """ 

242 return u.pix 

243 

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

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

246 return x 

247 

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

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

250 return y 

251 

252 def serialize(self) -> SerializableFrame: 

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

254 return cast(SerializableFrame, self) 

255 

256 @classmethod 

257 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

259 return cast(Self, serialized) 

260 

261 @property 

262 def _ast_ident(self) -> str: 

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

264 

265 

266@final 

267class GeneralFrame(ArchiveTree, frozen=True): 

268 """An arbitrary Euclidean coordinate system.""" 

269 

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

271 

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

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

274 ) 

275 

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

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

278 return x 

279 

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

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

282 return y 

283 

284 def serialize(self) -> SerializableFrame: 

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

286 return cast(SerializableFrame, self) 

287 

288 @classmethod 

289 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

291 return cast(Self, serialized) 

292 

293 @property 

294 def _ast_ident(self) -> str: 

295 return "GENERAL" 

296 

297 

298class SkyFrame(enum.StrEnum): 

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

300 

301 ICRS = "ICRS" 

302 

303 @property 

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

305 """Units of the coordinates in this frame 

306 (`astropy.units.UnitBase`). 

307 """ 

308 return u.rad 

309 

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

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

312 return _wrap_positive(x) 

313 

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

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

316 return _wrap_symmetric(y) 

317 

318 def serialize(self) -> SerializableFrame: 

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

320 return cast(SerializableFrame, self) 

321 

322 @classmethod 

323 def deserialize(cls, serialized: SerializableFrame) -> Self: 

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

325 return cast(Self, serialized) 

326 

327 @property 

328 def _ast_ident(self) -> str: 

329 return self.value 

330 

331 

332ICRS = SkyFrame.ICRS 

333 

334 

335type SerializableFrame = ( 

336 SkyFrame 

337 | Annotated[ 

338 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame | GeneralFrame, 

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

340 ] 

341) 

342 

343 

344_TWOPI: float = np.pi * 2 

345 

346 

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

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

349 

350 

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

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

353 

354 

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

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