Coverage for python/lsst/meas/extensions/piff/piffPsfDeterminer.py: 24%
112 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-18 11:35 +0000
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-18 11:35 +0000
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
27import re
29import lsst.pex.config as pexConfig
30import lsst.meas.algorithms as measAlg
31from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask
32from .piffPsf import PiffPsf
35class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass):
36 def _validateGalsimInterpolant(name: str) -> bool: # noqa: N805
37 """A helper function to validate the GalSim interpolant at config time.
38 """
39 # First, check if ``name`` is a valid Lanczos interpolant.
40 for pattern in (re.compile(r"Lanczos\(\d+\)"), re.compile(r"galsim.Lanczos\(\d+\)"),):
41 match = re.match(pattern, name) # Search from the start of the string.
42 if match is not None:
43 # Check that the pattern is also the end of the string.
44 return match.end() == len(name)
46 # If not, check if ``name`` is any other valid GalSim interpolant.
47 names = {"galsim.{interp}" for interp in
48 ("Cubic", "Delta", "Linear", "Nearest", "Quintic", "SincInterpolant")
49 }
50 return name in names
52 spatialOrder = pexConfig.Field(
53 doc="specify spatial order for PSF kernel creation",
54 dtype=int,
55 default=2,
56 )
57 samplingSize = pexConfig.Field(
58 doc="Resolution of the internal PSF model relative to the pixel size; "
59 "e.g. 0.5 is equal to 2x oversampling",
60 dtype=float,
61 default=1,
62 )
63 outlierNSigma = pexConfig.Field(
64 doc="n sigma for chisq outlier rejection",
65 dtype=float,
66 default=4.0
67 )
68 outlierMaxRemove = pexConfig.Field(
69 doc="Max fraction of stars to remove as outliers each iteration",
70 dtype=float,
71 default=0.05
72 )
73 maxSNR = pexConfig.Field(
74 doc="Rescale the weight of bright stars such that their SNR is less "
75 "than this value.",
76 dtype=float,
77 default=200.0
78 )
79 zeroWeightMaskBits = pexConfig.ListField(
80 doc="List of mask bits for which to set pixel weights to zero.",
81 dtype=str,
82 default=['BAD', 'CR', 'INTRP', 'SAT', 'SUSPECT', 'NO_DATA']
83 )
84 minimumUnmaskedFraction = pexConfig.Field(
85 doc="Minimum fraction of unmasked pixels required to use star.",
86 dtype=float,
87 default=0.5
88 )
89 interpolant = pexConfig.Field(
90 doc="GalSim interpolant name for Piff to use. "
91 "Options include 'Lanczos(N)', where N is an integer, along with "
92 "galsim.Cubic, galsim.Delta, galsim.Linear, galsim.Nearest, "
93 "galsim.Quintic, and galsim.SincInterpolant.",
94 dtype=str,
95 check=_validateGalsimInterpolant,
96 default="Lanczos(11)",
97 )
99 def setDefaults(self):
100 # kernelSize should be at least 25 so that
101 # i) aperture flux with 12 pixel radius can be compared to PSF flux.
102 # ii) fake sources injected to match the 12 pixel aperture flux get
103 # measured correctly
104 self.kernelSize = 25
105 self.kernelSizeMin = 11
106 self.kernelSizeMax = 35
109def getGoodPixels(maskedImage, zeroWeightMaskBits):
110 """Compute an index array indicating good pixels to use.
112 Parameters
113 ----------
114 maskedImage : `afw.image.MaskedImage`
115 PSF candidate postage stamp
116 zeroWeightMaskBits : `List[str]`
117 List of mask bits for which to set pixel weights to zero.
119 Returns
120 -------
121 good : `ndarray`
122 Index array indicating good pixels.
123 """
124 imArr = maskedImage.image.array
125 varArr = maskedImage.variance.array
126 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits)
127 good = (
128 (varArr != 0)
129 & (np.isfinite(varArr))
130 & (np.isfinite(imArr))
131 & ((maskedImage.mask.array & bitmask) == 0)
132 )
133 return good
136def computeWeight(maskedImage, maxSNR, good):
137 """Derive a weight map without Poisson variance component due to signal.
139 Parameters
140 ----------
141 maskedImage : `afw.image.MaskedImage`
142 PSF candidate postage stamp
143 maxSNR : `float`
144 Maximum SNR applying variance floor.
145 good : `ndarray`
146 Index array indicating good pixels.
148 Returns
149 -------
150 weightArr : `ndarry`
151 Array to use for weight.
152 """
153 imArr = maskedImage.image.array
154 varArr = maskedImage.variance.array
156 # Fit a straight line to variance vs (sky-subtracted) signal.
157 # The evaluate that line at zero signal to get an estimate of the
158 # signal-free variance.
159 fit = np.polyfit(imArr[good], varArr[good], deg=1)
160 # fit is [1/gain, sky_var]
161 weightArr = np.zeros_like(imArr, dtype=float)
162 weightArr[good] = 1./fit[1]
164 applyMaxSNR(imArr, weightArr, good, maxSNR)
165 return weightArr
168def applyMaxSNR(imArr, weightArr, good, maxSNR):
169 """Rescale weight of bright stars to cap the computed SNR.
171 Parameters
172 ----------
173 imArr : `ndarray`
174 Signal (image) array of stamp.
175 weightArr : `ndarray`
176 Weight map array. May be rescaled in place.
177 good : `ndarray`
178 Index array of pixels to use when computing SNR.
179 maxSNR : `float`
180 Threshold for adjusting variance plane implementing maximum SNR.
181 """
182 # We define the SNR value following Piff. Here's the comment from that
183 # code base explaining the calculation.
184 #
185 # The S/N value that we use will be the weighted total flux where the
186 # weight function is the star's profile itself. This is the maximum S/N
187 # value that any flux measurement can possibly produce, which will be
188 # closer to an in-practice S/N than using all the pixels equally.
189 #
190 # F = Sum_i w_i I_i^2
191 # var(F) = Sum_i w_i^2 I_i^2 var(I_i)
192 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i
193 #
194 # S/N = F / sqrt(var(F))
195 #
196 # Note that if the image is pure noise, this will produce a "signal" of
197 #
198 # F_noise = Sum_i w_i 1/w_i = Npix
199 #
200 # So for a more accurate estimate of the S/N of the actual star itself, one
201 # should subtract off Npix from the measured F.
202 #
203 # The final formula then is:
204 #
205 # F = Sum_i w_i I_i^2
206 # S/N = (F-Npix) / sqrt(F)
207 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float)
208 Npix = np.sum(good)
209 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F)
210 # rescale weight of bright stars. Essentially makes an error floor.
211 if SNR > maxSNR:
212 factor = (maxSNR / SNR)**2
213 weightArr[good] *= factor
216def _computeWeightAlternative(maskedImage, maxSNR):
217 """Alternative algorithm for creating weight map.
219 This version is equivalent to that used by Piff internally. The weight map
220 it produces tends to leave a residual when removing the Poisson component
221 due to the signal. We leave it here as a reference, but without intending
222 that it be used (or be maintained).
223 """
224 imArr = maskedImage.image.array
225 varArr = maskedImage.variance.array
226 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
228 fit = np.polyfit(imArr[good], varArr[good], deg=1)
229 # fit is [1/gain, sky_var]
230 gain = 1./fit[0]
231 varArr[good] -= imArr[good] / gain
232 weightArr = np.zeros_like(imArr, dtype=float)
233 weightArr[good] = 1./varArr[good]
235 applyMaxSNR(imArr, weightArr, good, maxSNR)
236 return weightArr
239class PiffPsfDeterminerTask(BasePsfDeterminerTask):
240 """A measurePsfTask PSF estimator using Piff as the implementation.
241 """
242 ConfigClass = PiffPsfDeterminerConfig
243 _DefaultName = "psfDeterminer.Piff"
245 def determinePsf(
246 self, exposure, psfCandidateList, metadata=None, flagKey=None
247 ):
248 """Determine a Piff PSF model for an exposure given a list of PSF
249 candidates.
251 Parameters
252 ----------
253 exposure : `lsst.afw.image.Exposure`
254 Exposure containing the PSF candidates.
255 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
256 A sequence of PSF candidates typically obtained by detecting sources
257 and then running them through a star selector.
258 metadata : `lsst.daf.base import PropertyList` or `None`, optional
259 A home for interesting tidbits of information.
260 flagKey : `str` or `None`, optional
261 Schema key used to mark sources actually used in PSF determination.
263 Returns
264 -------
265 psf : `lsst.meas.extensions.piff.PiffPsf`
266 The measured PSF model.
267 psfCellSet : `None`
268 Unused by this PsfDeterminer.
269 """
270 kernelSize = int(np.clip(
271 self.config.kernelSize,
272 self.config.kernelSizeMin,
273 self.config.kernelSizeMax
274 ))
275 self._validatePsfCandidates(psfCandidateList, kernelSize, self.config.samplingSize)
277 stars = []
278 for candidate in psfCandidateList:
279 cmi = candidate.getMaskedImage()
280 good = getGoodPixels(cmi, self.config.zeroWeightMaskBits)
281 fracGood = np.sum(good)/good.size
282 if fracGood < self.config.minimumUnmaskedFraction:
283 continue
284 weight = computeWeight(cmi, self.config.maxSNR, good)
286 bbox = cmi.getBBox()
287 bds = galsim.BoundsI(
288 galsim.PositionI(*bbox.getMin()),
289 galsim.PositionI(*bbox.getMax())
290 )
291 gsImage = galsim.Image(bds, scale=1.0, dtype=float)
292 gsImage.array[:] = cmi.image.array
293 gsWeight = galsim.Image(bds, scale=1.0, dtype=float)
294 gsWeight.array[:] = weight
296 source = candidate.getSource()
297 image_pos = galsim.PositionD(source.getX(), source.getY())
299 data = piff.StarData(
300 gsImage,
301 image_pos,
302 weight=gsWeight
303 )
304 stars.append(piff.Star(data, None))
306 piffConfig = {
307 'type': "Simple",
308 'model': {
309 'type': 'PixelGrid',
310 'scale': self.config.samplingSize,
311 'size': kernelSize,
312 'interp': self.config.interpolant
313 },
314 'interp': {
315 'type': 'BasisPolynomial',
316 'order': self.config.spatialOrder
317 },
318 'outliers': {
319 'type': 'Chisq',
320 'nsigma': self.config.outlierNSigma,
321 'max_remove': self.config.outlierMaxRemove
322 }
323 }
325 piffResult = piff.PSF.process(piffConfig)
326 # Run on a single CCD, and in image coords rather than sky coords.
327 wcs = {0: galsim.PixelScale(1.0)}
328 pointing = None
330 piffResult.fit(stars, wcs, pointing, logger=self.log)
331 drawSize = 2*np.floor(0.5*kernelSize/self.config.samplingSize) + 1
332 psf = PiffPsf(drawSize, drawSize, piffResult)
334 used_image_pos = [s.image_pos for s in piffResult.stars]
335 if flagKey:
336 for candidate in psfCandidateList:
337 source = candidate.getSource()
338 posd = galsim.PositionD(source.getX(), source.getY())
339 if posd in used_image_pos:
340 source.set(flagKey, True)
342 if metadata is not None:
343 metadata["spatialFitChi2"] = piffResult.chisq
344 metadata["numAvailStars"] = len(stars)
345 metadata["numGoodStars"] = len(piffResult.stars)
346 metadata["avgX"] = np.mean([p.x for p in piffResult.stars])
347 metadata["avgY"] = np.mean([p.y for p in piffResult.stars])
349 return psf, None
351 def _validatePsfCandidates(self, psfCandidateList, kernelSize, samplingSize):
352 """Raise if psfCandidates are smaller than the configured kernelSize.
354 Parameters
355 ----------
356 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
357 Sequence of psf candidates to check.
358 kernelSize : `int`
359 Size of image model to use in PIFF.
360 samplingSize : `float`
361 Resolution of the internal PSF model relative to the pixel size.
363 Raises
364 ------
365 RuntimeError
366 Raised if any psfCandidate has width or height smaller than
367 config.kernelSize.
368 """
369 # We can assume all candidates have the same dimensions.
370 candidate = psfCandidateList[0]
371 drawSize = int(2*np.floor(0.5*kernelSize/samplingSize) + 1)
372 if (candidate.getHeight() < drawSize
373 or candidate.getWidth() < drawSize):
374 raise RuntimeError("PSF candidates must be at least config.kernelSize/config.samplingSize="
375 f"{drawSize} pixels per side; "
376 f"found {candidate.getWidth()}x{candidate.getHeight()}.")
379measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)