Coverage for python/lsst/meas/extensions/psfex/psfexPsfDeterminer.py: 12%

212 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-17 10:58 +0000

1# This file is part of meas_extensions_psfex. 

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__ = ("PsfexPsfDeterminerConfig", "PsfexPsfDeterminerTask") 

23 

24import os 

25import numpy as np 

26 

27import lsst.daf.base as dafBase 

28import lsst.pex.config as pexConfig 

29import lsst.pex.exceptions as pexExcept 

30import lsst.geom as geom 

31import lsst.afw.geom.ellipses as afwEll 

32import lsst.afw.display as afwDisplay 

33import lsst.afw.image as afwImage 

34import lsst.afw.math as afwMath 

35import lsst.meas.algorithms as measAlg 

36import lsst.meas.algorithms.utils as maUtils 

37import lsst.meas.extensions.psfex as psfex 

38 

39 

40class PsfexPsfDeterminerConfig(measAlg.BasePsfDeterminerConfig): 

41 spatialOrder = pexConfig.Field[int]( 41 ↛ exitline 41 didn't jump to the function exit

42 doc="specify spatial order for PSF kernel creation", 

43 default=2, 

44 check=lambda x: x >= 1, 

45 ) 

46 sizeCellX = pexConfig.Field[int]( 46 ↛ exitline 46 didn't jump to the function exit

47 doc="size of cell used to determine PSF (pixels, column direction)", 

48 default=256, 

49 # minValue = 10, 

50 check=lambda x: x >= 10, 

51 ) 

52 sizeCellY = pexConfig.Field[int]( 52 ↛ exitline 52 didn't jump to the function exit

53 doc="size of cell used to determine PSF (pixels, row direction)", 

54 default=sizeCellX.default, 

55 # minValue = 10, 

56 check=lambda x: x >= 10, 

57 ) 

58 samplingSize = pexConfig.Field[float]( 

59 doc="Resolution of the internal PSF model relative to the pixel size; " 

60 "e.g. 0.5 is equal to 2x oversampling", 

61 default=0.5, 

62 ) 

63 badMaskBits = pexConfig.ListField[str]( 

64 doc="List of mask bits which cause a source to be rejected as bad " 

65 "N.b. INTRP is used specially in PsfCandidateSet; it means \"Contaminated by neighbour\"", 

66 default=["INTRP", "SAT"], 

67 ) 

68 psfexBasis = pexConfig.ChoiceField[str]( 

69 doc="BASIS value given to psfex. PIXEL_AUTO will use the requested samplingSize only if " 

70 "the FWHM < 3 pixels. Otherwise, it will use samplingSize=1. PIXEL will always use the " 

71 "requested samplingSize", 

72 allowed={ 

73 "PIXEL": "Always use requested samplingSize", 

74 "PIXEL_AUTO": "Only use requested samplingSize when FWHM < 3", 

75 }, 

76 default='PIXEL_AUTO', 

77 optional=False, 

78 ) 

79 tolerance = pexConfig.Field[float]( 

80 doc="tolerance of spatial fitting", 

81 default=1e-2, 

82 ) 

83 lam = pexConfig.Field[float]( 

84 doc="floor for variance is lam*data", 

85 default=0.05, 

86 ) 

87 reducedChi2ForPsfCandidates = pexConfig.Field[float]( 

88 doc="for psf candidate evaluation", 

89 default=2.0, 

90 ) 

91 spatialReject = pexConfig.Field[float]( 

92 doc="Rejection threshold (stdev) for candidates based on spatial fit", 

93 default=3.0, 

94 ) 

95 recentroid = pexConfig.Field[bool]( 

96 doc="Should PSFEX be permitted to recentroid PSF candidates?", 

97 default=False, 

98 ) 

99 photometricFluxField = pexConfig.Field[str]( 

100 doc="Flux field to use for photometric normalization. This overrides the " 

101 "``PHOTFLUX_KEY`` field for psfex. The associated flux error is " 

102 "derived by appending ``Err`` to this field.", 

103 default="base_CircularApertureFlux_9_0_instFlux", 

104 ) 

105 

106 def setDefaults(self): 

107 super().setDefaults() 

108 self.stampSize = 41 

109 

110 

111class PsfexPsfDeterminerTask(measAlg.BasePsfDeterminerTask): 

112 ConfigClass = PsfexPsfDeterminerConfig 

113 

114 def determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None): 

115 """Determine a PSFEX PSF model for an exposure given a list of PSF 

116 candidates. 

117 

118 Parameters 

119 ---------- 

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

121 Exposure containing the PSF candidates. 

122 psfCandidateList: iterable of `lsst.meas.algorithms.PsfCandidate` 

123 Sequence of PSF candidates typically obtained by detecting sources 

124 and then running them through a star selector. 

125 metadata: metadata, optional 

126 A home for interesting tidbits of information. 

127 flagKey: `lsst.afw.table.Key`, optional 

128 Schema key used to mark sources actually used in PSF determination. 

129 

130 Returns 

131 ------- 

132 psf: `lsst.meas.extensions.psfex.PsfexPsf` 

133 The determined PSF. 

134 """ 

135 

136 import lsstDebug 

137 display = lsstDebug.Info(__name__).display 

138 displayExposure = display and \ 

139 lsstDebug.Info(__name__).displayExposure # display the Exposure + spatialCells 

140 displayPsfComponents = display and \ 

141 lsstDebug.Info(__name__).displayPsfComponents # show the basis functions 

142 showBadCandidates = display and \ 

143 lsstDebug.Info(__name__).showBadCandidates # Include bad candidates (meaningless, methinks) 

144 displayResiduals = display and \ 

145 lsstDebug.Info(__name__).displayResiduals # show residuals 

146 displayPsfMosaic = display and \ 

147 lsstDebug.Info(__name__).displayPsfMosaic # show mosaic of reconstructed PSF(x,y) 

148 normalizeResiduals = lsstDebug.Info(__name__).normalizeResiduals 

149 afwDisplay.setDefaultMaskTransparency(75) 

150 # Normalise residuals by object amplitude 

151 

152 mi = exposure.getMaskedImage() 

153 

154 nCand = len(psfCandidateList) 

155 if nCand == 0: 

156 raise RuntimeError("No PSF candidates supplied.") 

157 # 

158 # How big should our PSF models be? 

159 # 

160 if display: # only needed for debug plots 

161 # construct and populate a spatial cell set 

162 bbox = mi.getBBox(afwImage.PARENT) 

163 psfCellSet = afwMath.SpatialCellSet(bbox, self.config.sizeCellX, self.config.sizeCellY) 

164 else: 

165 psfCellSet = None 

166 

167 sizes = np.empty(nCand) 

168 for i, psfCandidate in enumerate(psfCandidateList): 

169 try: 

170 if psfCellSet: 

171 psfCellSet.insertCandidate(psfCandidate) 

172 except Exception as e: 

173 self.log.error("Skipping PSF candidate %d of %d: %s", i, len(psfCandidateList), e) 

174 continue 

175 

176 source = psfCandidate.getSource() 

177 quad = afwEll.Quadrupole(source.getIxx(), source.getIyy(), source.getIxy()) 

178 rmsSize = quad.getTraceRadius() 

179 sizes[i] = rmsSize 

180 

181 pixKernelSize = self.config.stampSize 

182 actualKernelSize = int(2*np.floor(0.5*pixKernelSize/self.config.samplingSize) + 1) 

183 

184 if display: 

185 rms = np.median(sizes) 

186 self.log.debug("Median PSF RMS size=%.2f pixels (\"FWHM\"=%.2f)", 

187 rms, 2*np.sqrt(2*np.log(2))*rms) 

188 

189 self.log.trace("Psfex Kernel size=%.2f, Image Kernel Size=%.2f", actualKernelSize, pixKernelSize) 

190 

191 # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- BEGIN PSFEX 

192 # 

193 # Insert the good candidates into the set 

194 # 

195 defaultsFile = os.path.join(os.environ["MEAS_EXTENSIONS_PSFEX_DIR"], "config", "default-lsst.psfex") 

196 args_md = dafBase.PropertySet() 

197 args_md.set("BASIS_TYPE", str(self.config.psfexBasis)) 

198 args_md.set("PSFVAR_DEGREES", str(self.config.spatialOrder)) 

199 args_md.set("PSF_SIZE", str(actualKernelSize)) 

200 args_md.set("PSF_SAMPLING", str(self.config.samplingSize)) 

201 args_md.set("PHOTFLUX_KEY", str(self.config.photometricFluxField)) 

202 args_md.set("PHOTFLUXERR_KEY", str(self.config.photometricFluxField) + "Err") 

203 prefs = psfex.Prefs(defaultsFile, args_md) 

204 prefs.setCommandLine([]) 

205 prefs.addCatalog("psfexPsfDeterminer") 

206 

207 prefs.use() 

208 principalComponentExclusionFlag = bool(bool(psfex.Context.REMOVEHIDDEN) 

209 if False else psfex.Context.KEEPHIDDEN) 

210 context = psfex.Context(prefs.getContextName(), prefs.getContextGroup(), 

211 prefs.getGroupDeg(), principalComponentExclusionFlag) 

212 psfSet = psfex.Set(context) 

213 psfSet.setVigSize(pixKernelSize, pixKernelSize) 

214 psfSet.setFwhm(2*np.sqrt(2*np.log(2))*np.median(sizes)) 

215 psfSet.setRecentroid(self.config.recentroid) 

216 

217 catindex, ext = 0, 0 

218 backnoise2 = afwMath.makeStatistics(mi.getImage(), afwMath.VARIANCECLIP).getValue() 

219 ccd = exposure.getDetector() 

220 if ccd: 

221 gain = np.mean(np.array([a.getGain() for a in ccd])) 

222 else: 

223 gain = 1.0 

224 self.log.warning("Setting gain to %g", gain) 

225 

226 contextvalp = [] 

227 for i, key in enumerate(context.getName()): 

228 if key[0] == ':': 

229 try: 

230 contextvalp.append(exposure.getMetadata().getScalar(key[1:])) 

231 except KeyError as e: 

232 raise RuntimeError("%s parameter not found in the header of %s" % 

233 (key[1:], prefs.getContextName())) from e 

234 else: 

235 try: 

236 contextvalp.append(np.array([psfCandidateList[_].getSource().get(key) 

237 for _ in range(nCand)])) 

238 except KeyError as e: 

239 raise RuntimeError("%s parameter not found" % (key,)) from e 

240 psfSet.setContextname(i, key) 

241 

242 if display: 

243 frame = 0 

244 if displayExposure: 

245 disp = afwDisplay.Display(frame=frame) 

246 disp.mtv(exposure, title="psf determination") 

247 

248 badBits = mi.getMask().getPlaneBitMask(self.config.badMaskBits) 

249 fluxName = prefs.getPhotfluxRkey() 

250 fluxFlagName = "base_" + fluxName + "_flag" 

251 

252 xpos, ypos = [], [] 

253 for i, psfCandidate in enumerate(psfCandidateList): 

254 source = psfCandidate.getSource() 

255 

256 # skip sources with bad centroids 

257 xc, yc = source.getX(), source.getY() 

258 if not np.isfinite(xc) or not np.isfinite(yc): 

259 continue 

260 # skip flagged sources 

261 if fluxFlagName in source.schema and source.get(fluxFlagName): 

262 continue 

263 # skip nonfinite and negative sources 

264 flux = source.get(fluxName) 

265 if flux < 0 or not np.isfinite(flux): 

266 continue 

267 

268 try: 

269 pstamp = psfCandidate.getMaskedImage(pixKernelSize, pixKernelSize).clone() 

270 except pexExcept.LengthError: 

271 self.log.warning("Could not get stamp image for psfCandidate: %s with kernel size: %s", 

272 psfCandidate, pixKernelSize) 

273 continue 

274 

275 # From this point, we're configuring the "sample" (PSFEx's version 

276 # of a PSF candidate). 

277 # Having created the sample, we must proceed to configure it, and 

278 # then fini (finalize), or it will be malformed. 

279 try: 

280 sample = psfSet.newSample() 

281 sample.setCatindex(catindex) 

282 sample.setExtindex(ext) 

283 sample.setObjindex(i) 

284 

285 imArray = pstamp.getImage().getArray() 

286 imArray[np.where(np.bitwise_and(pstamp.getMask().getArray(), badBits))] = \ 

287 -2*psfex.BIG 

288 sample.setVig(imArray) 

289 

290 sample.setNorm(flux) 

291 sample.setBacknoise2(backnoise2) 

292 sample.setGain(gain) 

293 sample.setX(xc) 

294 sample.setY(yc) 

295 sample.setFluxrad(sizes[i]) 

296 

297 for j in range(psfSet.getNcontext()): 

298 sample.setContext(j, float(contextvalp[j][i])) 

299 except Exception as e: 

300 self.log.error("Exception when processing sample at (%f,%f): %s", xc, yc, e) 

301 continue 

302 else: 

303 psfSet.finiSample(sample) 

304 

305 xpos.append(xc) # for QA 

306 ypos.append(yc) 

307 

308 if displayExposure: 

309 with disp.Buffering(): 

310 disp.dot("o", xc, yc, ctype=afwDisplay.CYAN, size=4) 

311 

312 if psfSet.getNsample() == 0: 

313 raise RuntimeError("No good PSF candidates to pass to PSFEx") 

314 

315 # ---- Update min and max and then the scaling 

316 for i in range(psfSet.getNcontext()): 

317 cmin = contextvalp[i].min() 

318 cmax = contextvalp[i].max() 

319 psfSet.setContextScale(i, cmax - cmin) 

320 psfSet.setContextOffset(i, (cmin + cmax)/2.0) 

321 

322 # Don't waste memory! 

323 psfSet.trimMemory() 

324 

325 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- END PSFEX 

326 # 

327 # Do a PSFEX decomposition of those PSF candidates 

328 # 

329 fields = [] 

330 field = psfex.Field("Unknown") 

331 field.addExt(exposure.getWcs(), exposure.getWidth(), exposure.getHeight(), psfSet.getNsample()) 

332 field.finalize() 

333 

334 fields.append(field) 

335 

336 sets = [] 

337 sets.append(psfSet) 

338 

339 psfex.makeit(fields, sets) 

340 psfs = field.getPsfs() 

341 

342 # Flag which objects were actually used in psfex by 

343 good_indices = [] 

344 for i in range(sets[0].getNsample()): 

345 index = sets[0].getSample(i).getObjindex() 

346 if index > -1: 

347 good_indices.append(index) 

348 

349 if flagKey is not None: 

350 for i, psfCandidate in enumerate(psfCandidateList): 

351 source = psfCandidate.getSource() 

352 if i in good_indices: 

353 source.set(flagKey, True) 

354 

355 xpos = np.array(xpos) 

356 ypos = np.array(ypos) 

357 numGoodStars = len(good_indices) 

358 avgX, avgY = np.mean(xpos), np.mean(ypos) 

359 

360 psf = psfex.PsfexPsf(psfs[0], geom.Point2D(avgX, avgY)) 

361 

362 # If there are too few stars, the PSFEx psf model will reduce the order 

363 # to 0, which the Science Pipelines code cannot handle (see 

364 # https://github.com/lsst/meas_extensions_psfex/blob/f0d5218b5446faf5e39edc30e31d2e6f673ef294/src/PsfexPsf.cc#L118 

365 # ). The easiest way to test for this condition is trying to compute 

366 # the PSF kernel and checking for an InvalidParameterError. 

367 try: 

368 _ = psf.getKernel(psf.getAveragePosition()) 

369 except pexExcept.InvalidParameterError: 

370 raise RuntimeError("Failed to determine psfex psf: too few good stars.") 

371 

372 # 

373 # Display code for debugging 

374 # 

375 if display: 

376 assert psfCellSet is not None 

377 

378 if displayExposure: 

379 maUtils.showPsfSpatialCells(exposure, psfCellSet, showChi2=True, 

380 symb="o", ctype=afwDisplay.YELLOW, ctypeBad=afwDisplay.RED, 

381 size=8, display=disp) 

382 if displayResiduals: 

383 disp4 = afwDisplay.Display(frame=4) 

384 maUtils.showPsfCandidates(exposure, psfCellSet, psf=psf, display=disp4, 

385 normalize=normalizeResiduals, 

386 showBadCandidates=showBadCandidates) 

387 if displayPsfComponents: 

388 disp6 = afwDisplay.Display(frame=6) 

389 maUtils.showPsf(psf, display=disp6) 

390 if displayPsfMosaic: 

391 disp7 = afwDisplay.Display(frame=7) 

392 maUtils.showPsfMosaic(exposure, psf, display=disp7, showFwhm=True) 

393 disp.scale('linear', 0, 1) 

394 # 

395 # Generate some QA information 

396 # 

397 # Count PSF stars 

398 # 

399 if metadata is not None: 

400 metadata["spatialFitChi2"] = np.nan 

401 metadata["numAvailStars"] = nCand 

402 metadata["numGoodStars"] = numGoodStars 

403 metadata["avgX"] = avgX 

404 metadata["avgY"] = avgY 

405 

406 return psf, psfCellSet 

407 

408 

409measAlg.psfDeterminerRegistry.register("psfex", PsfexPsfDeterminerTask)