Coverage for python/lsst/obs/lsst/assembly.py : 12%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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 if warned:
226 logger.debug("%s: %s", *args)
227 else:
228 logger.warning("%s: %s", *args)
229 warned = True
231 yield logCmd
234def fixAmpsAndAssemble(ampExps, msg):
235 """Fix amp geometry and assemble into exposure.
237 Parameters
238 ----------
239 ampExps : sequence of `lsst.afw.image.Exposure`
240 Per-amplifier images.
241 msg : `str`
242 Message to add to log and exception output.
244 Returns
245 -------
246 exposure : `lsst.afw.image.Exposure`
247 Exposure with the amps combined into a single image.
249 Notes
250 -----
251 The returned exposure does not have any metadata or WCS attached.
253 """
254 if not len(ampExps):
255 raise RuntimeError(f"Unable to read raw_amps for {msg}")
257 ccd = ampExps[0].getDetector() # the same (full, CCD-level) Detector is attached to all ampExps
258 #
259 # Check that the geometry in the metadata matches cameraGeom
260 #
261 with warn_once(msg) as logCmd:
262 # Rebuild the detector and the amplifiers to use their corrected
263 # geometry.
264 tempCcd = ccd.rebuild()
265 tempCcd.clear()
266 for amp, ampExp in zip(ccd, ampExps):
267 outAmp, _ = fixAmpGeometry(amp,
268 bbox=ampExp.getBBox(),
269 metadata=ampExp.getMetadata(),
270 logCmd=logCmd)
271 tempCcd.append(outAmp)
272 ccd = tempCcd.finish()
274 # Update the data to be combined to point to the newly rebuilt detector.
275 for ampExp in ampExps:
276 ampExp.setDetector(ccd)
278 exposure = assembleUntrimmedCcd(ccd, ampExps)
279 return exposure
282def readRawAmps(fileName, detector):
283 """Given a file name read the amps and attach the detector.
285 Parameters
286 ----------
287 fileName : `str`
288 The full path to a file containing data from a single CCD.
289 detector : `lsst.afw.cameraGeom.Detector`
290 Detector to associate with the amps.
292 Returns
293 -------
294 ampExps : `list` of `lsst.afw.image.Exposure`
295 All the individual amps read from the file.
296 """
297 amps = []
298 for hdu in range(1, len(detector)+1):
299 reader = afwImage.ImageFitsReader(fileName, hdu=hdu)
300 exp = afwImage.makeExposure(afwImage.makeMaskedImage(reader.read(dtype=np.dtype(np.int32),
301 allowUnsafe=True)))
302 exp.setDetector(detector)
303 exp.setMetadata(reader.readMetadata())
304 amps.append(exp)
305 return amps