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 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 logging 

25 

26import numpy as np 

27import piff 

28import galsim 

29 

30import lsst.log 

31import lsst.pex.config as pexConfig 

32import lsst.meas.algorithms as measAlg 

33from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask 

34from .piffPsf import PiffPsf 

35 

36 

37class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass): 

38 spatialOrder = pexConfig.Field( 

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

40 dtype=int, 

41 default=2, 

42 ) 

43 samplingSize = pexConfig.Field( 

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

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

46 dtype=float, 

47 default=1, 

48 ) 

49 outlierNSigma = pexConfig.Field( 

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

51 dtype=float, 

52 default=4.0 

53 ) 

54 outlierMaxRemove = pexConfig.Field( 

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

56 dtype=float, 

57 default=0.05 

58 ) 

59 maxSNR = pexConfig.Field( 

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

61 "than this value.", 

62 dtype=float, 

63 default=200.0 

64 ) 

65 

66 def setDefaults(self): 

67 self.kernelSize = 21 

68 self.kernelSizeMin = 11 

69 self.kernelSizeMax = 35 

70 

71 

72def computeWeight(maskedImage, maxSNR): 

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

74 

75 Parameters 

76 ---------- 

77 maskedImage : `afw.image.MaskedImage` 

78 PSF candidate postage stamp 

79 maxSNR : `float` 

80 Maximum SNR applying variance floor. 

81 

82 Returns 

83 ------- 

84 weightArr : `ndarry` 

85 Array to use for weight. 

86 """ 

87 imArr = maskedImage.image.array 

88 varArr = maskedImage.variance.array 

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

90 

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

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

93 # signal-free variance. 

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

95 # fit is [1/gain, sky_var] 

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

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

98 

99 applyMaxSNR(imArr, weightArr, good, maxSNR) 

100 return weightArr 

101 

102 

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

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

105 

106 Parameters 

107 ---------- 

108 imArr : `ndarray` 

109 Signal (image) array of stamp. 

110 weightArr : `ndarray` 

111 Weight map array. May be rescaled in place. 

112 good : `ndarray` 

113 Index array of pixels to use when computing SNR. 

114 maxSNR : `float` 

115 Threshold for adjusting variance plane implementing maximum SNR. 

116 """ 

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

118 # code base explaining the calculation. 

119 # 

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

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

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

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

124 # 

125 # F = Sum_i w_i I_i^2 

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

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

128 # 

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

130 # 

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

132 # 

133 # F_noise = Sum_i w_i 1/w_i = Npix 

134 # 

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

136 # should subtract off Npix from the measured F. 

137 # 

138 # The final formula then is: 

139 # 

140 # F = Sum_i w_i I_i^2 

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

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

143 Npix = np.sum(good) 

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

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

146 if SNR > maxSNR: 

147 factor = (maxSNR / SNR)**2 

148 weightArr[good] *= factor 

149 

150 

151def _computeWeightAlternative(maskedImage, maxSNR): 

152 """Alternative algorithm for creating weight map. 

153 

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

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

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

157 that it be used. 

158 """ 

159 imArr = maskedImage.image.array 

160 varArr = maskedImage.variance.array 

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

162 

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

164 # fit is [1/gain, sky_var] 

165 gain = 1./fit[0] 

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

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

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

169 

170 applyMaxSNR(imArr, weightArr, good, maxSNR) 

171 return weightArr 

172 

173 

174class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

176 """ 

177 ConfigClass = PiffPsfDeterminerConfig 

178 

179 def determinePsf( 

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

181 ): 

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

183 candidates. 

184 

185 Parameters 

186 ---------- 

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

188 Exposure containing the PSF candidates. 

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

190 A sequence of PSF candidates typically obtained by detecting sources 

191 and then running them through a star selector. 

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

193 A home for interesting tidbits of information. 

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

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

196 

197 Returns 

198 ------- 

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

200 The measured PSF model. 

201 psfCellSet : `None` 

202 Unused by this PsfDeterminer. 

203 """ 

204 stars = [] 

205 for candidate in psfCandidateList: 

206 cmi = candidate.getMaskedImage() 

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

208 

209 bbox = cmi.getBBox() 

210 bds = galsim.BoundsI( 

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

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

213 ) 

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

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

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

217 gsWeight.array[:] = weight 

218 

219 source = candidate.getSource() 

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

221 

222 data = piff.StarData( 

223 gsImage, 

224 image_pos, 

225 weight=gsWeight 

226 ) 

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

228 

229 kernelSize = int(np.clip( 

230 self.config.kernelSize, 

231 self.config.kernelSizeMin, 

232 self.config.kernelSizeMax 

233 )) 

234 

235 piffConfig = { 

236 'type': "Simple", 

237 'model': { 

238 'type': 'PixelGrid', 

239 'scale': self.config.samplingSize, 

240 'size': kernelSize 

241 }, 

242 'interp': { 

243 'type': 'BasisPolynomial', 

244 'order': self.config.spatialOrder 

245 }, 

246 'outliers': { 

247 'type': 'Chisq', 

248 'nsigma': self.config.outlierNSigma, 

249 'max_remove': self.config.outlierMaxRemove 

250 } 

251 } 

252 

253 piffResult = piff.PSF.process(piffConfig) 

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

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

256 pointing = None 

257 

258 logger = logging.getLogger(self.log.getName()+".Piff") 

259 logger.addHandler(lsst.log.LogHandler()) 

260 

261 piffResult.fit(stars, wcs, pointing, logger=logger) 

262 psf = PiffPsf(kernelSize, kernelSize, piffResult) 

263 

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

265 if flagKey: 

266 for candidate in psfCandidateList: 

267 source = candidate.getSource() 

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

269 if posd in used_image_pos: 

270 source.set(flagKey, True) 

271 

272 if metadata is not None: 

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

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

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

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

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

278 

279 return psf, None 

280 

281 

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