Coverage for python / lsst / obs / base / yamlCamera.py: 11%

143 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:50 +0000

1# This file is part of obs_base. 

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# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22from __future__ import annotations 

23 

24from functools import lru_cache 

25from typing import TYPE_CHECKING, Any 

26 

27import numpy as np 

28import yaml 

29 

30import lsst.afw.cameraGeom as cameraGeom 

31import lsst.afw.geom as afwGeom 

32import lsst.geom as geom 

33from lsst.afw.cameraGeom import Amplifier, Camera, ReadoutCorner 

34 

35if TYPE_CHECKING: 

36 from lsst.afw.cameraGeom import CameraSys, DetectorConfig 

37 

38__all__ = ["makeCamera"] 

39 

40 

41@lru_cache 

42def makeCamera(cameraFile: str) -> Camera: 

43 """Construct an imaging camera (e.g. the LSST 3Gpix camera). 

44 

45 Parameters 

46 ---------- 

47 cameraFile : `str` 

48 Camera description YAML file. 

49 

50 Returns 

51 ------- 

52 camera : `lsst.afw.cameraGeom.Camera` 

53 The desired Camera 

54 """ 

55 with open(cameraFile) as fd: 

56 cameraParams = yaml.load(fd, Loader=yaml.CLoader) 

57 

58 cameraName = cameraParams["name"] 

59 

60 # 

61 # Handle distortion models. 

62 # 

63 plateScale = geom.Angle(cameraParams["plateScale"], geom.arcseconds) 

64 nativeSys = cameraGeom.CameraSys(cameraParams["transforms"].pop("nativeSys")) 

65 transforms = makeTransformDict(nativeSys, cameraParams["transforms"], plateScale) 

66 

67 ccdParams = cameraParams["CCDs"] 

68 detectorConfigList = makeDetectorConfigList(ccdParams) 

69 focalPlaneParity = cameraParams.get("focalPlaneParity", False) 

70 

71 amplifierDict = {} 

72 for ccdName, ccdValues in ccdParams.items(): 

73 amplifierDict[ccdName] = makeAmplifierList(ccdValues) 

74 

75 return makeCameraFromCatalogs( 

76 cameraName, 

77 detectorConfigList, 

78 nativeSys, 

79 transforms, 

80 amplifierDict, 

81 focalPlaneParity=focalPlaneParity, 

82 ) 

83 

84 

85def makeDetectorConfigList(ccdParams: dict[str, Any]) -> list[DetectorConfig]: 

86 """Make a list of detector configs. 

87 

88 Returns 

89 ------- 

90 detectorConfig : `list` of `lsst.afw.cameraGeom.DetectorConfig` 

91 A list of detector configs. 

92 """ 

93 detectorConfigs = [] 

94 for name, ccd in ccdParams.items(): 

95 detectorConfig = cameraGeom.DetectorConfig() 

96 detectorConfigs.append(detectorConfig) 

97 

98 detectorConfig.name = name 

99 detectorConfig.id = ccd["id"] 

100 detectorConfig.serial = ccd["serial"] 

101 detectorConfig.detectorType = ccd["detectorType"] 

102 if "physicalType" in ccd: 

103 detectorConfig.physicalType = ccd["physicalType"] 

104 # This is the orientation we need to put the serial direction along 

105 # the x-axis 

106 detectorConfig.bbox_x0, detectorConfig.bbox_y0 = ccd["bbox"][0] 

107 detectorConfig.bbox_x1, detectorConfig.bbox_y1 = ccd["bbox"][1] 

108 detectorConfig.pixelSize_x, detectorConfig.pixelSize_y = ccd["pixelSize"] 

109 detectorConfig.transformDict.nativeSys = ccd["transformDict"]["nativeSys"] 

110 transforms = ccd["transformDict"]["transforms"] 

111 detectorConfig.transformDict.transforms = None if transforms == "None" else transforms 

112 detectorConfig.refpos_x, detectorConfig.refpos_y = ccd["refpos"] 

113 if len(ccd["offset"]) == 2: 

114 detectorConfig.offset_x, detectorConfig.offset_y = ccd["offset"] 

115 detectorConfig.offset_z = 0.0 

116 else: 

117 detectorConfig.offset_x, detectorConfig.offset_y, detectorConfig.offset_z = ccd["offset"] 

118 detectorConfig.transposeDetector = ccd["transposeDetector"] 

119 detectorConfig.pitchDeg = ccd["pitch"] 

120 detectorConfig.yawDeg = ccd["yaw"] 

121 detectorConfig.rollDeg = ccd["roll"] 

122 if "crosstalk" in ccd: 

123 detectorConfig.crosstalk = ccd["crosstalk"] 

124 

125 return detectorConfigs 

126 

127 

128def makeAmplifierList(ccd: dict[str, Any]) -> list[Amplifier.Builder]: 

129 """Construct a list of AmplifierBuilder objects.""" 

130 # Much of this will need to be filled in when we know it. 

131 assert len(ccd) > 0 

132 amp = list(ccd["amplifiers"].values())[0] 

133 

134 rawBBox = makeBBoxFromList(amp["rawBBox"]) # total in file 

135 xRawExtent, yRawExtent = rawBBox.getDimensions() 

136 

137 readCorners = { 

138 "LL": ReadoutCorner.LL, 

139 "LR": ReadoutCorner.LR, 

140 "UL": ReadoutCorner.UL, 

141 "UR": ReadoutCorner.UR, 

142 } 

143 

144 amplifierList = [] 

145 for name, amp in sorted(ccd["amplifiers"].items(), key=lambda x: x[1]["hdu"]): 

146 amplifier = Amplifier.Builder() 

147 amplifier.setName(name) 

148 

149 ix, iy = amp["ixy"] 

150 perAmpData = amp["perAmpData"] 

151 if perAmpData: 

152 x0, y0 = 0, 0 # origin of data within each amp image 

153 else: 

154 x0, y0 = ix * xRawExtent, iy * yRawExtent 

155 

156 rawDataBBox = makeBBoxFromList(amp["rawDataBBox"]) # Photosensitive area 

157 xDataExtent, yDataExtent = rawDataBBox.getDimensions() 

158 amplifier.setBBox( 

159 geom.Box2I(geom.PointI(ix * xDataExtent, iy * yDataExtent), rawDataBBox.getDimensions()) 

160 ) 

161 

162 rawBBox = makeBBoxFromList(amp["rawBBox"]) 

163 rawBBox.shift(geom.ExtentI(x0, y0)) 

164 amplifier.setRawBBox(rawBBox) 

165 

166 rawDataBBox = makeBBoxFromList(amp["rawDataBBox"]) 

167 rawDataBBox.shift(geom.ExtentI(x0, y0)) 

168 amplifier.setRawDataBBox(rawDataBBox) 

169 

170 rawSerialOverscanBBox = makeBBoxFromList(amp["rawSerialOverscanBBox"]) 

171 rawSerialOverscanBBox.shift(geom.ExtentI(x0, y0)) 

172 amplifier.setRawHorizontalOverscanBBox(rawSerialOverscanBBox) 

173 

174 rawParallelOverscanBBox = makeBBoxFromList(amp["rawParallelOverscanBBox"]) 

175 rawParallelOverscanBBox.shift(geom.ExtentI(x0, y0)) 

176 amplifier.setRawVerticalOverscanBBox(rawParallelOverscanBBox) 

177 

178 rawSerialPrescanBBox = makeBBoxFromList(amp["rawSerialPrescanBBox"]) 

179 rawSerialPrescanBBox.shift(geom.ExtentI(x0, y0)) 

180 amplifier.setRawPrescanBBox(rawSerialPrescanBBox) 

181 

182 if perAmpData: 

183 amplifier.setRawXYOffset(geom.Extent2I(ix * xRawExtent, iy * yRawExtent)) 

184 else: 

185 amplifier.setRawXYOffset(geom.Extent2I(0, 0)) 

186 

187 amplifier.setReadoutCorner(readCorners[amp["readCorner"]]) 

188 amplifier.setGain(amp["gain"]) 

189 amplifier.setReadNoise(amp["readNoise"]) 

190 amplifier.setSaturation(amp["saturation"]) 

191 amplifier.setSuspectLevel(amp.get("suspect", np.nan)) 

192 

193 # flip data when assembling if needs be (e.g. data from the serial at 

194 # the top of a CCD) 

195 flipX, flipY = amp.get("flipXY") 

196 

197 amplifier.setRawFlipX(flipX) 

198 amplifier.setRawFlipY(flipY) 

199 # linearity placeholder stuff 

200 amplifier.setLinearityCoeffs([float(val) for val in amp["linearityCoeffs"]]) 

201 amplifier.setLinearityType(amp["linearityType"]) 

202 amplifier.setLinearityThreshold(float(amp["linearityThreshold"])) 

203 amplifier.setLinearityMaximum(float(amp["linearityMax"])) 

204 amplifier.setLinearityUnits("DN") 

205 amplifierList.append(amplifier) 

206 return amplifierList 

207 

208 

209def makeAmpInfoCatalog(ccd: dict[str, Any]) -> list[Amplifier.Builder]: 

210 """Backward compatible name.""" 

211 return makeAmplifierList(ccd) 

212 

213 

214def makeBBoxFromList(ylist: tuple[tuple[int, int], tuple[int, int]]) -> geom.Box2I: 

215 """Given a list [(x0, y0), (xsize, ysize)], probably from a yaml file, 

216 return a Box2I. 

217 """ 

218 (x0, y0), (xsize, ysize) = ylist 

219 return geom.Box2I(geom.PointI(x0, y0), geom.ExtentI(xsize, ysize)) 

220 

221 

222def makeTransformDict( 

223 nativeSys: CameraSys, transformDict: dict[str, Any], plateScale: geom.Angle 

224) -> dict[CameraSys, afwGeom.TransformPoint2ToPoint2]: 

225 """Make a dictionary of TransformPoint2ToPoint2s from yaml, mapping from 

226 nativeSys. 

227 

228 Parameters 

229 ---------- 

230 nativeSys : `lsst.afw.cameraGeom.CameraSys` 

231 transformDict : `dict` 

232 A dict specifying parameters of transforms; keys are camera system 

233 names. 

234 plateScale : `lsst.geom.Angle` 

235 The size of a pixel in angular units/mm (e.g. 20 arcsec/mm for LSST) 

236 

237 Returns 

238 ------- 

239 transforms : `dict` [`lsst.afw.cameraGeom.CameraSys`, \ 

240 `lsst.afw.geom.TransformPoint2ToPoint2` ] 

241 The values are Transforms *from* NativeSys *to* CameraSys 

242 """ 

243 # As other comments note this is required, and this is one function where 

244 # it's assumed 

245 assert nativeSys == cameraGeom.FOCAL_PLANE, "Cameras with nativeSys != FOCAL_PLANE are not supported." 

246 

247 resMap = {} 

248 

249 for key, transform in transformDict.items(): 

250 transformType = transform["transformType"] 

251 knownTransformTypes = ["affine", "radial"] 

252 if transformType not in knownTransformTypes: 

253 raise RuntimeError( 

254 "Saw unknown transform type for {}: {} (known types are: [{}])".format( 

255 key, transform["transformType"], ", ".join(knownTransformTypes) 

256 ) 

257 ) 

258 

259 if transformType == "affine": 

260 affine = geom.AffineTransform(np.array(transform["linear"]), np.array(transform["translation"])) 

261 

262 transform = afwGeom.makeTransform(affine) 

263 elif transformType == "radial": 

264 # radial coefficients of the form 

265 # [0, 1 (no units), C2 (mm^-1), usually 0, C4 (mm^-3), ...] 

266 # Radial distortion is modeled as a radial polynomial that converts 

267 # from focal plane radius (in mm) to field angle (in radians). 

268 # The provided coefficients are divided by the plate 

269 # scale (in radians/mm) meaning that C1 is always 1. 

270 radialCoeffs = np.array(transform["coeffs"]) 

271 

272 radialCoeffs *= plateScale.asRadians() 

273 transform = afwGeom.makeRadialTransform(radialCoeffs) 

274 else: 

275 raise RuntimeError( 

276 'Impossible condition "{}" is not in: [{}])'.format( 

277 transform["transformType"], ", ".join(knownTransformTypes) 

278 ) 

279 ) 

280 

281 resMap[cameraGeom.CameraSys(key)] = transform 

282 

283 return resMap 

284 

285 

286def makeCameraFromCatalogs( 

287 cameraName: str, 

288 detectorConfigList: list[DetectorConfig], 

289 nativeSys: CameraSys, 

290 transformDict: dict[CameraSys, afwGeom.TransformPoint2ToPoint2], 

291 amplifierDict: dict[str, cameraGeom.Amplifier.Builder], 

292 pupilFactoryClass: type[cameraGeom.pupil.PupilFactory] = cameraGeom.pupil.PupilFactory, 

293 focalPlaneParity: bool = False, 

294) -> Camera: 

295 """Construct a Camera instance from a dictionary of 

296 detector name and `lsst.afw.cameraGeom.Amplifier`. 

297 

298 Parameters 

299 ---------- 

300 cameraName : `str` 

301 The name of the camera 

302 detectorConfigList : `list` 

303 A list of `lsst.afw.cameraGeom.cameraConfig.DetectorConfig` 

304 nativeSys : `lsst.afw.cameraGeom.CameraSys` 

305 The native transformation type; must be 

306 `lsst.afw.cameraGeom.FOCAL_PLANE` 

307 transformDict : `dict` 

308 A dict of lsst.afw.cameraGeom.CameraSys : 

309 `lsst.afw.geom.TransformPoint2ToPoint2` 

310 amplifierDict : `dict` [`str`, `lsst.afw.cameraGeom.Amplifier.Builder` ] 

311 A dictionary of detector name and amplifier builders. 

312 pupilFactoryClass : `type` [`lsst.afw.cameraGeom.PupilFactory`], \ 

313 optional 

314 Class to attach to camera. 

315 focalPlaneParity : `bool`, optional 

316 If `True`, the X axis is flipped between the FOCAL_PLANE and 

317 FIELD_ANGLE coordinate systems. 

318 

319 Returns 

320 ------- 

321 camera : `lsst.afw.cameraGeom.Camera` 

322 New Camera instance. 

323 

324 Notes 

325 ----- 

326 Copied from `lsst.afw.cameraGeom.cameraFactory` with permission and 

327 encouragement from Jim Bosch. 

328 """ 

329 # nativeSys=FOCAL_PLANE seems to be assumed in various places in this file 

330 # (e.g. the definition of TAN_PIXELS), despite CameraConfig providing the 

331 # illusion that it's configurable. 

332 # Note that we can't actually get rid of the nativeSys config option 

333 # without breaking lots of on-disk camera configs. 

334 assert nativeSys == cameraGeom.FOCAL_PLANE, "Cameras with nativeSys != FOCAL_PLANE are not supported." 

335 

336 focalPlaneToField = transformDict[cameraGeom.FIELD_ANGLE] 

337 

338 cameraBuilder = Camera.Builder(cameraName) 

339 cameraBuilder.setPupilFactoryClass(pupilFactoryClass) 

340 cameraBuilder.setFocalPlaneParity(focalPlaneParity) 

341 

342 # Ensure all transforms in the camera transform dict are included. 

343 for toSys, transform in transformDict.items(): 

344 cameraBuilder.setTransformFromFocalPlaneTo(toSys, transform) 

345 

346 for detectorConfig in detectorConfigList: 

347 # This should build all detector pixel -> focalPlane transforms. 

348 cameraGeom.addDetectorBuilderFromConfig( 

349 cameraBuilder, detectorConfig, amplifierDict[detectorConfig.name], focalPlaneToField 

350 ) 

351 

352 # For reasons I don't understand, some obs_ packages (e.g. HSC) set 

353 # nativeSys to None for their detectors (which doesn't seem to be 

354 # permitted by the config class!), but they really mean PIXELS. For 

355 # backwards compatibility we use that as the default... 

356 detectorNativeSys = detectorConfig.transformDict.nativeSys 

357 detectorNativeSys = ( 

358 cameraGeom.PIXELS if detectorNativeSys is None else cameraGeom.CameraSysPrefix(detectorNativeSys) 

359 ) 

360 

361 # ...well, actually, it seems that we've always assumed down in C++ 

362 # that the answer is always PIXELS without ever checking that it is. 

363 # So let's assert that it is, since there are hints all over this file 

364 # (e.g. the definition of TAN_PIXELS) that other parts of the codebase 

365 # have regularly made that assumption as well. Note that we can't 

366 # actually get rid of the nativeSys config option without breaking 

367 # lots of on-disk camera configs. 

368 assert detectorNativeSys == cameraGeom.PIXELS, "Detectors with nativeSys != PIXELS are not supported." 

369 detectorNativeSys = cameraGeom.CameraSys(detectorNativeSys, detectorConfig.name) 

370 

371 return cameraBuilder.finish()