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

112 statements  

« prev     ^ index     » next       coverage.py v6.4, created at 2022-06-02 04:15 -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 self.kernelSize = 21 

101 self.kernelSizeMin = 11 

102 self.kernelSizeMax = 35 

103 

104 

105def getGoodPixels(maskedImage, zeroWeightMaskBits): 

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

107 

108 Parameters 

109 ---------- 

110 maskedImage : `afw.image.MaskedImage` 

111 PSF candidate postage stamp 

112 zeroWeightMaskBits : `List[str]` 

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

114 

115 Returns 

116 ------- 

117 good : `ndarray` 

118 Index array indicating good pixels. 

119 """ 

120 imArr = maskedImage.image.array 

121 varArr = maskedImage.variance.array 

122 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits) 

123 good = ( 

124 (varArr != 0) 

125 & (np.isfinite(varArr)) 

126 & (np.isfinite(imArr)) 

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

128 ) 

129 return good 

130 

131 

132def computeWeight(maskedImage, maxSNR, good): 

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

134 

135 Parameters 

136 ---------- 

137 maskedImage : `afw.image.MaskedImage` 

138 PSF candidate postage stamp 

139 maxSNR : `float` 

140 Maximum SNR applying variance floor. 

141 good : `ndarray` 

142 Index array indicating good pixels. 

143 

144 Returns 

145 ------- 

146 weightArr : `ndarry` 

147 Array to use for weight. 

148 """ 

149 imArr = maskedImage.image.array 

150 varArr = maskedImage.variance.array 

151 

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

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

154 # signal-free variance. 

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

156 # fit is [1/gain, sky_var] 

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

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

159 

160 applyMaxSNR(imArr, weightArr, good, maxSNR) 

161 return weightArr 

162 

163 

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

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

166 

167 Parameters 

168 ---------- 

169 imArr : `ndarray` 

170 Signal (image) array of stamp. 

171 weightArr : `ndarray` 

172 Weight map array. May be rescaled in place. 

173 good : `ndarray` 

174 Index array of pixels to use when computing SNR. 

175 maxSNR : `float` 

176 Threshold for adjusting variance plane implementing maximum SNR. 

177 """ 

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

179 # code base explaining the calculation. 

180 # 

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

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

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

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

185 # 

186 # F = Sum_i w_i I_i^2 

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

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

189 # 

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

191 # 

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

193 # 

194 # F_noise = Sum_i w_i 1/w_i = Npix 

195 # 

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

197 # should subtract off Npix from the measured F. 

198 # 

199 # The final formula then is: 

200 # 

201 # F = Sum_i w_i I_i^2 

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

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

204 Npix = np.sum(good) 

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

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

207 if SNR > maxSNR: 

208 factor = (maxSNR / SNR)**2 

209 weightArr[good] *= factor 

210 

211 

212def _computeWeightAlternative(maskedImage, maxSNR): 

213 """Alternative algorithm for creating weight map. 

214 

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

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

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

218 that it be used (or be maintained). 

219 """ 

220 imArr = maskedImage.image.array 

221 varArr = maskedImage.variance.array 

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

223 

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

225 # fit is [1/gain, sky_var] 

226 gain = 1./fit[0] 

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

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

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

230 

231 applyMaxSNR(imArr, weightArr, good, maxSNR) 

232 return weightArr 

233 

234 

235class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

237 """ 

238 ConfigClass = PiffPsfDeterminerConfig 

239 _DefaultName = "psfDeterminer.Piff" 

240 

241 def determinePsf( 

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

243 ): 

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

245 candidates. 

246 

247 Parameters 

248 ---------- 

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

250 Exposure containing the PSF candidates. 

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

252 A sequence of PSF candidates typically obtained by detecting sources 

253 and then running them through a star selector. 

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

255 A home for interesting tidbits of information. 

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

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

258 

259 Returns 

260 ------- 

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

262 The measured PSF model. 

263 psfCellSet : `None` 

264 Unused by this PsfDeterminer. 

265 """ 

266 kernelSize = int(np.clip( 

267 self.config.kernelSize, 

268 self.config.kernelSizeMin, 

269 self.config.kernelSizeMax 

270 )) 

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

272 

273 stars = [] 

274 for candidate in psfCandidateList: 

275 cmi = candidate.getMaskedImage() 

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

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

278 if fracGood < self.config.minimumUnmaskedFraction: 

279 continue 

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

281 

282 bbox = cmi.getBBox() 

283 bds = galsim.BoundsI( 

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

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

286 ) 

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

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

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

290 gsWeight.array[:] = weight 

291 

292 source = candidate.getSource() 

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

294 

295 data = piff.StarData( 

296 gsImage, 

297 image_pos, 

298 weight=gsWeight 

299 ) 

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

301 

302 piffConfig = { 

303 'type': "Simple", 

304 'model': { 

305 'type': 'PixelGrid', 

306 'scale': self.config.samplingSize, 

307 'size': kernelSize, 

308 'interp': self.config.interpolant 

309 }, 

310 'interp': { 

311 'type': 'BasisPolynomial', 

312 'order': self.config.spatialOrder 

313 }, 

314 'outliers': { 

315 'type': 'Chisq', 

316 'nsigma': self.config.outlierNSigma, 

317 'max_remove': self.config.outlierMaxRemove 

318 } 

319 } 

320 

321 piffResult = piff.PSF.process(piffConfig) 

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

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

324 pointing = None 

325 

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

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

328 psf = PiffPsf(drawSize, drawSize, piffResult) 

329 

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

331 if flagKey: 

332 for candidate in psfCandidateList: 

333 source = candidate.getSource() 

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

335 if posd in used_image_pos: 

336 source.set(flagKey, True) 

337 

338 if metadata is not None: 

339 metadata["spatialFitChi2"] = piffResult.chisq 

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

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

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

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

344 

345 return psf, None 

346 

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

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

349 

350 Parameters 

351 ---------- 

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

353 Sequence of psf candidates to check. 

354 kernelSize : `int` 

355 Size of image model to use in PIFF. 

356 samplingSize : `float` 

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

358 

359 Raises 

360 ------ 

361 RuntimeError 

362 Raised if any psfCandidate has width or height smaller than 

363 config.kernelSize. 

364 """ 

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

366 candidate = psfCandidateList[0] 

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

368 if (candidate.getHeight() < drawSize 

369 or candidate.getWidth() < drawSize): 

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

371 f"{drawSize} pixels per side; " 

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

373 

374 

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