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