Hide keyboard shortcuts

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/>. 

21 

22__all__ = ("attachRawWcsFromBoresight", "fixAmpGeometry", "assembleUntrimmedCcd", 

23 "fixAmpsAndAssemble", "readRawAmps") 

24 

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 

33 

34logger = logging.getLogger("obs.lsst.assembly") 

35 

36 

37def attachRawWcsFromBoresight(exposure, dataIdForErrMsg=None): 

38 """Attach a WCS by extracting boresight, rotation, and camera geometry from 

39 an Exposure. 

40 

41 Parameters 

42 ---------- 

43 exposure : `lsst.afw.image.Exposure` 

44 Image object with attached metadata and detector components. 

45 

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 

59 # LATISS (and likely others) need flipping, DC2 etc do not 

60 flipX = False 

61 if obsInfo.instrument in ("LATISS",): 

62 flipX = True 

63 

64 if visitInfo.getBoresightRaDec().isFinite(): 

65 exposure.setWcs(createInitialSkyWcs(visitInfo, exposure.getDetector(), flipX=flipX)) 

66 return True 

67 

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 

72 

73 

74def fixAmpGeometry(inAmp, bbox, metadata, logCmd=None): 

75 """Make sure a camera geometry amplifier matches an on-disk bounding box. 

76 

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. 

80 

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. 

94 

95 Return 

96 ------ 

97 outAmp : `~lsst.afw.cameraGeom.Amplifier.Builder` 

98 modified : `bool` 

99 `True` if ``amp`` was modified; `False` otherwise. 

100 

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 

111 

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) 

117 

118 modified = False 

119 

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())) 

125 

126 logCmd("outAmp.getRawBBox() != data.getBBox(); patching. (%s v. %s)", outAmp.getRawBBox(), bbox) 

127 

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() 

152 

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)) 

157 

158 modified = True 

159 

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 

169 

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) 

176 

177 return outAmp, modified 

178 

179 

180def assembleUntrimmedCcd(ccd, exposures): 

181 """Assemble an untrimmmed CCD from per-amp Exposure objects. 

182 

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``. 

190 

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) 

203 

204 

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. 

209 

210 Parameters 

211 ---------- 

212 msg : `str` 

213 Message to prefix all log messages with. 

214 

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 

222 

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 

231 

232 yield logCmd 

233 

234 

235def fixAmpsAndAssemble(ampExps, msg): 

236 """Fix amp geometry and assemble into exposure. 

237 

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. 

244 

245 Returns 

246 ------- 

247 exposure : `lsst.afw.image.Exposure` 

248 Exposure with the amps combined into a single image. 

249 

250 Notes 

251 ----- 

252 The returned exposure does not have any metadata or WCS attached. 

253 

254 """ 

255 if not len(ampExps): 

256 raise RuntimeError(f"Unable to read raw_amps for {msg}") 

257 

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() 

274 

275 # Update the data to be combined to point to the newly rebuilt detector. 

276 for ampExp in ampExps: 

277 ampExp.setDetector(ccd) 

278 

279 exposure = assembleUntrimmedCcd(ccd, ampExps) 

280 return exposure 

281 

282 

283def readRawAmps(fileName, detector): 

284 """Given a file name read the amps and attach the detector. 

285 

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. 

292 

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