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