Coverage for python / lsst / obs / base / yamlCamera.py: 11%
143 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-25 08:22 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-25 08:22 +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/>.
22from __future__ import annotations
24from functools import lru_cache
25from typing import TYPE_CHECKING, Any
27import numpy as np
28import yaml
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
35if TYPE_CHECKING:
36 from lsst.afw.cameraGeom import CameraSys, DetectorConfig
38__all__ = ["makeCamera"]
41@lru_cache
42def makeCamera(cameraFile: str) -> Camera:
43 """Construct an imaging camera (e.g. the LSST 3Gpix camera).
45 Parameters
46 ----------
47 cameraFile : `str`
48 Camera description YAML file.
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)
58 cameraName = cameraParams["name"]
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)
67 ccdParams = cameraParams["CCDs"]
68 detectorConfigList = makeDetectorConfigList(ccdParams)
69 focalPlaneParity = cameraParams.get("focalPlaneParity", False)
71 amplifierDict = {}
72 for ccdName, ccdValues in ccdParams.items():
73 amplifierDict[ccdName] = makeAmplifierList(ccdValues)
75 return makeCameraFromCatalogs(
76 cameraName,
77 detectorConfigList,
78 nativeSys,
79 transforms,
80 amplifierDict,
81 focalPlaneParity=focalPlaneParity,
82 )
85def makeDetectorConfigList(ccdParams: dict[str, Any]) -> list[DetectorConfig]:
86 """Make a list of detector configs.
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)
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"]
125 return detectorConfigs
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]
134 rawBBox = makeBBoxFromList(amp["rawBBox"]) # total in file
135 xRawExtent, yRawExtent = rawBBox.getDimensions()
137 readCorners = {
138 "LL": ReadoutCorner.LL,
139 "LR": ReadoutCorner.LR,
140 "UL": ReadoutCorner.UL,
141 "UR": ReadoutCorner.UR,
142 }
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)
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
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 )
162 rawBBox = makeBBoxFromList(amp["rawBBox"])
163 rawBBox.shift(geom.ExtentI(x0, y0))
164 amplifier.setRawBBox(rawBBox)
166 rawDataBBox = makeBBoxFromList(amp["rawDataBBox"])
167 rawDataBBox.shift(geom.ExtentI(x0, y0))
168 amplifier.setRawDataBBox(rawDataBBox)
170 rawSerialOverscanBBox = makeBBoxFromList(amp["rawSerialOverscanBBox"])
171 rawSerialOverscanBBox.shift(geom.ExtentI(x0, y0))
172 amplifier.setRawHorizontalOverscanBBox(rawSerialOverscanBBox)
174 rawParallelOverscanBBox = makeBBoxFromList(amp["rawParallelOverscanBBox"])
175 rawParallelOverscanBBox.shift(geom.ExtentI(x0, y0))
176 amplifier.setRawVerticalOverscanBBox(rawParallelOverscanBBox)
178 rawSerialPrescanBBox = makeBBoxFromList(amp["rawSerialPrescanBBox"])
179 rawSerialPrescanBBox.shift(geom.ExtentI(x0, y0))
180 amplifier.setRawPrescanBBox(rawSerialPrescanBBox)
182 if perAmpData:
183 amplifier.setRawXYOffset(geom.Extent2I(ix * xRawExtent, iy * yRawExtent))
184 else:
185 amplifier.setRawXYOffset(geom.Extent2I(0, 0))
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))
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")
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
209def makeAmpInfoCatalog(ccd: dict[str, Any]) -> list[Amplifier.Builder]:
210 """Backward compatible name."""
211 return makeAmplifierList(ccd)
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))
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.
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)
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."
247 resMap = {}
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 )
259 if transformType == "affine":
260 affine = geom.AffineTransform(np.array(transform["linear"]), np.array(transform["translation"]))
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"])
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 )
281 resMap[cameraGeom.CameraSys(key)] = transform
283 return resMap
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`.
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.
319 Returns
320 -------
321 camera : `lsst.afw.cameraGeom.Camera`
322 New Camera instance.
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."
336 focalPlaneToField = transformDict[cameraGeom.FIELD_ANGLE]
338 cameraBuilder = Camera.Builder(cameraName)
339 cameraBuilder.setPupilFactoryClass(pupilFactoryClass)
340 cameraBuilder.setFocalPlaneParity(focalPlaneParity)
342 # Ensure all transforms in the camera transform dict are included.
343 for toSys, transform in transformDict.items():
344 cameraBuilder.setTransformFromFocalPlaneTo(toSys, transform)
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 )
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 )
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)
371 return cameraBuilder.finish()