Coverage for python/lsst/pipe/tasks/snapCombine.py: 26%

147 statements  

« prev     ^ index     » next       coverage.py v7.2.4, created at 2023-04-29 04:03 -0700

1# This file is part of pipe_tasks. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://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 <https://www.gnu.org/licenses/>. 

21 

22__all__ = ["InitialPsfConfig", "SnapCombineConfig", "SnapCombineTask"] 

23 

24import numpy as num 

25import lsst.pex.config as pexConfig 

26import lsst.daf.base as dafBase 

27import lsst.afw.image as afwImage 

28import lsst.afw.table as afwTable 

29import lsst.pipe.base as pipeBase 

30from lsstDebug import getDebugFrame 

31from lsst.afw.display import getDisplay 

32from lsst.coadd.utils import addToCoadd, setCoaddEdgeBits 

33from lsst.ip.diffim import SnapPsfMatchTask 

34from lsst.meas.algorithms import SourceDetectionTask 

35from lsst.meas.base import SingleFrameMeasurementTask 

36import lsst.meas.algorithms as measAlg 

37from lsst.utils.timer import timeMethod 

38 

39from .repair import RepairTask 

40 

41 

42class InitialPsfConfig(pexConfig.Config): 

43 """Describes the initial PSF used for detection and measurement before we do PSF determination.""" 

44 

45 model = pexConfig.ChoiceField( 

46 dtype=str, 

47 doc="PSF model type", 

48 default="SingleGaussian", 

49 allowed={ 

50 "SingleGaussian": "Single Gaussian model", 

51 "DoubleGaussian": "Double Gaussian model", 

52 }, 

53 ) 

54 pixelScale = pexConfig.Field( 

55 dtype=float, 

56 doc="Pixel size (arcsec). Only needed if no Wcs is provided", 

57 default=0.25, 

58 ) 

59 fwhm = pexConfig.Field( 

60 dtype=float, 

61 doc="FWHM of PSF model (arcsec)", 

62 default=1.0, 

63 ) 

64 size = pexConfig.Field( 

65 dtype=int, 

66 doc="Size of PSF model (pixels)", 

67 default=15, 

68 ) 

69 

70 

71class SnapCombineConfig(pexConfig.Config): 

72 doRepair = pexConfig.Field( 

73 dtype=bool, 

74 doc="Repair images (CR reject and interpolate) before combining", 

75 default=True, 

76 ) 

77 repairPsfFwhm = pexConfig.Field( 

78 dtype=float, 

79 doc="Psf FWHM (pixels) used to detect CRs", 

80 default=2.5, 

81 ) 

82 doDiffIm = pexConfig.Field( 

83 dtype=bool, 

84 doc="Perform difference imaging before combining", 

85 default=False, 

86 ) 

87 doPsfMatch = pexConfig.Field( 

88 dtype=bool, 

89 doc="Perform PSF matching for difference imaging (ignored if doDiffIm false)", 

90 default=True, 

91 ) 

92 doMeasurement = pexConfig.Field( 

93 dtype=bool, 

94 doc="Measure difference sources (ignored if doDiffIm false)", 

95 default=True, 

96 ) 

97 badMaskPlanes = pexConfig.ListField( 

98 dtype=str, 

99 doc="Mask planes that, if set, the associated pixels are not included in the combined exposure; " 

100 "DETECTED excludes cosmic rays", 

101 default=("DETECTED",), 

102 ) 

103 averageKeys = pexConfig.ListField( 

104 dtype=str, 

105 doc="List of float metadata keys to average when combining snaps, e.g. float positions and dates; " 

106 "non-float data must be handled by overriding the fixMetadata method", 

107 optional=True, 

108 

109 ) 

110 sumKeys = pexConfig.ListField( 

111 dtype=str, 

112 doc="List of float or int metadata keys to sum when combining snaps, e.g. exposure time; " 

113 "non-float, non-int data must be handled by overriding the fixMetadata method", 

114 optional=True, 

115 ) 

116 

117 repair = pexConfig.ConfigurableField(target=RepairTask, doc="") 

118 diffim = pexConfig.ConfigurableField(target=SnapPsfMatchTask, doc="") 

119 detection = pexConfig.ConfigurableField(target=SourceDetectionTask, doc="") 

120 initialPsf = pexConfig.ConfigField(dtype=InitialPsfConfig, doc="") 

121 measurement = pexConfig.ConfigurableField(target=SingleFrameMeasurementTask, doc="") 

122 

123 def setDefaults(self): 

124 self.detection.thresholdPolarity = "both" 

125 

126 def validate(self): 

127 if self.detection.thresholdPolarity != "both": 

128 raise ValueError("detection.thresholdPolarity must be 'both' for SnapCombineTask") 

129 

130 

131class SnapCombineTask(pipeBase.Task): 

132 """Combine two snaps into a single visit image. 

133 

134 Notes 

135 ----- 

136 Debugging: 

137 The `~lsst.base.lsstDebug` variables in SnapCombineTask are: 

138 

139 display 

140 A dictionary containing debug point names as keys with frame number as value. Valid keys are: 

141 

142 .. code-block:: none 

143 

144 repair0 

145 Display the first snap after repairing. 

146 repair1 

147 Display the second snap after repairing. 

148 """ 

149 

150 ConfigClass = SnapCombineConfig 

151 _DefaultName = "snapCombine" 

152 

153 def __init__(self, *args, **kwargs): 

154 pipeBase.Task.__init__(self, *args, **kwargs) 

155 self.makeSubtask("repair") 

156 self.makeSubtask("diffim") 

157 self.schema = afwTable.SourceTable.makeMinimalSchema() 

158 self.algMetadata = dafBase.PropertyList() 

159 self.makeSubtask("detection", schema=self.schema) 

160 if self.config.doMeasurement: 

161 self.makeSubtask("measurement", schema=self.schema, algMetadata=self.algMetadata) 

162 

163 @timeMethod 

164 def run(self, snap0, snap1, defects=None): 

165 """Combine two snaps. 

166 

167 Parameters 

168 ---------- 

169 snap0 : `Unknown` 

170 Snapshot exposure 0. 

171 snap1 : `Unknown` 

172 Snapshot exposure 1. 

173 defects : `list` or `None`, optional 

174 Defect list (for repair task). 

175 

176 Returns 

177 ------- 

178 result : `lsst.pipe.base.Struct` 

179 Results as a struct with attributes: 

180 

181 ``exposure`` 

182 Snap-combined exposure. 

183 ``sources`` 

184 Detected sources, or `None` if detection not performed. 

185 """ 

186 # initialize optional outputs 

187 sources = None 

188 

189 if self.config.doRepair: 

190 self.log.info("snapCombine repair") 

191 psf = self.makeInitialPsf(snap0, fwhmPix=self.config.repairPsfFwhm) 

192 snap0.setPsf(psf) 

193 snap1.setPsf(psf) 

194 self.repair.run(snap0, defects=defects, keepCRs=False) 

195 self.repair.run(snap1, defects=defects, keepCRs=False) 

196 

197 repair0frame = getDebugFrame(self._display, "repair0") 

198 if repair0frame: 

199 getDisplay(repair0frame).mtv(snap0) 

200 repair1frame = getDebugFrame(self._display, "repair1") 

201 if repair1frame: 

202 getDisplay(repair1frame).mtv(snap1) 

203 

204 if self.config.doDiffIm: 

205 if self.config.doPsfMatch: 

206 self.log.info("snapCombine psfMatch") 

207 diffRet = self.diffim.run(snap0, snap1, "subtractExposures") 

208 diffExp = diffRet.subtractedImage 

209 

210 # Measure centroid and width of kernel; dependent on ticket #1980 

211 # Useful diagnostic for the degree of astrometric shift between snaps. 

212 diffKern = diffRet.psfMatchingKernel 

213 width, height = diffKern.getDimensions() 

214 

215 else: 

216 diffExp = afwImage.ExposureF(snap0, True) 

217 diffMi = diffExp.getMaskedImage() 

218 diffMi -= snap1.getMaskedImage() 

219 

220 psf = self.makeInitialPsf(snap0) 

221 diffExp.setPsf(psf) 

222 table = afwTable.SourceTable.make(self.schema) 

223 table.setMetadata(self.algMetadata) 

224 detRet = self.detection.run(table, diffExp) 

225 sources = detRet.sources 

226 if self.config.doMeasurement: 

227 self.measurement.measure(diffExp, sources) 

228 

229 mask0 = snap0.getMaskedImage().getMask() 

230 mask1 = snap1.getMaskedImage().getMask() 

231 detRet.positive.setMask(mask0, "DETECTED") 

232 detRet.negative.setMask(mask1, "DETECTED") 

233 

234 maskD = diffExp.getMaskedImage().getMask() 

235 detRet.positive.setMask(maskD, "DETECTED") 

236 detRet.negative.setMask(maskD, "DETECTED_NEGATIVE") 

237 

238 combinedExp = self.addSnaps(snap0, snap1) 

239 

240 return pipeBase.Struct( 

241 exposure=combinedExp, 

242 sources=sources, 

243 ) 

244 

245 def addSnaps(self, snap0, snap1): 

246 """Add two snap exposures together, returning a new exposure. 

247 

248 Parameters 

249 ---------- 

250 snap0 : `Unknown` 

251 Snap exposure 0. 

252 snap1 : `Unknown` 

253 Snap exposure 1. 

254 

255 Returns 

256 ------- 

257 combinedExp : `Unknown` 

258 Combined exposure. 

259 """ 

260 self.log.info("snapCombine addSnaps") 

261 

262 combinedExp = snap0.Factory(snap0, True) 

263 combinedMi = combinedExp.getMaskedImage() 

264 combinedMi.set(0) 

265 

266 weightMap = combinedMi.getImage().Factory(combinedMi.getBBox()) 

267 weight = 1.0 

268 badPixelMask = afwImage.Mask.getPlaneBitMask(self.config.badMaskPlanes) 

269 addToCoadd(combinedMi, weightMap, snap0.getMaskedImage(), badPixelMask, weight) 

270 addToCoadd(combinedMi, weightMap, snap1.getMaskedImage(), badPixelMask, weight) 

271 

272 # pre-scaling the weight map instead of post-scaling the combinedMi saves a bit of time 

273 # because the weight map is a simple Image instead of a MaskedImage 

274 weightMap *= 0.5 # so result is sum of both images, instead of average 

275 combinedMi /= weightMap 

276 setCoaddEdgeBits(combinedMi.getMask(), weightMap) 

277 

278 # note: none of the inputs has a valid PhotoCalib object, so that is not touched 

279 # Filter was already copied 

280 

281 combinedMetadata = combinedExp.getMetadata() 

282 metadata0 = snap0.getMetadata() 

283 metadata1 = snap1.getMetadata() 

284 self.fixMetadata(combinedMetadata, metadata0, metadata1) 

285 

286 return combinedExp 

287 

288 def fixMetadata(self, combinedMetadata, metadata0, metadata1): 

289 """Fix the metadata of the combined exposure (in place). 

290 

291 This implementation handles items specified by config.averageKeys and config.sumKeys, 

292 which have data type restrictions. To handle other data types (such as sexagesimal 

293 positions and ISO dates) you must supplement this method with your own code. 

294 

295 Parameters 

296 ---------- 

297 combinedMetadata : `lsst.daf.base.PropertySet` 

298 Metadata of combined exposure; 

299 on input this is a deep copy of metadata0 (a PropertySet). 

300 metadata0 : `lsst.daf.base.PropertySet` 

301 Metadata of snap0 (a PropertySet). 

302 metadata1 : `lsst.daf.base.PropertySet` 

303 Metadata of snap1 (a PropertySet). 

304 

305 Notes 

306 ----- 

307 The inputs are presently PropertySets due to ticket #2542. However, in some sense 

308 they are just PropertyLists that are missing some methods. In particular: comments and order 

309 are preserved if you alter an existing value with set(key, value). 

310 """ 

311 keyDoAvgList = [] 

312 if self.config.averageKeys: 

313 keyDoAvgList += [(key, 1) for key in self.config.averageKeys] 

314 if self.config.sumKeys: 

315 keyDoAvgList += [(key, 0) for key in self.config.sumKeys] 

316 for key, doAvg in keyDoAvgList: 

317 opStr = "average" if doAvg else "sum" 

318 try: 

319 val0 = metadata0.getScalar(key) 

320 val1 = metadata1.getScalar(key) 

321 except Exception: 

322 self.log.warning("Could not %s metadata %r: missing from one or both exposures", opStr, key) 

323 continue 

324 

325 try: 

326 combinedVal = val0 + val1 

327 if doAvg: 

328 combinedVal /= 2.0 

329 except Exception: 

330 self.log.warning("Could not %s metadata %r: value %r and/or %r not numeric", 

331 opStr, key, val0, val1) 

332 continue 

333 

334 combinedMetadata.set(key, combinedVal) 

335 

336 def makeInitialPsf(self, exposure, fwhmPix=None): 

337 """Initialise the detection procedure by setting the PSF and WCS. 

338 

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

340 Exposure to process. 

341 

342 Returns 

343 ------- 

344 psf : `Unknown` 

345 PSF, WCS 

346 

347 AssertionError 

348 Raised if any of the following occur: 

349 - No exposure provided. 

350 - No wcs in exposure. 

351 """ 

352 assert exposure, "No exposure provided" 

353 wcs = exposure.getWcs() 

354 assert wcs, "No wcs in exposure" 

355 

356 if fwhmPix is None: 

357 fwhmPix = self.config.initialPsf.fwhm / wcs.getPixelScale().asArcseconds() 

358 

359 size = self.config.initialPsf.size 

360 model = self.config.initialPsf.model 

361 self.log.info("installInitialPsf fwhm=%s pixels; size=%s pixels", fwhmPix, size) 

362 psfCls = getattr(measAlg, model + "Psf") 

363 psf = psfCls(size, size, fwhmPix/(2.0*num.sqrt(2*num.log(2.0)))) 

364 return psf