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

147 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-09 03:43 -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 

28import logging 

29 

30import lsst.utils.logging 

31from lsst.afw.cameraGeom import PIXELS, FIELD_ANGLE 

32import lsst.pex.config as pexConfig 

33import lsst.meas.algorithms as measAlg 

34from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask 

35from .piffPsf import PiffPsf 

36from .wcs_wrapper import CelestialWcsWrapper, UVWcsWrapper 

37 

38 

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

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

41 

42 Parameters 

43 ---------- 

44 name : str 

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

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

47 galsim.Linear 

48 galsim.Cubic 

49 galsim.Quintic 

50 galsim.Delta 

51 galsim.Nearest 

52 galsim.SincInterpolant 

53 

54 Returns 

55 ------- 

56 is_valid : bool 

57 Whether the provided interpolant name is valid. 

58 """ 

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

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

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

62 if match is not None: 

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

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

65 

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

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

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

69 } 

70 return name in names 

71 

72 

73class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass): 

74 spatialOrder = pexConfig.Field[int]( 

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

76 default=2, 

77 ) 

78 samplingSize = pexConfig.Field[float]( 

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

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

81 default=1, 

82 ) 

83 outlierNSigma = pexConfig.Field[float]( 

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

85 default=4.0 

86 ) 

87 outlierMaxRemove = pexConfig.Field[float]( 

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

89 default=0.05 

90 ) 

91 maxSNR = pexConfig.Field[float]( 

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

93 "than this value.", 

94 default=200.0 

95 ) 

96 zeroWeightMaskBits = pexConfig.ListField[str]( 

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

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

99 ) 

100 minimumUnmaskedFraction = pexConfig.Field[float]( 

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

102 default=0.5 

103 ) 

104 interpolant = pexConfig.Field[str]( 

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

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

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

108 "galsim.Quintic, and galsim.SincInterpolant.", 

109 check=_validateGalsimInterpolant, 

110 default="Lanczos(11)", 

111 ) 

112 debugStarData = pexConfig.Field[bool]( 

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

114 default=False 

115 ) 

116 useCoordinates = pexConfig.ChoiceField[str]( 

117 doc="Which spatial coordinates to regress against in PSF modeling.", 

118 allowed=dict( 

119 pixel='Regress against pixel coordinates', 

120 field='Regress against field angles', 

121 sky='Regress against RA/Dec' 

122 ), 

123 default='pixel' 

124 ) 

125 piffLoggingLevel = pexConfig.RangeField[int]( 

126 doc="PIFF-specific logging level, from 0 (least chatty) to 3 (very verbose); " 

127 "note that logs will come out with unrelated notations (e.g. WARNING does " 

128 "not imply a warning.)", 

129 default=0, 

130 min=0, 

131 max=3, 

132 ) 

133 

134 def setDefaults(self): 

135 super().setDefaults() 

136 # stampSize should be at least 25 so that 

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

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

139 # measured correctly 

140 self.stampSize = 25 

141 

142 

143def getGoodPixels(maskedImage, zeroWeightMaskBits): 

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

145 

146 Parameters 

147 ---------- 

148 maskedImage : `afw.image.MaskedImage` 

149 PSF candidate postage stamp 

150 zeroWeightMaskBits : `List[str]` 

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

152 

153 Returns 

154 ------- 

155 good : `ndarray` 

156 Index array indicating good pixels. 

157 """ 

158 imArr = maskedImage.image.array 

159 varArr = maskedImage.variance.array 

160 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits) 

161 good = ( 

162 (varArr != 0) 

163 & (np.isfinite(varArr)) 

164 & (np.isfinite(imArr)) 

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

166 ) 

167 return good 

168 

169 

170def computeWeight(maskedImage, maxSNR, good): 

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

172 

173 Parameters 

174 ---------- 

175 maskedImage : `afw.image.MaskedImage` 

176 PSF candidate postage stamp 

177 maxSNR : `float` 

178 Maximum SNR applying variance floor. 

179 good : `ndarray` 

180 Index array indicating good pixels. 

181 

182 Returns 

183 ------- 

184 weightArr : `ndarry` 

185 Array to use for weight. 

186 

187 See Also 

188 -------- 

189 `lsst.meas.algorithms.variance_plance.remove_signal_from_variance` : 

190 Remove the Poisson contribution from sources in the variance plane of 

191 an Exposure. 

192 """ 

193 imArr = maskedImage.image.array 

194 varArr = maskedImage.variance.array 

195 

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

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

198 # signal-free variance. 

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

200 # fit is [1/gain, sky_var] 

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

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

203 

204 applyMaxSNR(imArr, weightArr, good, maxSNR) 

205 return weightArr 

206 

207 

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

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

210 

211 Parameters 

212 ---------- 

213 imArr : `ndarray` 

214 Signal (image) array of stamp. 

215 weightArr : `ndarray` 

216 Weight map array. May be rescaled in place. 

217 good : `ndarray` 

218 Index array of pixels to use when computing SNR. 

219 maxSNR : `float` 

220 Threshold for adjusting variance plane implementing maximum SNR. 

221 """ 

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

223 # code base explaining the calculation. 

224 # 

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

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

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

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

229 # 

230 # F = Sum_i w_i I_i^2 

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

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

233 # 

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

235 # 

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

237 # 

238 # F_noise = Sum_i w_i 1/w_i = Npix 

239 # 

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

241 # should subtract off Npix from the measured F. 

242 # 

243 # The final formula then is: 

244 # 

245 # F = Sum_i w_i I_i^2 

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

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

248 Npix = np.sum(good) 

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

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

251 if SNR > maxSNR: 

252 factor = (maxSNR / SNR)**2 

253 weightArr[good] *= factor 

254 

255 

256def _computeWeightAlternative(maskedImage, maxSNR): 

257 """Alternative algorithm for creating weight map. 

258 

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

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

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

262 that it be used (or be maintained). 

263 """ 

264 imArr = maskedImage.image.array 

265 varArr = maskedImage.variance.array 

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

267 

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

269 # fit is [1/gain, sky_var] 

270 gain = 1./fit[0] 

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

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

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

274 

275 applyMaxSNR(imArr, weightArr, good, maxSNR) 

276 return weightArr 

277 

278 

279class PiffPsfDeterminerTask(BasePsfDeterminerTask): 

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

281 """ 

282 ConfigClass = PiffPsfDeterminerConfig 

283 _DefaultName = "psfDeterminer.Piff" 

284 

285 def __init__(self, config, schema=None, **kwds): 

286 BasePsfDeterminerTask.__init__(self, config, schema=schema, **kwds) 

287 

288 piffLoggingLevels = { 

289 0: logging.CRITICAL, 

290 1: logging.WARNING, 

291 2: logging.INFO, 

292 3: logging.DEBUG, 

293 } 

294 self.piffLogger = lsst.utils.logging.getLogger(f"{self.log.name}.piff") 

295 self.piffLogger.setLevel(piffLoggingLevels[self.config.piffLoggingLevel]) 

296 

297 def determinePsf( 

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

299 ): 

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

301 candidates. 

302 

303 Parameters 

304 ---------- 

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

306 Exposure containing the PSF candidates. 

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

308 A sequence of PSF candidates typically obtained by detecting sources 

309 and then running them through a star selector. 

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

311 A home for interesting tidbits of information. 

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

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

314 

315 Returns 

316 ------- 

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

318 The measured PSF model. 

319 psfCellSet : `None` 

320 Unused by this PsfDeterminer. 

321 """ 

322 psfCandidateList = self.downsampleCandidates(psfCandidateList) 

323 

324 if self.config.stampSize: 

325 stampSize = self.config.stampSize 

326 if stampSize > psfCandidateList[0].getWidth(): 

327 self.log.warning("stampSize is larger than the PSF candidate size. Using candidate size.") 

328 stampSize = psfCandidateList[0].getWidth() 

329 else: # TODO: Only the if block should stay after DM-36311 

330 self.log.debug("stampSize not set. Using candidate size.") 

331 stampSize = psfCandidateList[0].getWidth() 

332 

333 scale = exposure.getWcs().getPixelScale().asArcseconds() 

334 match self.config.useCoordinates: 

335 case 'field': 

336 detector = exposure.getDetector() 

337 pix_to_field = detector.getTransform(PIXELS, FIELD_ANGLE) 

338 gswcs = UVWcsWrapper(pix_to_field) 

339 pointing = None 

340 case 'sky': 

341 gswcs = CelestialWcsWrapper(exposure.getWcs()) 

342 skyOrigin = exposure.getWcs().getSkyOrigin() 

343 ra = skyOrigin.getLongitude().asDegrees() 

344 dec = skyOrigin.getLatitude().asDegrees() 

345 pointing = galsim.CelestialCoord( 

346 ra*galsim.degrees, 

347 dec*galsim.degrees 

348 ) 

349 case 'pixel': 

350 gswcs = galsim.PixelScale(scale) 

351 pointing = None 

352 

353 stars = [] 

354 for candidate in psfCandidateList: 

355 cmi = candidate.getMaskedImage(stampSize, stampSize) 

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

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

358 if fracGood < self.config.minimumUnmaskedFraction: 

359 continue 

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

361 

362 bbox = cmi.getBBox() 

363 bds = galsim.BoundsI( 

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

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

366 ) 

367 gsImage = galsim.Image(bds, wcs=gswcs, dtype=float) 

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

369 gsWeight = galsim.Image(bds, wcs=gswcs, dtype=float) 

370 gsWeight.array[:] = weight 

371 

372 source = candidate.getSource() 

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

374 

375 data = piff.StarData( 

376 gsImage, 

377 image_pos, 

378 weight=gsWeight, 

379 pointing=pointing 

380 ) 

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

382 

383 piffConfig = { 

384 'type': "Simple", 

385 'model': { 

386 'type': 'PixelGrid', 

387 'scale': scale * self.config.samplingSize, 

388 'size': stampSize, 

389 'interp': self.config.interpolant 

390 }, 

391 'interp': { 

392 'type': 'BasisPolynomial', 

393 'order': self.config.spatialOrder 

394 }, 

395 'outliers': { 

396 'type': 'Chisq', 

397 'nsigma': self.config.outlierNSigma, 

398 'max_remove': self.config.outlierMaxRemove 

399 } 

400 } 

401 

402 piffResult = piff.PSF.process(piffConfig) 

403 wcs = {0: gswcs} 

404 

405 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger) 

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

407 

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

409 if flagKey: 

410 for candidate in psfCandidateList: 

411 source = candidate.getSource() 

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

413 if posd in used_image_pos: 

414 source.set(flagKey, True) 

415 

416 if metadata is not None: 

417 metadata["spatialFitChi2"] = piffResult.chisq 

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

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

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

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

422 

423 if not self.config.debugStarData: 

424 for star in piffResult.stars: 

425 # Remove large data objects from the stars 

426 del star.fit.params 

427 del star.fit.params_var 

428 del star.fit.A 

429 del star.fit.b 

430 del star.data.image 

431 del star.data.weight 

432 del star.data.orig_weight 

433 

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

435 

436 

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