Coverage for python/lsst/obs/lsst/assembly.py: 12%
106 statements
« prev ^ index » next coverage.py v7.1.0, created at 2023-02-05 18:52 -0800
« prev ^ index » next coverage.py v7.1.0, created at 2023-02-05 18:52 -0800
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("obs.lsst.assembly")
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)
59 # LATISS (and likely others) need flipping, DC2 etc do not
60 flipX = False
61 if obsInfo.instrument in ("LATISS",):
62 flipX = True
64 if visitInfo.getBoresightRaDec().isFinite():
65 exposure.setWcs(createInitialSkyWcs(visitInfo, exposure.getDetector(), flipX=flipX))
66 return True
68 if obsInfo.observation_type == "science":
69 logger.warning("Unable to set WCS from header as RA/Dec/Angle are unavailable%s",
70 ("" if dataIdForErrMsg is None else " for dataId %s" % dataIdForErrMsg))
71 return False
74def fixAmpGeometry(inAmp, bbox, metadata, logCmd=None):
75 """Make sure a camera geometry amplifier matches an on-disk bounding box.
77 Bounding box differences that are consistent with differences in overscan
78 regions are assumed to be overscan regions, which gives us enough
79 information to correct the camera geometry.
81 Parameters
82 ----------
83 inAmp : `lsst.afw.cameraGeom.Amplifier`
84 Amplifier description from camera geometry.
85 bbox : `lsst.geom.Box2I`
86 The on-disk bounding box of the amplifer image.
87 metadata : `lsst.daf.base.PropertyList`
88 FITS header metadata from the amplifier HDU.
89 logCmd : `function`, optional
90 Call back to use to issue log messages about patching. Arguments to
91 this function should match arguments to be accepted by normal logging
92 functions. Warnings about bad EXTNAMES are always sent directly to
93 the module-level logger.
95 Return
96 ------
97 outAmp : `~lsst.afw.cameraGeom.Amplifier.Builder`
98 modified : `bool`
99 `True` if ``amp`` was modified; `False` otherwise.
101 Raises
102 ------
103 RuntimeError
104 Raised if the bounding boxes differ in a way that is not consistent
105 with just a change in overscan.
106 """
107 if logCmd is None:
108 # Define a null log command
109 def logCmd(*args):
110 return
112 # check that the book-keeping worked and we got the correct EXTNAME
113 extname = metadata.get("EXTNAME")
114 predictedExtname = f"Segment{inAmp.getName()[1:]}"
115 if extname is not None and predictedExtname != extname:
116 logger.warning('expected to see EXTNAME == "%s", but saw "%s"', predictedExtname, extname)
118 modified = False
120 outAmp = inAmp.rebuild()
121 if outAmp.getRawBBox() != bbox: # Oh dear. cameraGeom is wrong -- probably overscan
122 if outAmp.getRawDataBBox().getDimensions() != outAmp.getBBox().getDimensions():
123 raise RuntimeError("Active area is the wrong size: %s v. %s" %
124 (outAmp.getRawDataBBox().getDimensions(), outAmp.getBBox().getDimensions()))
126 logCmd("outAmp.getRawBBox() != data.getBBox(); patching. (%s v. %s)", outAmp.getRawBBox(), bbox)
128 w, h = bbox.getDimensions()
129 ow, oh = outAmp.getRawBBox().getDimensions() # "old" (cameraGeom) dimensions
130 #
131 # We could trust the BIASSEC keyword, or we can just assume that
132 # they've changed the number of overscan pixels (serial and/or
133 # parallel). As Jim Chiang points out, the latter is safer
134 #
135 fromCamGeom = outAmp.getRawHorizontalOverscanBBox()
136 hOverscanBBox = Box2I(fromCamGeom.getBegin(),
137 Extent2I(w - fromCamGeom.getBeginX(), fromCamGeom.getHeight()))
138 fromCamGeom = outAmp.getRawVerticalOverscanBBox()
139 vOverscanBBox = Box2I(fromCamGeom.getBegin(),
140 Extent2I(fromCamGeom.getWidth(), h - fromCamGeom.getBeginY()))
141 outAmp.setRawBBox(bbox)
142 outAmp.setRawHorizontalOverscanBBox(hOverscanBBox)
143 outAmp.setRawVerticalOverscanBBox(vOverscanBBox)
144 #
145 # This gets all the geometry right for the amplifier, but the size
146 # of the untrimmed image will be wrong and we'll put the amp sections
147 # in the wrong places, i.e.
148 # outAmp.getRawXYOffset()
149 # will be wrong. So we need to recalculate the offsets.
150 #
151 xRawExtent, yRawExtent = outAmp.getRawBBox().getDimensions()
153 x0, y0 = outAmp.getRawXYOffset()
154 ix, iy = x0//ow, y0/oh
155 x0, y0 = ix*xRawExtent, iy*yRawExtent
156 outAmp.setRawXYOffset(Extent2I(ix*xRawExtent, iy*yRawExtent))
158 modified = True
160 #
161 # Check the "IRAF" keywords, but don't abort if they're wrong
162 #
163 # Only warn about the first amp, use debug for the others
164 #
165 d = metadata.toDict()
166 detsec = bboxFromIraf(d["DETSEC"]) if "DETSEC" in d else None
167 datasec = bboxFromIraf(d["DATASEC"]) if "DATASEC" in d else None
168 biassec = bboxFromIraf(d["BIASSEC"]) if "BIASSEC" in d else None
170 if detsec and outAmp.getBBox() != detsec:
171 logCmd("DETSEC doesn't match (%s != %s)", outAmp.getBBox(), detsec)
172 if datasec and outAmp.getRawDataBBox() != datasec:
173 logCmd("DATASEC doesn't match for (%s != %s)", outAmp.getRawDataBBox(), detsec)
174 if biassec and outAmp.getRawHorizontalOverscanBBox() != biassec:
175 logCmd("BIASSEC doesn't match for (%s != %s)", outAmp.getRawHorizontalOverscanBBox(), detsec)
177 return outAmp, modified
180def assembleUntrimmedCcd(ccd, exposures):
181 """Assemble an untrimmmed CCD from per-amp Exposure objects.
183 Parameters
184 ----------
185 ccd : `~lsst.afw.cameraGeom.Detector`
186 The detector geometry for this ccd that will be used as the
187 framework for the assembly of the input amplifier exposures.
188 exposures : sequence of `lsst.afw.image.Exposure`
189 Per-amplifier images, in the same order as ``amps``.
191 Returns
192 -------
193 ccd : `lsst.afw.image.Exposure`
194 Assembled CCD image.
195 """
196 ampDict = {}
197 for amp, exposure in zip(ccd, exposures):
198 ampDict[amp.getName()] = exposure
199 config = AssembleCcdTask.ConfigClass()
200 config.doTrim = False
201 assembleTask = AssembleCcdTask(config=config)
202 return assembleTask.assembleCcd(ampDict)
205@contextmanager
206def warn_once(msg):
207 """Return a context manager around a log-like object that emits a warning
208 the first time it is used and a debug message all subsequent times.
210 Parameters
211 ----------
212 msg : `str`
213 Message to prefix all log messages with.
215 Returns
216 -------
217 logger
218 A log-like object that takes a %-style format string and positional
219 substition args.
220 """
221 warned = False
223 def logCmd(s, *args):
224 nonlocal warned
225 log_msg = f"{msg}: {s}"
226 if warned:
227 logger.debug(log_msg, *args)
228 else:
229 logger.warning(log_msg, *args)
230 warned = True
232 yield logCmd
235def fixAmpsAndAssemble(ampExps, msg):
236 """Fix amp geometry and assemble into exposure.
238 Parameters
239 ----------
240 ampExps : sequence of `lsst.afw.image.Exposure`
241 Per-amplifier images.
242 msg : `str`
243 Message to add to log and exception output.
245 Returns
246 -------
247 exposure : `lsst.afw.image.Exposure`
248 Exposure with the amps combined into a single image.
250 Notes
251 -----
252 The returned exposure does not have any metadata or WCS attached.
254 """
255 if not len(ampExps):
256 raise RuntimeError(f"Unable to read raw_amps for {msg}")
258 ccd = ampExps[0].getDetector() # the same (full, CCD-level) Detector is attached to all ampExps
259 #
260 # Check that the geometry in the metadata matches cameraGeom
261 #
262 with warn_once(msg) as logCmd:
263 # Rebuild the detector and the amplifiers to use their corrected
264 # geometry.
265 tempCcd = ccd.rebuild()
266 tempCcd.clear()
267 for amp, ampExp in zip(ccd, ampExps):
268 outAmp, _ = fixAmpGeometry(amp,
269 bbox=ampExp.getBBox(),
270 metadata=ampExp.getMetadata(),
271 logCmd=logCmd)
272 tempCcd.append(outAmp)
273 ccd = tempCcd.finish()
275 # Update the data to be combined to point to the newly rebuilt detector.
276 for ampExp in ampExps:
277 ampExp.setDetector(ccd)
279 exposure = assembleUntrimmedCcd(ccd, ampExps)
280 return exposure
283def readRawAmps(fileName, detector):
284 """Given a file name read the amps and attach the detector.
286 Parameters
287 ----------
288 fileName : `str`
289 The full path to a file containing data from a single CCD.
290 detector : `lsst.afw.cameraGeom.Detector`
291 Detector to associate with the amps.
293 Returns
294 -------
295 ampExps : `list` of `lsst.afw.image.Exposure`
296 All the individual amps read from the file.
297 """
298 amps = []
299 for hdu in range(1, len(detector)+1):
300 reader = afwImage.ImageFitsReader(fileName, hdu=hdu)
301 exp = afwImage.makeExposure(afwImage.makeMaskedImage(reader.read(dtype=np.dtype(np.int32),
302 allowUnsafe=True)))
303 exp.setDetector(detector)
304 exp.setMetadata(reader.readMetadata())
305 amps.append(exp)
306 return amps