Coverage for python/lsst/meas/extensions/piff/piffPsfDeterminer.py: 24%

Shortcuts 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

101 statements  

1# This file is part of meas_extensions_piff. 

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__ = ["PiffPsfDeterminerConfig", "PiffPsfDeterminerTask"] 

23 

24import numpy as np 

25import piff 

26import galsim 

27 

28import lsst.pex.config as pexConfig 

29import lsst.meas.algorithms as measAlg 

30from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask 

31from .piffPsf import PiffPsf 

32 

33 

34class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass): 

35 spatialOrder = pexConfig.Field( 

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

37 dtype=int, 

38 default=2, 

39 ) 

40 samplingSize = pexConfig.Field( 

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

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

43 dtype=float, 

44 default=1, 

45 ) 

46 outlierNSigma = pexConfig.Field( 

47 doc="n sigma for chisq outlier rejection", 

48 dtype=float, 

49 default=4.0 

50 ) 

51 outlierMaxRemove = pexConfig.Field( 

52 doc="Max fraction of stars to remove as outliers each iteration", 

53 dtype=float, 

54 default=0.05 

55 ) 

56 maxSNR = pexConfig.Field( 

57 doc="Rescale the weight of bright stars such that their SNR is less " 

58 "than this value.", 

59 dtype=float, 

60 default=200.0 

61 ) 

62 zeroWeightMaskBits = pexConfig.ListField( 

63 doc="List of mask bits for which to set pixel weights to zero.", 

64 dtype=str, 

65 default=['BAD', 'CR', 'INTRP', 'SAT', 'SUSPECT', 'NO_DATA'] 

66 ) 

67 minimumUnmaskedFraction = pexConfig.Field( 

68 doc="Minimum fraction of unmasked pixels required to use star.", 

69 dtype=float, 

70 default=0.5 

71 ) 

72 

73 def setDefaults(self): 

74 self.kernelSize = 21 

75 self.kernelSizeMin = 11 

76 self.kernelSizeMax = 35 

77 

78 

79def getGoodPixels(maskedImage, zeroWeightMaskBits): 

80 """Compute an index array indicating good pixels to use. 

81 

82 Parameters 

83 ---------- 

84 maskedImage : `afw.image.MaskedImage` 

85 PSF candidate postage stamp 

86 zeroWeightMaskBits : `List[str]` 

87 List of mask bits for which to set pixel weights to zero. 

88 

89 Returns 

90 ------- 

91 good : `ndarray` 

92 Index array indicating good pixels. 

93 """ 

94 imArr = maskedImage.image.array 

95 varArr = maskedImage.variance.array 

96 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits) 

97 good = ( 

98 (varArr != 0) 

99 & (np.isfinite(varArr)) 

100 & (np.isfinite(imArr)) 

101 & ((maskedImage.mask.array & bitmask) == 0) 

102 ) 

103 return good 

104 

105 

106def computeWeight(maskedImage, maxSNR, good): 

107 """Derive a weight map without Poisson variance component due to signal. 

108 

109 Parameters 

110 ---------- 

111 maskedImage : `afw.image.MaskedImage` 

112 PSF candidate postage stamp 

113 maxSNR : `float` 

114 Maximum SNR applying variance floor. 

115 good : `ndarray` 

116 Index array indicating good pixels. 

117 

118 Returns 

119 ------- 

120 weightArr : `ndarry` 

121 Array to use for weight. 

122 """ 

123 imArr = maskedImage.image.array 

124 varArr = maskedImage.variance.array 

125 

126 # Fit a straight line to variance vs (sky-subtracted) signal. 

127 # The evaluate that line at zero signal to get an estimate of the 

128 # signal-free variance. 

129 fit = np.polyfit(imArr[good], varArr[good], deg=1) 

130 # fit is [1/gain, sky_var] 

131 weightArr = np.zeros_like(imArr, dtype=float) 

132 weightArr[good] = 1./fit[1] 

133 

134 applyMaxSNR(imArr, weightArr, good, maxSNR) 

135 return weightArr 

136 

137 

138def applyMaxSNR(imArr, weightArr, good, maxSNR): 

139 """Rescale weight of bright stars to cap the computed SNR. 

140 

141 Parameters 

142 ---------- 

143 imArr : `ndarray` 

144 Signal (image) array of stamp. 

145 weightArr : `ndarray` 

146 Weight map array. May be rescaled in place. 

147 good : `ndarray` 

148 Index array of pixels to use when computing SNR. 

149 maxSNR : `float` 

150 Threshold for adjusting variance plane implementing maximum SNR. 

151 """ 

152 # We define the SNR value following Piff. Here's the comment from that 

153 # code base explaining the calculation. 

154 # 

155 # The S/N value that we use will be the weighted total flux where the 

156 # weight function is the star's profile itself. This is the maximum S/N 

157 # value that any flux measurement can possibly produce, which will be 

158 # closer to an in-practice S/N than using all the pixels equally. 

159 # 

160 # F = Sum_i w_i I_i^2 

161 # var(F) = Sum_i w_i^2 I_i^2 var(I_i) 

162 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i 

163 # 

164 # S/N = F / sqrt(var(F)) 

165 # 

166 # Note that if the image is pure noise, this will produce a "signal" of 

167 # 

168 # F_noise = Sum_i w_i 1/w_i = Npix 

169 # 

170 # So for a more accurate estimate of the S/N of the actual star itself, one 

171 # should subtract off Npix from the measured F. 

172 # 

173 # The final formula then is: 

174 # 

175 # F = Sum_i w_i I_i^2 

176 # S/N = (F-Npix) / sqrt(F) 

177 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float) 

178 Npix = np.sum(good) 

179 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F) 

180 # rescale weight of bright stars. Essentially makes an error floor. 

181 if SNR > maxSNR: 

182 factor = (maxSNR / SNR)**2 

183 weightArr[good] *= factor 

184 

185 

186def _computeWeightAlternative(maskedImage, maxSNR): 

187 """Alternative algorithm for creating weight map. 

188 

189 This version is equivalent to that used by Piff internally. The weight map 

190 it produces tends to leave a residual when removing the Poisson component 

191 due to the signal. We leave it here as a reference, but without intending 

192 that it be used (or be maintained). 

193 """ 

194 imArr = maskedImage.image.array 

195 varArr = maskedImage.variance.array 

196 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr) 

197 

198 fit = np.polyfit(imArr[good], varArr[good], deg=1) 

199 # fit is [1/gain, sky_var] 

200 gain = 1./fit[0] 

201 varArr[good] -= imArr[good] / gain 

202 weightArr = np.zeros_like(imArr, dtype=float) 

203 weightArr[good] = 1./varArr[good] 

204 

205 applyMaxSNR(imArr, weightArr, good, maxSNR) 

206 return weightArr 

207 

208 

209class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

210 """A measurePsfTask PSF estimator using Piff as the implementation. 

211 """ 

212 ConfigClass = PiffPsfDeterminerConfig 

213 _DefaultName = "psfDeterminer.Piff" 

214 

215 def determinePsf( 

216 self, exposure, psfCandidateList, metadata=None, flagKey=None 

217 ): 

218 """Determine a Piff PSF model for an exposure given a list of PSF 

219 candidates. 

220 

221 Parameters 

222 ---------- 

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

224 Exposure containing the PSF candidates. 

225 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate` 

226 A sequence of PSF candidates typically obtained by detecting sources 

227 and then running them through a star selector. 

228 metadata : `lsst.daf.base import PropertyList` or `None`, optional 

229 A home for interesting tidbits of information. 

230 flagKey : `str` or `None`, optional 

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

232 

233 Returns 

234 ------- 

235 psf : `lsst.meas.extensions.piff.PiffPsf` 

236 The measured PSF model. 

237 psfCellSet : `None` 

238 Unused by this PsfDeterminer. 

239 """ 

240 kernelSize = int(np.clip( 

241 self.config.kernelSize, 

242 self.config.kernelSizeMin, 

243 self.config.kernelSizeMax 

244 )) 

245 self._validatePsfCandidates(psfCandidateList, kernelSize) 

246 

247 stars = [] 

248 for candidate in psfCandidateList: 

249 cmi = candidate.getMaskedImage() 

250 good = getGoodPixels(cmi, self.config.zeroWeightMaskBits) 

251 fracGood = np.sum(good)/good.size 

252 if fracGood < self.config.minimumUnmaskedFraction: 

253 continue 

254 weight = computeWeight(cmi, self.config.maxSNR, good) 

255 

256 bbox = cmi.getBBox() 

257 bds = galsim.BoundsI( 

258 galsim.PositionI(*bbox.getMin()), 

259 galsim.PositionI(*bbox.getMax()) 

260 ) 

261 gsImage = galsim.Image(bds, scale=1.0, dtype=float) 

262 gsImage.array[:] = cmi.image.array 

263 gsWeight = galsim.Image(bds, scale=1.0, dtype=float) 

264 gsWeight.array[:] = weight 

265 

266 source = candidate.getSource() 

267 image_pos = galsim.PositionD(source.getX(), source.getY()) 

268 

269 data = piff.StarData( 

270 gsImage, 

271 image_pos, 

272 weight=gsWeight 

273 ) 

274 stars.append(piff.Star(data, None)) 

275 

276 piffConfig = { 

277 'type': "Simple", 

278 'model': { 

279 'type': 'PixelGrid', 

280 'scale': self.config.samplingSize, 

281 'size': kernelSize 

282 }, 

283 'interp': { 

284 'type': 'BasisPolynomial', 

285 'order': self.config.spatialOrder 

286 }, 

287 'outliers': { 

288 'type': 'Chisq', 

289 'nsigma': self.config.outlierNSigma, 

290 'max_remove': self.config.outlierMaxRemove 

291 } 

292 } 

293 

294 piffResult = piff.PSF.process(piffConfig) 

295 # Run on a single CCD, and in image coords rather than sky coords. 

296 wcs = {0: galsim.PixelScale(1.0)} 

297 pointing = None 

298 

299 piffResult.fit(stars, wcs, pointing, logger=self.log) 

300 psf = PiffPsf(kernelSize, kernelSize, piffResult) 

301 

302 used_image_pos = [s.image_pos for s in piffResult.stars] 

303 if flagKey: 

304 for candidate in psfCandidateList: 

305 source = candidate.getSource() 

306 posd = galsim.PositionD(source.getX(), source.getY()) 

307 if posd in used_image_pos: 

308 source.set(flagKey, True) 

309 

310 if metadata is not None: 

311 metadata["spatialFitChi2"] = piffResult.chisq 

312 metadata["numAvailStars"] = len(stars) 

313 metadata["numGoodStars"] = len(piffResult.stars) 

314 metadata["avgX"] = np.mean([p.x for p in piffResult.stars]) 

315 metadata["avgY"] = np.mean([p.y for p in piffResult.stars]) 

316 

317 return psf, None 

318 

319 def _validatePsfCandidates(self, psfCandidateList, kernelSize): 

320 """Raise if psfCandidates are smaller than the configured kernelSize. 

321 

322 Parameters 

323 ---------- 

324 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate` 

325 Sequence of psf candidates to check. 

326 kernelSize : `int` 

327 Size of image model to use in PIFF. 

328 

329 Raises 

330 ------ 

331 RuntimeError 

332 Raised if any psfCandidate has width or height smaller than 

333 config.kernelSize. 

334 """ 

335 # We can assume all candidates have the same dimensions. 

336 candidate = psfCandidateList[0] 

337 if (candidate.getHeight() < kernelSize 

338 or candidate.getWidth() < kernelSize): 

339 raise RuntimeError("PSF candidates must be at least config.kernelSize=" 

340 f"{kernelSize} pixels per side; " 

341 f"found {candidate.getWidth()}x{candidate.getHeight()}.") 

342 

343 

344measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)