Coverage for python/lsst/meas/extensions/piff/piffPsfDeterminer.py: 24%
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 )
62 zeroWeightMaskBits = pexConfig.ListField(
63 doc="List of mask bits for which to set pixel weights to zero.",
64 dtype=str,
65 default=['BAD', 'CR', 'INTRP', 'SAT', 'SUSPECT', 'NO_DATA']
66 )
67 minimumUnmaskedFraction = pexConfig.Field(
68 doc="Minimum fraction of unmasked pixels required to use star.",
69 dtype=float,
70 default=0.5
71 )
73 def setDefaults(self):
74 self.kernelSize = 21
75 self.kernelSizeMin = 11
76 self.kernelSizeMax = 35
79def getGoodPixels(maskedImage, zeroWeightMaskBits):
80 """Compute an index array indicating good pixels to use.
82 Parameters
83 ----------
84 maskedImage : `afw.image.MaskedImage`
85 PSF candidate postage stamp
86 zeroWeightMaskBits : `List[str]`
87 List of mask bits for which to set pixel weights to zero.
89 Returns
90 -------
91 good : `ndarray`
92 Index array indicating good pixels.
93 """
94 imArr = maskedImage.image.array
95 varArr = maskedImage.variance.array
96 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits)
97 good = (
98 (varArr != 0)
99 & (np.isfinite(varArr))
100 & (np.isfinite(imArr))
101 & ((maskedImage.mask.array & bitmask) == 0)
102 )
103 return good
106def computeWeight(maskedImage, maxSNR, good):
107 """Derive a weight map without Poisson variance component due to signal.
109 Parameters
110 ----------
111 maskedImage : `afw.image.MaskedImage`
112 PSF candidate postage stamp
113 maxSNR : `float`
114 Maximum SNR applying variance floor.
115 good : `ndarray`
116 Index array indicating good pixels.
118 Returns
119 -------
120 weightArr : `ndarry`
121 Array to use for weight.
122 """
123 imArr = maskedImage.image.array
124 varArr = maskedImage.variance.array
126 # Fit a straight line to variance vs (sky-subtracted) signal.
127 # The evaluate that line at zero signal to get an estimate of the
128 # signal-free variance.
129 fit = np.polyfit(imArr[good], varArr[good], deg=1)
130 # fit is [1/gain, sky_var]
131 weightArr = np.zeros_like(imArr, dtype=float)
132 weightArr[good] = 1./fit[1]
134 applyMaxSNR(imArr, weightArr, good, maxSNR)
135 return weightArr
138def applyMaxSNR(imArr, weightArr, good, maxSNR):
139 """Rescale weight of bright stars to cap the computed SNR.
141 Parameters
142 ----------
143 imArr : `ndarray`
144 Signal (image) array of stamp.
145 weightArr : `ndarray`
146 Weight map array. May be rescaled in place.
147 good : `ndarray`
148 Index array of pixels to use when computing SNR.
149 maxSNR : `float`
150 Threshold for adjusting variance plane implementing maximum SNR.
151 """
152 # We define the SNR value following Piff. Here's the comment from that
153 # code base explaining the calculation.
154 #
155 # The S/N value that we use will be the weighted total flux where the
156 # weight function is the star's profile itself. This is the maximum S/N
157 # value that any flux measurement can possibly produce, which will be
158 # closer to an in-practice S/N than using all the pixels equally.
159 #
160 # F = Sum_i w_i I_i^2
161 # var(F) = Sum_i w_i^2 I_i^2 var(I_i)
162 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i
163 #
164 # S/N = F / sqrt(var(F))
165 #
166 # Note that if the image is pure noise, this will produce a "signal" of
167 #
168 # F_noise = Sum_i w_i 1/w_i = Npix
169 #
170 # So for a more accurate estimate of the S/N of the actual star itself, one
171 # should subtract off Npix from the measured F.
172 #
173 # The final formula then is:
174 #
175 # F = Sum_i w_i I_i^2
176 # S/N = (F-Npix) / sqrt(F)
177 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float)
178 Npix = np.sum(good)
179 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F)
180 # rescale weight of bright stars. Essentially makes an error floor.
181 if SNR > maxSNR:
182 factor = (maxSNR / SNR)**2
183 weightArr[good] *= factor
186def _computeWeightAlternative(maskedImage, maxSNR):
187 """Alternative algorithm for creating weight map.
189 This version is equivalent to that used by Piff internally. The weight map
190 it produces tends to leave a residual when removing the Poisson component
191 due to the signal. We leave it here as a reference, but without intending
192 that it be used (or be maintained).
193 """
194 imArr = maskedImage.image.array
195 varArr = maskedImage.variance.array
196 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
198 fit = np.polyfit(imArr[good], varArr[good], deg=1)
199 # fit is [1/gain, sky_var]
200 gain = 1./fit[0]
201 varArr[good] -= imArr[good] / gain
202 weightArr = np.zeros_like(imArr, dtype=float)
203 weightArr[good] = 1./varArr[good]
205 applyMaxSNR(imArr, weightArr, good, maxSNR)
206 return weightArr
209class PiffPsfDeterminerTask(BasePsfDeterminerTask):
210 """A measurePsfTask PSF estimator using Piff as the implementation.
211 """
212 ConfigClass = PiffPsfDeterminerConfig
213 _DefaultName = "psfDeterminer.Piff"
215 def determinePsf(
216 self, exposure, psfCandidateList, metadata=None, flagKey=None
217 ):
218 """Determine a Piff PSF model for an exposure given a list of PSF
219 candidates.
221 Parameters
222 ----------
223 exposure : `lsst.afw.image.Exposure`
224 Exposure containing the PSF candidates.
225 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
226 A sequence of PSF candidates typically obtained by detecting sources
227 and then running them through a star selector.
228 metadata : `lsst.daf.base import PropertyList` or `None`, optional
229 A home for interesting tidbits of information.
230 flagKey : `str` or `None`, optional
231 Schema key used to mark sources actually used in PSF determination.
233 Returns
234 -------
235 psf : `lsst.meas.extensions.piff.PiffPsf`
236 The measured PSF model.
237 psfCellSet : `None`
238 Unused by this PsfDeterminer.
239 """
240 kernelSize = int(np.clip(
241 self.config.kernelSize,
242 self.config.kernelSizeMin,
243 self.config.kernelSizeMax
244 ))
245 self._validatePsfCandidates(psfCandidateList, kernelSize)
247 stars = []
248 for candidate in psfCandidateList:
249 cmi = candidate.getMaskedImage()
250 good = getGoodPixels(cmi, self.config.zeroWeightMaskBits)
251 fracGood = np.sum(good)/good.size
252 if fracGood < self.config.minimumUnmaskedFraction:
253 continue
254 weight = computeWeight(cmi, self.config.maxSNR, good)
256 bbox = cmi.getBBox()
257 bds = galsim.BoundsI(
258 galsim.PositionI(*bbox.getMin()),
259 galsim.PositionI(*bbox.getMax())
260 )
261 gsImage = galsim.Image(bds, scale=1.0, dtype=float)
262 gsImage.array[:] = cmi.image.array
263 gsWeight = galsim.Image(bds, scale=1.0, dtype=float)
264 gsWeight.array[:] = weight
266 source = candidate.getSource()
267 image_pos = galsim.PositionD(source.getX(), source.getY())
269 data = piff.StarData(
270 gsImage,
271 image_pos,
272 weight=gsWeight
273 )
274 stars.append(piff.Star(data, None))
276 piffConfig = {
277 'type': "Simple",
278 'model': {
279 'type': 'PixelGrid',
280 'scale': self.config.samplingSize,
281 'size': kernelSize
282 },
283 'interp': {
284 'type': 'BasisPolynomial',
285 'order': self.config.spatialOrder
286 },
287 'outliers': {
288 'type': 'Chisq',
289 'nsigma': self.config.outlierNSigma,
290 'max_remove': self.config.outlierMaxRemove
291 }
292 }
294 piffResult = piff.PSF.process(piffConfig)
295 # Run on a single CCD, and in image coords rather than sky coords.
296 wcs = {0: galsim.PixelScale(1.0)}
297 pointing = None
299 piffResult.fit(stars, wcs, pointing, logger=self.log)
300 psf = PiffPsf(kernelSize, kernelSize, piffResult)
302 used_image_pos = [s.image_pos for s in piffResult.stars]
303 if flagKey:
304 for candidate in psfCandidateList:
305 source = candidate.getSource()
306 posd = galsim.PositionD(source.getX(), source.getY())
307 if posd in used_image_pos:
308 source.set(flagKey, True)
310 if metadata is not None:
311 metadata["spatialFitChi2"] = piffResult.chisq
312 metadata["numAvailStars"] = len(stars)
313 metadata["numGoodStars"] = len(piffResult.stars)
314 metadata["avgX"] = np.mean([p.x for p in piffResult.stars])
315 metadata["avgY"] = np.mean([p.y for p in piffResult.stars])
317 return psf, None
319 def _validatePsfCandidates(self, psfCandidateList, kernelSize):
320 """Raise if psfCandidates are smaller than the configured kernelSize.
322 Parameters
323 ----------
324 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
325 Sequence of psf candidates to check.
326 kernelSize : `int`
327 Size of image model to use in PIFF.
329 Raises
330 ------
331 RuntimeError
332 Raised if any psfCandidate has width or height smaller than
333 config.kernelSize.
334 """
335 # We can assume all candidates have the same dimensions.
336 candidate = psfCandidateList[0]
337 if (candidate.getHeight() < kernelSize
338 or candidate.getWidth() < kernelSize):
339 raise RuntimeError("PSF candidates must be at least config.kernelSize="
340 f"{kernelSize} pixels per side; "
341 f"found {candidate.getWidth()}x{candidate.getHeight()}.")
344measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)