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

133 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-23 08:41 +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 "SerializableFrame", 

21 "SkyFrame", 

22 "TractFrame", 

23) 

24 

25import enum 

26from typing import Annotated, Literal, Protocol, Self, cast, final 

27 

28import astropy.units as u 

29import numpy as np 

30import pydantic 

31 

32from .._geom import Box 

33from ..serialization import ArchiveTree, Unit 

34from ..utils import is_none 

35 

36 

37class Frame(Protocol): 

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

39 

40 @property 

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

42 """Units of the coordinates in this frame 

43 (`astropy.units.UnitBase`). 

44 """ 

45 ... 

46 

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

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

49 ... 

50 

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

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

53 ... 

54 

55 # At present all Frames are members of the same serializable Union, 

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

57 # always be the case. 

58 

59 def serialize(self) -> SerializableFrame: 

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

61 ... 

62 

63 @classmethod 

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

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

66 return cast(Self, serialized) 

67 

68 @property 

69 def _ast_ident(self) -> str: 

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

71 ... 

72 

73 

74@final 

75class DetectorFrame(ArchiveTree, frozen=True): 

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

77 

78 Notes 

79 ----- 

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

81 overscan regions still present). 

82 """ 

83 

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

85 visit: int | None = pydantic.Field( 

86 default=None, 

87 description=( 

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

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

90 ), 

91 exclude_if=is_none, 

92 ) 

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

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

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

96 default="DETECTOR", description="Descriminator for the frame type." 

97 ) 

98 

99 @property 

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

101 """Units of the coordinates in this frame 

102 (`astropy.units.UnitBase`). 

103 """ 

104 return u.pix 

105 

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

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

108 return x 

109 

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

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

112 return y 

113 

114 def serialize(self) -> SerializableFrame: 

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

116 return cast(SerializableFrame, self) 

117 

118 @classmethod 

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

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

121 return cast(Self, serialized) 

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(ArchiveTree, frozen=True): 

130 """A Euclidian 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="Descriminator 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) -> SerializableFrame: 

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

157 return cast(SerializableFrame, self) 

158 

159 @classmethod 

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

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

162 return cast(Self, serialized) 

163 

164 @property 

165 def _ast_ident(self) -> str: 

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

167 

168 

169@final 

170class FieldAngleFrame(ArchiveTree, frozen=True): 

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

172 boresight. 

173 

174 Notes 

175 ----- 

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

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

178 """ 

179 

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

181 visit: int | None = pydantic.Field( 

182 default=None, 

183 description=( 

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

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

186 ), 

187 exclude_if=is_none, 

188 ) 

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

190 default="FIELD_ANGLE", description="Descriminator for the frame type." 

191 ) 

192 

193 @property 

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

195 """Units of the coordinates in this frame 

196 (`astropy.units.UnitBase`). 

197 """ 

198 return u.rad 

199 

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

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

202 return _wrap_symmetric(x) 

203 

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

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

206 return _wrap_symmetric(y) 

207 

208 def serialize(self) -> SerializableFrame: 

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

210 return cast(SerializableFrame, self) 

211 

212 @classmethod 

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

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

215 return cast(Self, serialized) 

216 

217 @property 

218 def _ast_ident(self) -> str: 

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

220 

221 

222@final 

223class TractFrame(ArchiveTree, frozen=True): 

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

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

226 a common pixel grid. 

227 """ 

228 

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

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

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

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

233 default="TRACT", description="Descriminator for the frame type." 

234 ) 

235 

236 @property 

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

238 """Units of the coordinates in this frame 

239 (`astropy.units.UnitBase`). 

240 """ 

241 return u.pix 

242 

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

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

245 return x 

246 

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

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

249 return y 

250 

251 def serialize(self) -> SerializableFrame: 

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

253 return cast(SerializableFrame, self) 

254 

255 @classmethod 

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

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

258 return cast(Self, serialized) 

259 

260 @property 

261 def _ast_ident(self) -> str: 

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

263 

264 

265class SkyFrame(enum.StrEnum): 

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

267 

268 ICRS = "ICRS" 

269 

270 @property 

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

272 """Units of the coordinates in this frame 

273 (`astropy.units.UnitBase`). 

274 """ 

275 return u.rad 

276 

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

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

279 return _wrap_positive(x) 

280 

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

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

283 return _wrap_symmetric(y) 

284 

285 def serialize(self) -> SerializableFrame: 

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

287 return cast(SerializableFrame, self) 

288 

289 @classmethod 

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

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

292 return cast(Self, serialized) 

293 

294 @property 

295 def _ast_ident(self) -> str: 

296 return self.value 

297 

298 

299ICRS = SkyFrame.ICRS 

300 

301 

302type SerializableFrame = ( 

303 SkyFrame 

304 | Annotated[ 

305 DetectorFrame | TractFrame | FocalPlaneFrame | FieldAngleFrame, 

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

307 ] 

308) 

309 

310 

311_TWOPI: float = np.pi * 2 

312 

313 

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

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

316 

317 

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

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

320 

321 

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

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