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

112 statements  

« prev     ^ index     » next       coverage.py v6.4.4, created at 2022-09-27 09:32 +0000

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 Parameters 

40 ---------- 

41 name : str 

42 The name of the interpolant to use from GalSim. Valid options are: 

43 Lancsos(N) where n is a positive integer 

44 Linear 

45 Cubic 

46 Quintic 

47 Delta 

48 Nearest 

49 SincInterpolant 

50 

51 Returns 

52 ------- 

53 is_valid : bool 

54 Whether the provided interpolant name is valid. 

55 """ 

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

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

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

59 if match is not None: 

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

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

62 

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

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

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

66 } 

67 return name in names 

68 

69 spatialOrder = pexConfig.Field( 

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

71 dtype=int, 

72 default=2, 

73 ) 

74 samplingSize = pexConfig.Field( 

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

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

77 dtype=float, 

78 default=1, 

79 ) 

80 outlierNSigma = pexConfig.Field( 

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

82 dtype=float, 

83 default=4.0 

84 ) 

85 outlierMaxRemove = pexConfig.Field( 

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

87 dtype=float, 

88 default=0.05 

89 ) 

90 maxSNR = pexConfig.Field( 

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

92 "than this value.", 

93 dtype=float, 

94 default=200.0 

95 ) 

96 zeroWeightMaskBits = pexConfig.ListField( 

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

98 dtype=str, 

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

100 ) 

101 minimumUnmaskedFraction = pexConfig.Field( 

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

103 dtype=float, 

104 default=0.5 

105 ) 

106 interpolant = pexConfig.Field( 

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

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

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

110 "galsim.Quintic, and galsim.SincInterpolant.", 

111 dtype=str, 

112 check=_validateGalsimInterpolant, 

113 default="Lanczos(11)", 

114 ) 

115 

116 def setDefaults(self): 

117 # kernelSize should be at least 25 so that 

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

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

120 # measured correctly 

121 self.kernelSize = 25 

122 self.kernelSizeMin = 11 

123 self.kernelSizeMax = 35 

124 

125 

126def getGoodPixels(maskedImage, zeroWeightMaskBits): 

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

128 

129 Parameters 

130 ---------- 

131 maskedImage : `afw.image.MaskedImage` 

132 PSF candidate postage stamp 

133 zeroWeightMaskBits : `List[str]` 

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

135 

136 Returns 

137 ------- 

138 good : `ndarray` 

139 Index array indicating good pixels. 

140 """ 

141 imArr = maskedImage.image.array 

142 varArr = maskedImage.variance.array 

143 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits) 

144 good = ( 

145 (varArr != 0) 

146 & (np.isfinite(varArr)) 

147 & (np.isfinite(imArr)) 

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

149 ) 

150 return good 

151 

152 

153def computeWeight(maskedImage, maxSNR, good): 

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

155 

156 Parameters 

157 ---------- 

158 maskedImage : `afw.image.MaskedImage` 

159 PSF candidate postage stamp 

160 maxSNR : `float` 

161 Maximum SNR applying variance floor. 

162 good : `ndarray` 

163 Index array indicating good pixels. 

164 

165 Returns 

166 ------- 

167 weightArr : `ndarry` 

168 Array to use for weight. 

169 """ 

170 imArr = maskedImage.image.array 

171 varArr = maskedImage.variance.array 

172 

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

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

175 # signal-free variance. 

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

177 # fit is [1/gain, sky_var] 

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

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

180 

181 applyMaxSNR(imArr, weightArr, good, maxSNR) 

182 return weightArr 

183 

184 

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

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

187 

188 Parameters 

189 ---------- 

190 imArr : `ndarray` 

191 Signal (image) array of stamp. 

192 weightArr : `ndarray` 

193 Weight map array. May be rescaled in place. 

194 good : `ndarray` 

195 Index array of pixels to use when computing SNR. 

196 maxSNR : `float` 

197 Threshold for adjusting variance plane implementing maximum SNR. 

198 """ 

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

200 # code base explaining the calculation. 

201 # 

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

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

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

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

206 # 

207 # F = Sum_i w_i I_i^2 

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

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

210 # 

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

212 # 

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

214 # 

215 # F_noise = Sum_i w_i 1/w_i = Npix 

216 # 

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

218 # should subtract off Npix from the measured F. 

219 # 

220 # The final formula then is: 

221 # 

222 # F = Sum_i w_i I_i^2 

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

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

225 Npix = np.sum(good) 

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

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

228 if SNR > maxSNR: 

229 factor = (maxSNR / SNR)**2 

230 weightArr[good] *= factor 

231 

232 

233def _computeWeightAlternative(maskedImage, maxSNR): 

234 """Alternative algorithm for creating weight map. 

235 

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

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

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

239 that it be used (or be maintained). 

240 """ 

241 imArr = maskedImage.image.array 

242 varArr = maskedImage.variance.array 

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

244 

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

246 # fit is [1/gain, sky_var] 

247 gain = 1./fit[0] 

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

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

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

251 

252 applyMaxSNR(imArr, weightArr, good, maxSNR) 

253 return weightArr 

254 

255 

256class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

258 """ 

259 ConfigClass = PiffPsfDeterminerConfig 

260 _DefaultName = "psfDeterminer.Piff" 

261 

262 def determinePsf( 

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

264 ): 

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

266 candidates. 

267 

268 Parameters 

269 ---------- 

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

271 Exposure containing the PSF candidates. 

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

273 A sequence of PSF candidates typically obtained by detecting sources 

274 and then running them through a star selector. 

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

276 A home for interesting tidbits of information. 

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

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

279 

280 Returns 

281 ------- 

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

283 The measured PSF model. 

284 psfCellSet : `None` 

285 Unused by this PsfDeterminer. 

286 """ 

287 kernelSize = int(np.clip( 

288 self.config.kernelSize, 

289 self.config.kernelSizeMin, 

290 self.config.kernelSizeMax 

291 )) 

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

293 

294 stars = [] 

295 for candidate in psfCandidateList: 

296 cmi = candidate.getMaskedImage() 

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

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

299 if fracGood < self.config.minimumUnmaskedFraction: 

300 continue 

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

302 

303 bbox = cmi.getBBox() 

304 bds = galsim.BoundsI( 

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

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

307 ) 

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

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

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

311 gsWeight.array[:] = weight 

312 

313 source = candidate.getSource() 

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

315 

316 data = piff.StarData( 

317 gsImage, 

318 image_pos, 

319 weight=gsWeight 

320 ) 

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

322 

323 piffConfig = { 

324 'type': "Simple", 

325 'model': { 

326 'type': 'PixelGrid', 

327 'scale': self.config.samplingSize, 

328 'size': kernelSize, 

329 'interp': self.config.interpolant 

330 }, 

331 'interp': { 

332 'type': 'BasisPolynomial', 

333 'order': self.config.spatialOrder 

334 }, 

335 'outliers': { 

336 'type': 'Chisq', 

337 'nsigma': self.config.outlierNSigma, 

338 'max_remove': self.config.outlierMaxRemove 

339 } 

340 } 

341 

342 piffResult = piff.PSF.process(piffConfig) 

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

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

345 pointing = None 

346 

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

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

349 psf = PiffPsf(drawSize, drawSize, piffResult) 

350 

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

352 if flagKey: 

353 for candidate in psfCandidateList: 

354 source = candidate.getSource() 

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

356 if posd in used_image_pos: 

357 source.set(flagKey, True) 

358 

359 if metadata is not None: 

360 metadata["spatialFitChi2"] = piffResult.chisq 

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

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

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

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

365 

366 return psf, None 

367 

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

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

370 

371 Parameters 

372 ---------- 

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

374 Sequence of psf candidates to check. 

375 kernelSize : `int` 

376 Size of image model to use in PIFF. 

377 samplingSize : `float` 

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

379 

380 Raises 

381 ------ 

382 RuntimeError 

383 Raised if any psfCandidate has width or height smaller than 

384 config.kernelSize. 

385 """ 

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

387 candidate = psfCandidateList[0] 

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

389 if (candidate.getHeight() < drawSize 

390 or candidate.getWidth() < drawSize): 

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

392 f"{drawSize} pixels per side; " 

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

394 

395 

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