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 

25from contextlib import contextmanager 

26import numpy as np 

27import lsst.log 

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 = lsst.log.Log.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.warn("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 if warned: 

226 logger.debug(f"{msg}: {s}", *args) 

227 else: 

228 logger.warn(f"{msg}: {s}", *args) 

229 warned = True 

230 

231 yield logCmd 

232 

233 

234def fixAmpsAndAssemble(ampExps, msg): 

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

236 

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. 

243 

244 Returns 

245 ------- 

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

247 Exposure with the amps combined into a single image. 

248 

249 Notes 

250 ----- 

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

252 

253 """ 

254 if not len(ampExps): 

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

256 

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

273 

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

275 for ampExp in ampExps: 

276 ampExp.setDetector(ccd) 

277 

278 exposure = assembleUntrimmedCcd(ccd, ampExps) 

279 return exposure 

280 

281 

282def readRawAmps(fileName, detector): 

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

284 

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. 

291 

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