Coverage for python/lsst/obs/lsst/assembly.py: 14%
104 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-12 10:45 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-08-12 10:45 +0000
1# This file is part of obs_lsst
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://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 <http://www.gnu.org/licenses/>.
22__all__ = ("attachRawWcsFromBoresight", "fixAmpGeometry", "assembleUntrimmedCcd",
23 "fixAmpsAndAssemble", "readRawAmps")
25import logging
26from contextlib import contextmanager
27import numpy as np
28import lsst.afw.image as afwImage
29from lsst.obs.base import bboxFromIraf, MakeRawVisitInfoViaObsInfo, createInitialSkyWcs
30from lsst.geom import Box2I, Extent2I
31from lsst.ip.isr import AssembleCcdTask
32from astro_metadata_translator import ObservationInfo
34logger = logging.getLogger(__name__)
37def attachRawWcsFromBoresight(exposure, dataIdForErrMsg=None):
38 """Attach a WCS by extracting boresight, rotation, and camera geometry from
39 an Exposure.
41 Parameters
42 ----------
43 exposure : `lsst.afw.image.Exposure`
44 Image object with attached metadata and detector components.
46 Return
47 ------
48 attached : `bool`
49 If True, a WCS component was successfully created and attached to
50 ``exposure``.
51 """
52 md = exposure.getMetadata()
53 # Use the generic version since we do not have a mapper available to
54 # tell us a specific translator to use.
55 obsInfo = ObservationInfo(md)
56 visitInfo = MakeRawVisitInfoViaObsInfo.observationInfo2visitInfo(obsInfo, log=logger)
57 exposure.getInfo().setVisitInfo(visitInfo)
58 exposure.info.id = obsInfo.detector_exposure_id
60 # LATISS (and likely others) need flipping, DC2 etc do not
61 flipX = False
62 if obsInfo.instrument in ("LATISS",):
63 flipX = True
65 if visitInfo.getBoresightRaDec().isFinite():
66 exposure.setWcs(createInitialSkyWcs(visitInfo, exposure.getDetector(), flipX=flipX))
67 return True
69 if obsInfo.observation_type == "science":
70 logger.warning("Unable to set WCS from header as RA/Dec/Angle are unavailable%s",
71 ("" if dataIdForErrMsg is None else " for dataId %s" % dataIdForErrMsg))
72 return False
75def fixAmpGeometry(inAmp, bbox, metadata, logCmd=None):
76 """Make sure a camera geometry amplifier matches an on-disk bounding box.
78 Bounding box differences that are consistent with differences in overscan
79 regions are assumed to be overscan regions, which gives us enough
80 information to correct the camera geometry.
82 Parameters
83 ----------
84 inAmp : `lsst.afw.cameraGeom.Amplifier`
85 Amplifier description from camera geometry.
86 bbox : `lsst.geom.Box2I`
87 The on-disk bounding box of the amplifer image.
88 metadata : `lsst.daf.base.PropertyList`
89 FITS header metadata from the amplifier HDU.
90 logCmd : `function`, optional
91 Call back to use to issue log messages about patching. Arguments to
92 this function should match arguments to be accepted by normal logging
93 functions. Warnings about bad EXTNAMES are always sent directly to
94 the module-level logger.
96 Return
97 ------
98 outAmp : `~lsst.afw.cameraGeom.Amplifier.Builder`
99 modified : `bool`
100 `True` if ``amp`` was modified; `False` otherwise.
102 Raises
103 ------
104 RuntimeError
105 Raised if the bounding boxes differ in a way that is not consistent
106 with just a change in overscan.
107 """
108 if logCmd is None:
109 # Define a null log command
110 def logCmd(*args):
111 return
113 # check that the book-keeping worked and we got the correct EXTNAME
114 extname = metadata.get("EXTNAME")
115 predictedExtname = f"Segment{inAmp.getName()[1:]}"
116 if extname is not None and predictedExtname != extname:
117 logger.warning('expected to see EXTNAME == "%s", but saw "%s"', predictedExtname, extname)
119 modified = False
121 outAmp = inAmp.rebuild()
122 if outAmp.getRawBBox() != bbox: # Oh dear. cameraGeom is wrong -- probably overscan
123 if outAmp.getRawDataBBox().getDimensions() != outAmp.getBBox().getDimensions():
124 raise RuntimeError("Active area is the wrong size: %s v. %s" %
125 (outAmp.getRawDataBBox().getDimensions(), outAmp.getBBox().getDimensions()))
127 logCmd("outAmp.getRawBBox() != data.getBBox(); patching. (%s v. %s)", outAmp.getRawBBox(), bbox)
129 w, h = bbox.getDimensions()
130 ow, oh = outAmp.getRawBBox().getDimensions() # "old" (cameraGeom) dimensions
131 #
132 # We could trust the BIASSEC keyword, or we can just assume that
133 # they've changed the number of overscan pixels (serial and/or
134 # parallel). As Jim Chiang points out, the latter is safer
135 #
136 fromCamGeom = outAmp.getRawHorizontalOverscanBBox()
137 hOverscanBBox = Box2I(fromCamGeom.getBegin(),
138 Extent2I(w - fromCamGeom.getBeginX(), fromCamGeom.getHeight()))
139 fromCamGeom = outAmp.getRawVerticalOverscanBBox()
140 vOverscanBBox = Box2I(fromCamGeom.getBegin(),
141 Extent2I(fromCamGeom.getWidth(), h - fromCamGeom.getBeginY()))
142 outAmp.setRawBBox(bbox)
143 outAmp.setRawHorizontalOverscanBBox(hOverscanBBox)
144 outAmp.setRawVerticalOverscanBBox(vOverscanBBox)
145 #
146 # This gets all the geometry right for the amplifier, but the size
147 # of the untrimmed image will be wrong and we'll put the amp sections
148 # in the wrong places, i.e.
149 # outAmp.getRawXYOffset()
150 # will be wrong. So we need to recalculate the offsets.
151 #
152 xRawExtent, yRawExtent = outAmp.getRawBBox().getDimensions()
154 x0, y0 = outAmp.getRawXYOffset()
155 ix, iy = x0//ow, y0/oh
156 x0, y0 = ix*xRawExtent, iy*yRawExtent
157 outAmp.setRawXYOffset(Extent2I(ix*xRawExtent, iy*yRawExtent))
159 modified = True
161 #
162 # Check the "IRAF" keywords, but don't abort if they're wrong
163 #
164 # Only warn about the first amp, use debug for the others
165 #
166 detsec = bboxFromIraf(metadata["DETSEC"]) if "DETSEC" in metadata else None
167 datasec = bboxFromIraf(metadata["DATASEC"]) if "DATASEC" in metadata else None
168 biassec = bboxFromIraf(metadata["BIASSEC"]) if "BIASSEC" in metadata else None
170 # 2022-11-11: There is a known issue that the header DETSEC have
171 # the y-axis values flipped between the C0x and C1x entries. This
172 # is incorrect, and disagrees with the cameraGeom values.
173 # DM-36115 contains additional details. This test has been
174 # disabled to remove useless warnings until that is resolved.
175 # if detsec and outAmp.getBBox() != detsec:
176 # logCmd("DETSEC doesn't match (%s != %s)",
177 # outAmp.getBBox(), detsec)
178 if datasec and outAmp.getRawDataBBox() != datasec:
179 logCmd("DATASEC doesn't match for (%s != %s)", outAmp.getRawDataBBox(), detsec)
180 if biassec and outAmp.getRawHorizontalOverscanBBox() != biassec:
181 logCmd("BIASSEC doesn't match for (%s != %s)", outAmp.getRawHorizontalOverscanBBox(), detsec)
183 return outAmp, modified
186def assembleUntrimmedCcd(ccd, exposures):
187 """Assemble an untrimmmed CCD from per-amp Exposure objects.
189 Parameters
190 ----------
191 ccd : `~lsst.afw.cameraGeom.Detector`
192 The detector geometry for this ccd that will be used as the
193 framework for the assembly of the input amplifier exposures.
194 exposures : sequence of `lsst.afw.image.Exposure`
195 Per-amplifier images, in the same order as ``amps``.
197 Returns
198 -------
199 ccd : `lsst.afw.image.Exposure`
200 Assembled CCD image.
201 """
202 ampDict = {}
203 for amp, exposure in zip(ccd, exposures):
204 ampDict[amp.getName()] = exposure
205 config = AssembleCcdTask.ConfigClass()
206 config.doTrim = False
207 assembleTask = AssembleCcdTask(config=config)
208 return assembleTask.assembleCcd(ampDict)
211@contextmanager
212def warn_once(msg):
213 """Return a context manager around a log-like object that emits a warning
214 the first time it is used and a debug message all subsequent times.
216 Parameters
217 ----------
218 msg : `str`
219 Message to prefix all log messages with.
221 Returns
222 -------
223 logger
224 A log-like object that takes a %-style format string and positional
225 substition args.
226 """
227 warned = False
229 def logCmd(s, *args):
230 nonlocal warned
231 log_msg = f"{msg}: {s}"
232 if warned:
233 logger.debug(log_msg, *args)
234 else:
235 logger.warning(log_msg, *args)
236 warned = True
238 yield logCmd
241def fixAmpsAndAssemble(ampExps, msg):
242 """Fix amp geometry and assemble into exposure.
244 Parameters
245 ----------
246 ampExps : sequence of `lsst.afw.image.Exposure`
247 Per-amplifier images.
248 msg : `str`
249 Message to add to log and exception output.
251 Returns
252 -------
253 exposure : `lsst.afw.image.Exposure`
254 Exposure with the amps combined into a single image.
256 Notes
257 -----
258 The returned exposure does not have any metadata or WCS attached.
260 """
261 if not len(ampExps):
262 raise RuntimeError(f"Unable to read raw_amps for {msg}")
264 ccd = ampExps[0].getDetector() # the same (full, CCD-level) Detector is attached to all ampExps
265 #
266 # Check that the geometry in the metadata matches cameraGeom
267 #
268 with warn_once(msg) as logCmd:
269 # Rebuild the detector and the amplifiers to use their corrected
270 # geometry.
271 tempCcd = ccd.rebuild()
272 tempCcd.clear()
273 for amp, ampExp in zip(ccd, ampExps):
274 outAmp, _ = fixAmpGeometry(amp,
275 bbox=ampExp.getBBox(),
276 metadata=ampExp.getMetadata(),
277 logCmd=logCmd)
278 tempCcd.append(outAmp)
279 ccd = tempCcd.finish()
281 # Update the data to be combined to point to the newly rebuilt detector.
282 for ampExp in ampExps:
283 ampExp.setDetector(ccd)
285 exposure = assembleUntrimmedCcd(ccd, ampExps)
286 return exposure
289def readRawAmps(fileName, detector):
290 """Given a file name read the amps and attach the detector.
292 Parameters
293 ----------
294 fileName : `str`
295 The full path to a file containing data from a single CCD.
296 detector : `lsst.afw.cameraGeom.Detector`
297 Detector to associate with the amps.
299 Returns
300 -------
301 ampExps : `list` of `lsst.afw.image.Exposure`
302 All the individual amps read from the file.
303 """
304 amps = []
305 for hdu in range(1, len(detector)+1):
306 reader = afwImage.ImageFitsReader(fileName, hdu=hdu)
307 exp = afwImage.makeExposure(afwImage.makeMaskedImage(reader.read(dtype=np.dtype(np.int32),
308 allowUnsafe=True)))
309 exp.setDetector(detector)
310 exp.setMetadata(reader.readMetadata())
311 amps.append(exp)
312 return amps