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

85 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-02-05 18:24 -0800

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 

63 def setDefaults(self): 

64 self.kernelSize = 21 

65 self.kernelSizeMin = 11 

66 self.kernelSizeMax = 35 

67 

68 

69def computeWeight(maskedImage, maxSNR): 

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

71 

72 Parameters 

73 ---------- 

74 maskedImage : `afw.image.MaskedImage` 

75 PSF candidate postage stamp 

76 maxSNR : `float` 

77 Maximum SNR applying variance floor. 

78 

79 Returns 

80 ------- 

81 weightArr : `ndarry` 

82 Array to use for weight. 

83 """ 

84 imArr = maskedImage.image.array 

85 varArr = maskedImage.variance.array 

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

87 

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

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

90 # signal-free variance. 

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

92 # fit is [1/gain, sky_var] 

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

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

95 

96 applyMaxSNR(imArr, weightArr, good, maxSNR) 

97 return weightArr 

98 

99 

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

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

102 

103 Parameters 

104 ---------- 

105 imArr : `ndarray` 

106 Signal (image) array of stamp. 

107 weightArr : `ndarray` 

108 Weight map array. May be rescaled in place. 

109 good : `ndarray` 

110 Index array of pixels to use when computing SNR. 

111 maxSNR : `float` 

112 Threshold for adjusting variance plane implementing maximum SNR. 

113 """ 

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

115 # code base explaining the calculation. 

116 # 

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

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

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

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

121 # 

122 # F = Sum_i w_i I_i^2 

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

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

125 # 

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

127 # 

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

129 # 

130 # F_noise = Sum_i w_i 1/w_i = Npix 

131 # 

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

133 # should subtract off Npix from the measured F. 

134 # 

135 # The final formula then is: 

136 # 

137 # F = Sum_i w_i I_i^2 

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

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

140 Npix = np.sum(good) 

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

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

143 if SNR > maxSNR: 

144 factor = (maxSNR / SNR)**2 

145 weightArr[good] *= factor 

146 

147 

148def _computeWeightAlternative(maskedImage, maxSNR): 

149 """Alternative algorithm for creating weight map. 

150 

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

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

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

154 that it be used. 

155 """ 

156 imArr = maskedImage.image.array 

157 varArr = maskedImage.variance.array 

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

159 

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

161 # fit is [1/gain, sky_var] 

162 gain = 1./fit[0] 

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

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

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

166 

167 applyMaxSNR(imArr, weightArr, good, maxSNR) 

168 return weightArr 

169 

170 

171class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

173 """ 

174 ConfigClass = PiffPsfDeterminerConfig 

175 _DefaultName = "psfDeterminer.Piff" 

176 

177 def determinePsf( 

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

179 ): 

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

181 candidates. 

182 

183 Parameters 

184 ---------- 

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

186 Exposure containing the PSF candidates. 

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

188 A sequence of PSF candidates typically obtained by detecting sources 

189 and then running them through a star selector. 

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

191 A home for interesting tidbits of information. 

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

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

194 

195 Returns 

196 ------- 

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

198 The measured PSF model. 

199 psfCellSet : `None` 

200 Unused by this PsfDeterminer. 

201 """ 

202 stars = [] 

203 for candidate in psfCandidateList: 

204 cmi = candidate.getMaskedImage() 

205 weight = computeWeight(cmi, self.config.maxSNR) 

206 

207 bbox = cmi.getBBox() 

208 bds = galsim.BoundsI( 

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

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

211 ) 

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

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

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

215 gsWeight.array[:] = weight 

216 

217 source = candidate.getSource() 

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

219 

220 data = piff.StarData( 

221 gsImage, 

222 image_pos, 

223 weight=gsWeight 

224 ) 

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

226 

227 kernelSize = int(np.clip( 

228 self.config.kernelSize, 

229 self.config.kernelSizeMin, 

230 self.config.kernelSizeMax 

231 )) 

232 

233 piffConfig = { 

234 'type': "Simple", 

235 'model': { 

236 'type': 'PixelGrid', 

237 'scale': self.config.samplingSize, 

238 'size': kernelSize 

239 }, 

240 'interp': { 

241 'type': 'BasisPolynomial', 

242 'order': self.config.spatialOrder 

243 }, 

244 'outliers': { 

245 'type': 'Chisq', 

246 'nsigma': self.config.outlierNSigma, 

247 'max_remove': self.config.outlierMaxRemove 

248 } 

249 } 

250 

251 piffResult = piff.PSF.process(piffConfig) 

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

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

254 pointing = None 

255 

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

257 psf = PiffPsf(kernelSize, kernelSize, piffResult) 

258 

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

260 if flagKey: 

261 for candidate in psfCandidateList: 

262 source = candidate.getSource() 

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

264 if posd in used_image_pos: 

265 source.set(flagKey, True) 

266 

267 if metadata is not None: 

268 metadata.set("spatialFitChi2", piffResult.chisq) 

269 metadata.set("numAvailStars", len(stars)) 

270 metadata.set("numGoodStars", len(piffResult.stars)) 

271 metadata.set("avgX", np.mean([p.x for p in piffResult.stars])) 

272 metadata.set("avgY", np.mean([p.y for p in piffResult.stars])) 

273 

274 return psf, None 

275 

276 

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