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

121 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-04-25 00:38 -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 

35def _validateGalsimInterpolant(name: str) -> bool: 

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

37 

38 Parameters 

39 ---------- 

40 name : str 

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

42 galsim.Lanczos(N) or Lancsos(N), where N is a positive integer 

43 galsim.Linear 

44 galsim.Cubic 

45 galsim.Quintic 

46 galsim.Delta 

47 galsim.Nearest 

48 galsim.SincInterpolant 

49 

50 Returns 

51 ------- 

52 is_valid : bool 

53 Whether the provided interpolant name is valid. 

54 """ 

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

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

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

58 if match is not None: 

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

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

61 

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

63 names = {f"galsim.{interp}" for interp in 

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

65 } 

66 return name in names 

67 

68 

69class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass): 

70 spatialOrder = pexConfig.Field( 

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

72 dtype=int, 

73 default=2, 

74 ) 

75 samplingSize = pexConfig.Field( 

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

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

78 dtype=float, 

79 default=1, 

80 ) 

81 outlierNSigma = pexConfig.Field( 

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

83 dtype=float, 

84 default=4.0 

85 ) 

86 outlierMaxRemove = pexConfig.Field( 

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

88 dtype=float, 

89 default=0.05 

90 ) 

91 maxSNR = pexConfig.Field( 

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

93 "than this value.", 

94 dtype=float, 

95 default=200.0 

96 ) 

97 zeroWeightMaskBits = pexConfig.ListField( 

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

99 dtype=str, 

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

101 ) 

102 minimumUnmaskedFraction = pexConfig.Field( 

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

104 dtype=float, 

105 default=0.5 

106 ) 

107 interpolant = pexConfig.Field( 

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

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

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

111 "galsim.Quintic, and galsim.SincInterpolant.", 

112 dtype=str, 

113 check=_validateGalsimInterpolant, 

114 default="Lanczos(11)", 

115 ) 

116 debugStarData = pexConfig.Field[bool]( 

117 doc="Include star images used for fitting in PSF model object.", 

118 default=False 

119 ) 

120 

121 def setDefaults(self): 

122 # kernelSize should be at least 25 so that 

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

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

125 # measured correctly 

126 self.kernelSize = 25 

127 self.kernelSizeMin = 11 

128 self.kernelSizeMax = 35 

129 

130 

131def getGoodPixels(maskedImage, zeroWeightMaskBits): 

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

133 

134 Parameters 

135 ---------- 

136 maskedImage : `afw.image.MaskedImage` 

137 PSF candidate postage stamp 

138 zeroWeightMaskBits : `List[str]` 

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

140 

141 Returns 

142 ------- 

143 good : `ndarray` 

144 Index array indicating good pixels. 

145 """ 

146 imArr = maskedImage.image.array 

147 varArr = maskedImage.variance.array 

148 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits) 

149 good = ( 

150 (varArr != 0) 

151 & (np.isfinite(varArr)) 

152 & (np.isfinite(imArr)) 

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

154 ) 

155 return good 

156 

157 

158def computeWeight(maskedImage, maxSNR, good): 

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

160 

161 Parameters 

162 ---------- 

163 maskedImage : `afw.image.MaskedImage` 

164 PSF candidate postage stamp 

165 maxSNR : `float` 

166 Maximum SNR applying variance floor. 

167 good : `ndarray` 

168 Index array indicating good pixels. 

169 

170 Returns 

171 ------- 

172 weightArr : `ndarry` 

173 Array to use for weight. 

174 """ 

175 imArr = maskedImage.image.array 

176 varArr = maskedImage.variance.array 

177 

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

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

180 # signal-free variance. 

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

182 # fit is [1/gain, sky_var] 

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

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

185 

186 applyMaxSNR(imArr, weightArr, good, maxSNR) 

187 return weightArr 

188 

189 

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

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

192 

193 Parameters 

194 ---------- 

195 imArr : `ndarray` 

196 Signal (image) array of stamp. 

197 weightArr : `ndarray` 

198 Weight map array. May be rescaled in place. 

199 good : `ndarray` 

200 Index array of pixels to use when computing SNR. 

201 maxSNR : `float` 

202 Threshold for adjusting variance plane implementing maximum SNR. 

203 """ 

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

205 # code base explaining the calculation. 

206 # 

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

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

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

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

211 # 

212 # F = Sum_i w_i I_i^2 

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

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

215 # 

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

217 # 

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

219 # 

220 # F_noise = Sum_i w_i 1/w_i = Npix 

221 # 

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

223 # should subtract off Npix from the measured F. 

224 # 

225 # The final formula then is: 

226 # 

227 # F = Sum_i w_i I_i^2 

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

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

230 Npix = np.sum(good) 

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

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

233 if SNR > maxSNR: 

234 factor = (maxSNR / SNR)**2 

235 weightArr[good] *= factor 

236 

237 

238def _computeWeightAlternative(maskedImage, maxSNR): 

239 """Alternative algorithm for creating weight map. 

240 

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

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

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

244 that it be used (or be maintained). 

245 """ 

246 imArr = maskedImage.image.array 

247 varArr = maskedImage.variance.array 

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

249 

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

251 # fit is [1/gain, sky_var] 

252 gain = 1./fit[0] 

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

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

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

256 

257 applyMaxSNR(imArr, weightArr, good, maxSNR) 

258 return weightArr 

259 

260 

261class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

263 """ 

264 ConfigClass = PiffPsfDeterminerConfig 

265 _DefaultName = "psfDeterminer.Piff" 

266 

267 def determinePsf( 

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

269 ): 

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

271 candidates. 

272 

273 Parameters 

274 ---------- 

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

276 Exposure containing the PSF candidates. 

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

278 A sequence of PSF candidates typically obtained by detecting sources 

279 and then running them through a star selector. 

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

281 A home for interesting tidbits of information. 

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

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

284 

285 Returns 

286 ------- 

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

288 The measured PSF model. 

289 psfCellSet : `None` 

290 Unused by this PsfDeterminer. 

291 """ 

292 kernelSize = int(np.clip( 

293 self.config.kernelSize, 

294 self.config.kernelSizeMin, 

295 self.config.kernelSizeMax 

296 )) 

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

298 

299 stars = [] 

300 for candidate in psfCandidateList: 

301 cmi = candidate.getMaskedImage() 

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

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

304 if fracGood < self.config.minimumUnmaskedFraction: 

305 continue 

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

307 

308 bbox = cmi.getBBox() 

309 bds = galsim.BoundsI( 

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

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

312 ) 

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

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

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

316 gsWeight.array[:] = weight 

317 

318 source = candidate.getSource() 

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

320 

321 data = piff.StarData( 

322 gsImage, 

323 image_pos, 

324 weight=gsWeight 

325 ) 

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

327 

328 piffConfig = { 

329 'type': "Simple", 

330 'model': { 

331 'type': 'PixelGrid', 

332 'scale': self.config.samplingSize, 

333 'size': kernelSize, 

334 'interp': self.config.interpolant 

335 }, 

336 'interp': { 

337 'type': 'BasisPolynomial', 

338 'order': self.config.spatialOrder 

339 }, 

340 'outliers': { 

341 'type': 'Chisq', 

342 'nsigma': self.config.outlierNSigma, 

343 'max_remove': self.config.outlierMaxRemove 

344 } 

345 } 

346 

347 piffResult = piff.PSF.process(piffConfig) 

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

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

350 pointing = None 

351 

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

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

354 

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

356 if flagKey: 

357 for candidate in psfCandidateList: 

358 source = candidate.getSource() 

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

360 if posd in used_image_pos: 

361 source.set(flagKey, True) 

362 

363 if metadata is not None: 

364 metadata["spatialFitChi2"] = piffResult.chisq 

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

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

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

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

369 

370 if not self.config.debugStarData: 

371 for star in piffResult.stars: 

372 # Remove large data objects from the stars 

373 del star.fit.params 

374 del star.fit.params_var 

375 del star.fit.A 

376 del star.fit.b 

377 del star.data.image 

378 del star.data.weight 

379 del star.data.orig_weight 

380 

381 return PiffPsf(drawSize, drawSize, piffResult), None 

382 

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

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

385 

386 Parameters 

387 ---------- 

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

389 Sequence of psf candidates to check. 

390 kernelSize : `int` 

391 Size of image model to use in PIFF. 

392 samplingSize : `float` 

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

394 

395 Raises 

396 ------ 

397 RuntimeError 

398 Raised if any psfCandidate has width or height smaller than 

399 config.kernelSize. 

400 """ 

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

402 candidate = psfCandidateList[0] 

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

404 if (candidate.getHeight() < drawSize 

405 or candidate.getWidth() < drawSize): 

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

407 f"{drawSize} pixels per side; " 

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

409 

410 

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