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