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

112 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-28 02:36 -0700

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 

27import re 

28 

29import lsst.pex.config as pexConfig 

30import lsst.meas.algorithms as measAlg 

31from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask 

32from .piffPsf import PiffPsf 

33 

34 

35class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass): 

36 def _validateGalsimInterpolant(name: str) -> bool: # noqa: N805 

37 """A helper function to validate the GalSim interpolant at config time. 

38 """ 

39 # First, check if ``name`` is a valid Lanczos interpolant. 

40 for pattern in (re.compile(r"Lanczos\(\d+\)"), re.compile(r"galsim.Lanczos\(\d+\)"),): 

41 match = re.match(pattern, name) # Search from the start of the string. 

42 if match is not None: 

43 # Check that the pattern is also the end of the string. 

44 return match.end() == len(name) 

45 

46 # If not, check if ``name`` is any other valid GalSim interpolant. 

47 names = {"galsim.{interp}" for interp in 

48 ("Cubic", "Delta", "Linear", "Nearest", "Quintic", "SincInterpolant") 

49 } 

50 return name in names 

51 

52 spatialOrder = pexConfig.Field( 

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

54 dtype=int, 

55 default=2, 

56 ) 

57 samplingSize = pexConfig.Field( 

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

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

60 dtype=float, 

61 default=1, 

62 ) 

63 outlierNSigma = pexConfig.Field( 

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

65 dtype=float, 

66 default=4.0 

67 ) 

68 outlierMaxRemove = pexConfig.Field( 

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

70 dtype=float, 

71 default=0.05 

72 ) 

73 maxSNR = pexConfig.Field( 

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

75 "than this value.", 

76 dtype=float, 

77 default=200.0 

78 ) 

79 zeroWeightMaskBits = pexConfig.ListField( 

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

81 dtype=str, 

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

83 ) 

84 minimumUnmaskedFraction = pexConfig.Field( 

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

86 dtype=float, 

87 default=0.5 

88 ) 

89 interpolant = pexConfig.Field( 

90 doc="GalSim interpolant name for Piff to use. " 

91 "Options include 'Lanczos(N)', where N is an integer, along with " 

92 "galsim.Cubic, galsim.Delta, galsim.Linear, galsim.Nearest, " 

93 "galsim.Quintic, and galsim.SincInterpolant.", 

94 dtype=str, 

95 check=_validateGalsimInterpolant, 

96 default="Lanczos(11)", 

97 ) 

98 

99 def setDefaults(self): 

100 # kernelSize should be at least 25 so that 

101 # i) aperture flux with 12 pixel radius can be compared to PSF flux. 

102 # ii) fake sources injected to match the 12 pixel aperture flux get 

103 # measured correctly 

104 self.kernelSize = 25 

105 self.kernelSizeMin = 11 

106 self.kernelSizeMax = 35 

107 

108 

109def getGoodPixels(maskedImage, zeroWeightMaskBits): 

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

111 

112 Parameters 

113 ---------- 

114 maskedImage : `afw.image.MaskedImage` 

115 PSF candidate postage stamp 

116 zeroWeightMaskBits : `List[str]` 

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

118 

119 Returns 

120 ------- 

121 good : `ndarray` 

122 Index array indicating good pixels. 

123 """ 

124 imArr = maskedImage.image.array 

125 varArr = maskedImage.variance.array 

126 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits) 

127 good = ( 

128 (varArr != 0) 

129 & (np.isfinite(varArr)) 

130 & (np.isfinite(imArr)) 

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

132 ) 

133 return good 

134 

135 

136def computeWeight(maskedImage, maxSNR, good): 

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

138 

139 Parameters 

140 ---------- 

141 maskedImage : `afw.image.MaskedImage` 

142 PSF candidate postage stamp 

143 maxSNR : `float` 

144 Maximum SNR applying variance floor. 

145 good : `ndarray` 

146 Index array indicating good pixels. 

147 

148 Returns 

149 ------- 

150 weightArr : `ndarry` 

151 Array to use for weight. 

152 """ 

153 imArr = maskedImage.image.array 

154 varArr = maskedImage.variance.array 

155 

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

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

158 # signal-free variance. 

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

160 # fit is [1/gain, sky_var] 

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

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

163 

164 applyMaxSNR(imArr, weightArr, good, maxSNR) 

165 return weightArr 

166 

167 

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

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

170 

171 Parameters 

172 ---------- 

173 imArr : `ndarray` 

174 Signal (image) array of stamp. 

175 weightArr : `ndarray` 

176 Weight map array. May be rescaled in place. 

177 good : `ndarray` 

178 Index array of pixels to use when computing SNR. 

179 maxSNR : `float` 

180 Threshold for adjusting variance plane implementing maximum SNR. 

181 """ 

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

183 # code base explaining the calculation. 

184 # 

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

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

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

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

189 # 

190 # F = Sum_i w_i I_i^2 

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

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

193 # 

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

195 # 

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

197 # 

198 # F_noise = Sum_i w_i 1/w_i = Npix 

199 # 

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

201 # should subtract off Npix from the measured F. 

202 # 

203 # The final formula then is: 

204 # 

205 # F = Sum_i w_i I_i^2 

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

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

208 Npix = np.sum(good) 

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

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

211 if SNR > maxSNR: 

212 factor = (maxSNR / SNR)**2 

213 weightArr[good] *= factor 

214 

215 

216def _computeWeightAlternative(maskedImage, maxSNR): 

217 """Alternative algorithm for creating weight map. 

218 

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

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

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

222 that it be used (or be maintained). 

223 """ 

224 imArr = maskedImage.image.array 

225 varArr = maskedImage.variance.array 

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

227 

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

229 # fit is [1/gain, sky_var] 

230 gain = 1./fit[0] 

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

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

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

234 

235 applyMaxSNR(imArr, weightArr, good, maxSNR) 

236 return weightArr 

237 

238 

239class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

241 """ 

242 ConfigClass = PiffPsfDeterminerConfig 

243 _DefaultName = "psfDeterminer.Piff" 

244 

245 def determinePsf( 

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

247 ): 

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

249 candidates. 

250 

251 Parameters 

252 ---------- 

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

254 Exposure containing the PSF candidates. 

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

256 A sequence of PSF candidates typically obtained by detecting sources 

257 and then running them through a star selector. 

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

259 A home for interesting tidbits of information. 

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

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

262 

263 Returns 

264 ------- 

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

266 The measured PSF model. 

267 psfCellSet : `None` 

268 Unused by this PsfDeterminer. 

269 """ 

270 kernelSize = int(np.clip( 

271 self.config.kernelSize, 

272 self.config.kernelSizeMin, 

273 self.config.kernelSizeMax 

274 )) 

275 self._validatePsfCandidates(psfCandidateList, kernelSize, self.config.samplingSize) 

276 

277 stars = [] 

278 for candidate in psfCandidateList: 

279 cmi = candidate.getMaskedImage() 

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

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

282 if fracGood < self.config.minimumUnmaskedFraction: 

283 continue 

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

285 

286 bbox = cmi.getBBox() 

287 bds = galsim.BoundsI( 

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

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

290 ) 

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

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

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

294 gsWeight.array[:] = weight 

295 

296 source = candidate.getSource() 

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

298 

299 data = piff.StarData( 

300 gsImage, 

301 image_pos, 

302 weight=gsWeight 

303 ) 

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

305 

306 piffConfig = { 

307 'type': "Simple", 

308 'model': { 

309 'type': 'PixelGrid', 

310 'scale': self.config.samplingSize, 

311 'size': kernelSize, 

312 'interp': self.config.interpolant 

313 }, 

314 'interp': { 

315 'type': 'BasisPolynomial', 

316 'order': self.config.spatialOrder 

317 }, 

318 'outliers': { 

319 'type': 'Chisq', 

320 'nsigma': self.config.outlierNSigma, 

321 'max_remove': self.config.outlierMaxRemove 

322 } 

323 } 

324 

325 piffResult = piff.PSF.process(piffConfig) 

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

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

328 pointing = None 

329 

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

331 drawSize = 2*np.floor(0.5*kernelSize/self.config.samplingSize) + 1 

332 psf = PiffPsf(drawSize, drawSize, piffResult) 

333 

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

335 if flagKey: 

336 for candidate in psfCandidateList: 

337 source = candidate.getSource() 

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

339 if posd in used_image_pos: 

340 source.set(flagKey, True) 

341 

342 if metadata is not None: 

343 metadata["spatialFitChi2"] = piffResult.chisq 

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

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

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

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

348 

349 return psf, None 

350 

351 def _validatePsfCandidates(self, psfCandidateList, kernelSize, samplingSize): 

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

353 

354 Parameters 

355 ---------- 

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

357 Sequence of psf candidates to check. 

358 kernelSize : `int` 

359 Size of image model to use in PIFF. 

360 samplingSize : `float` 

361 Resolution of the internal PSF model relative to the pixel size. 

362 

363 Raises 

364 ------ 

365 RuntimeError 

366 Raised if any psfCandidate has width or height smaller than 

367 config.kernelSize. 

368 """ 

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

370 candidate = psfCandidateList[0] 

371 drawSize = int(2*np.floor(0.5*kernelSize/samplingSize) + 1) 

372 if (candidate.getHeight() < drawSize 

373 or candidate.getWidth() < drawSize): 

374 raise RuntimeError("PSF candidates must be at least config.kernelSize/config.samplingSize=" 

375 f"{drawSize} pixels per side; " 

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

377 

378 

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