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

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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/>.
22__all__ = ["PiffPsfDeterminerConfig", "PiffPsfDeterminerTask"]
24import logging
26import numpy as np
27import piff
28import galsim
30import lsst.log
31import lsst.pex.config as pexConfig
32import lsst.meas.algorithms as measAlg
33from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask
34from .piffPsf import PiffPsf
37class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass):
38 spatialOrder = pexConfig.Field(
39 doc="specify spatial order for PSF kernel creation",
40 dtype=int,
41 default=2,
42 )
43 samplingSize = pexConfig.Field(
44 doc="Resolution of the internal PSF model relative to the pixel size; "
45 "e.g. 0.5 is equal to 2x oversampling",
46 dtype=float,
47 default=1,
48 )
49 outlierNSigma = pexConfig.Field(
50 doc="n sigma for chisq outlier rejection",
51 dtype=float,
52 default=4.0
53 )
54 outlierMaxRemove = pexConfig.Field(
55 doc="Max fraction of stars to remove as outliers each iteration",
56 dtype=float,
57 default=0.05
58 )
59 maxSNR = pexConfig.Field(
60 doc="Rescale the weight of bright stars such that their SNR is less "
61 "than this value.",
62 dtype=float,
63 default=200.0
64 )
66 def setDefaults(self):
67 self.kernelSize = 21
68 self.kernelSizeMin = 11
69 self.kernelSizeMax = 35
72def computeWeight(maskedImage, maxSNR):
73 """Derive a weight map without Poisson variance component due to signal.
75 Parameters
76 ----------
77 maskedImage : `afw.image.MaskedImage`
78 PSF candidate postage stamp
79 maxSNR : `float`
80 Maximum SNR applying variance floor.
82 Returns
83 -------
84 weightArr : `ndarry`
85 Array to use for weight.
86 """
87 imArr = maskedImage.image.array
88 varArr = maskedImage.variance.array
89 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
91 # Fit a straight line to variance vs (sky-subtracted) signal.
92 # The evaluate that line at zero signal to get an estimate of the
93 # signal-free variance.
94 fit = np.polyfit(imArr[good], varArr[good], deg=1)
95 # fit is [1/gain, sky_var]
96 weightArr = np.zeros_like(imArr, dtype=float)
97 weightArr[good] = 1./fit[1]
99 applyMaxSNR(imArr, weightArr, good, maxSNR)
100 return weightArr
103def applyMaxSNR(imArr, weightArr, good, maxSNR):
104 """Rescale weight of bright stars to cap the computed SNR.
106 Parameters
107 ----------
108 imArr : `ndarray`
109 Signal (image) array of stamp.
110 weightArr : `ndarray`
111 Weight map array. May be rescaled in place.
112 good : `ndarray`
113 Index array of pixels to use when computing SNR.
114 maxSNR : `float`
115 Threshold for adjusting variance plane implementing maximum SNR.
116 """
117 # We define the SNR value following Piff. Here's the comment from that
118 # code base explaining the calculation.
119 #
120 # The S/N value that we use will be the weighted total flux where the
121 # weight function is the star's profile itself. This is the maximum S/N
122 # value that any flux measurement can possibly produce, which will be
123 # closer to an in-practice S/N than using all the pixels equally.
124 #
125 # F = Sum_i w_i I_i^2
126 # var(F) = Sum_i w_i^2 I_i^2 var(I_i)
127 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i
128 #
129 # S/N = F / sqrt(var(F))
130 #
131 # Note that if the image is pure noise, this will produce a "signal" of
132 #
133 # F_noise = Sum_i w_i 1/w_i = Npix
134 #
135 # So for a more accurate estimate of the S/N of the actual star itself, one
136 # should subtract off Npix from the measured F.
137 #
138 # The final formula then is:
139 #
140 # F = Sum_i w_i I_i^2
141 # S/N = (F-Npix) / sqrt(F)
142 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float)
143 Npix = np.sum(good)
144 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F)
145 # rescale weight of bright stars. Essentially makes an error floor.
146 if SNR > maxSNR:
147 factor = (maxSNR / SNR)**2
148 weightArr[good] *= factor
151def _computeWeightAlternative(maskedImage, maxSNR):
152 """Alternative algorithm for creating weight map.
154 This version is equivalent to that used by Piff internally. The weight map
155 it produces tends to leave a residual when removing the Poisson component
156 due to the signal. We leave it here as a reference, but without intending
157 that it be used.
158 """
159 imArr = maskedImage.image.array
160 varArr = maskedImage.variance.array
161 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
163 fit = np.polyfit(imArr[good], varArr[good], deg=1)
164 # fit is [1/gain, sky_var]
165 gain = 1./fit[0]
166 varArr[good] -= imArr[good] / gain
167 weightArr = np.zeros_like(imArr, dtype=float)
168 weightArr[good] = 1./varArr[good]
170 applyMaxSNR(imArr, weightArr, good, maxSNR)
171 return weightArr
174class PiffPsfDeterminerTask(BasePsfDeterminerTask):
175 """A measurePsfTask PSF estimator using Piff as the implementation.
176 """
177 ConfigClass = PiffPsfDeterminerConfig
179 def determinePsf(
180 self, exposure, psfCandidateList, metadata=None, flagKey=None
181 ):
182 """Determine a Piff PSF model for an exposure given a list of PSF
183 candidates.
185 Parameters
186 ----------
187 exposure : `lsst.afw.image.Exposure`
188 Exposure containing the PSF candidates.
189 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
190 A sequence of PSF candidates typically obtained by detecting sources
191 and then running them through a star selector.
192 metadata : `lsst.daf.base import PropertyList` or `None`, optional
193 A home for interesting tidbits of information.
194 flagKey : `str` or `None`, optional
195 Schema key used to mark sources actually used in PSF determination.
197 Returns
198 -------
199 psf : `lsst.meas.extensions.piff.PiffPsf`
200 The measured PSF model.
201 psfCellSet : `None`
202 Unused by this PsfDeterminer.
203 """
204 stars = []
205 for candidate in psfCandidateList:
206 cmi = candidate.getMaskedImage()
207 weight = computeWeight(cmi, self.config.maxSNR)
209 bbox = cmi.getBBox()
210 bds = galsim.BoundsI(
211 galsim.PositionI(*bbox.getMin()),
212 galsim.PositionI(*bbox.getMax())
213 )
214 gsImage = galsim.Image(bds, scale=1.0, dtype=float)
215 gsImage.array[:] = cmi.image.array
216 gsWeight = galsim.Image(bds, scale=1.0, dtype=float)
217 gsWeight.array[:] = weight
219 source = candidate.getSource()
220 image_pos = galsim.PositionD(source.getX(), source.getY())
222 data = piff.StarData(
223 gsImage,
224 image_pos,
225 weight=gsWeight
226 )
227 stars.append(piff.Star(data, None))
229 kernelSize = int(np.clip(
230 self.config.kernelSize,
231 self.config.kernelSizeMin,
232 self.config.kernelSizeMax
233 ))
235 piffConfig = {
236 'type': "Simple",
237 'model': {
238 'type': 'PixelGrid',
239 'scale': self.config.samplingSize,
240 'size': kernelSize
241 },
242 'interp': {
243 'type': 'BasisPolynomial',
244 'order': self.config.spatialOrder
245 },
246 'outliers': {
247 'type': 'Chisq',
248 'nsigma': self.config.outlierNSigma,
249 'max_remove': self.config.outlierMaxRemove
250 }
251 }
253 piffResult = piff.PSF.process(piffConfig)
254 # Run on a single CCD, and in image coords rather than sky coords.
255 wcs = {0: galsim.PixelScale(1.0)}
256 pointing = None
258 logger = logging.getLogger(self.log.getName()+".Piff")
259 logger.addHandler(lsst.log.LogHandler())
261 piffResult.fit(stars, wcs, pointing, logger=logger)
262 psf = PiffPsf(kernelSize, kernelSize, piffResult)
264 used_image_pos = [s.image_pos for s in piffResult.stars]
265 if flagKey:
266 for candidate in psfCandidateList:
267 source = candidate.getSource()
268 posd = galsim.PositionD(source.getX(), source.getY())
269 if posd in used_image_pos:
270 source.set(flagKey, True)
272 if metadata is not None:
273 metadata.set("spatialFitChi2", piffResult.chisq)
274 metadata.set("numAvailStars", len(stars))
275 metadata.set("numGoodStars", len(piffResult.stars))
276 metadata.set("avgX", np.mean([p.x for p in piffResult.stars]))
277 metadata.set("avgY", np.mean([p.y for p in piffResult.stars]))
279 return psf, None
282measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)