LSST Applications g0b6bd0c080+a72a5dd7e6,g1182afd7b4+2a019aa3bb,g17e5ecfddb+2b8207f7de,g1d67935e3f+06cf436103,g38293774b4+ac198e9f13,g396055baef+6a2097e274,g3b44f30a73+6611e0205b,g480783c3b1+98f8679e14,g48ccf36440+89c08d0516,g4b93dc025c+98f8679e14,g5c4744a4d9+a302e8c7f0,g613e996a0d+e1c447f2e0,g6c8d09e9e7+25247a063c,g7271f0639c+98f8679e14,g7a9cd813b8+124095ede6,g9d27549199+a302e8c7f0,ga1cf026fa3+ac198e9f13,ga32aa97882+7403ac30ac,ga786bb30fb+7a139211af,gaa63f70f4e+9994eb9896,gabf319e997+ade567573c,gba47b54d5d+94dc90c3ea,gbec6a3398f+06cf436103,gc6308e37c7+07dd123edb,gc655b1545f+ade567573c,gcc9029db3c+ab229f5caf,gd01420fc67+06cf436103,gd877ba84e5+06cf436103,gdb4cecd868+6f279b5b48,ge2d134c3d5+cc4dbb2e3f,ge448b5faa6+86d1ceac1d,gecc7e12556+98f8679e14,gf3ee170dca+25247a063c,gf4ac96e456+ade567573c,gf9f5ea5b4d+ac198e9f13,gff490e6085+8c2580be5c,w.2022.27
LSST Data Management Base Package
piffPsfDeterminer.py
Go to the documentation of this file.
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/>.
21
22__all__ = ["PiffPsfDeterminerConfig", "PiffPsfDeterminerTask"]
23
24import numpy as np
25import piff
26import galsim
27import re
28
29import lsst.pex.config as pexConfig
30import lsst.meas.algorithms as measAlg
31from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask
32from .piffPsf import PiffPsf
33
34
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 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
50
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)
62
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
68
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 )
115
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.kernelSizekernelSize = 25
122 self.kernelSizeMinkernelSizeMin = 11
123 self.kernelSizeMaxkernelSizeMax = 35
124
125
126def getGoodPixels(maskedImage, zeroWeightMaskBits):
127 """Compute an index array indicating good pixels to use.
128
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.
135
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
151
152
153def computeWeight(maskedImage, maxSNR, good):
154 """Derive a weight map without Poisson variance component due to signal.
155
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.
164
165 Returns
166 -------
167 weightArr : `ndarry`
168 Array to use for weight.
169 """
170 imArr = maskedImage.image.array
171 varArr = maskedImage.variance.array
172
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]
180
181 applyMaxSNR(imArr, weightArr, good, maxSNR)
182 return weightArr
183
184
185def applyMaxSNR(imArr, weightArr, good, maxSNR):
186 """Rescale weight of bright stars to cap the computed SNR.
187
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
231
232
233def _computeWeightAlternative(maskedImage, maxSNR):
234 """Alternative algorithm for creating weight map.
235
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)
244
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]
251
252 applyMaxSNR(imArr, weightArr, good, maxSNR)
253 return weightArr
254
255
257 """A measurePsfTask PSF estimator using Piff as the implementation.
258 """
259 ConfigClass = PiffPsfDeterminerConfig
260 _DefaultName = "psfDeterminer.Piff"
261
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.
267
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.
279
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_validatePsfCandidates(psfCandidateList, kernelSize, self.config.samplingSize)
293
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)
302
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
312
313 source = candidate.getSource()
314 image_pos = galsim.PositionD(source.getX(), source.getY())
315
316 data = piff.StarData(
317 gsImage,
318 image_pos,
319 weight=gsWeight
320 )
321 stars.append(piff.Star(data, None))
322
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 }
341
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
346
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)
350
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)
358
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])
365
366 return psf, None
367
368 def _validatePsfCandidates(self, psfCandidateList, kernelSize, samplingSize):
369 """Raise if psfCandidates are smaller than the configured kernelSize.
370
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.
379
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()}.")
394
395
396measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)
A class to contain the data, WCS, and other information needed to describe an image of the sky.
Definition: Exposure.h:72
A class to manipulate images, masks, and variance as a single object.
Definition: MaskedImage.h:73
Class stored in SpatialCells for spatial Psf fitting.
Definition: PsfCandidate.h:55
def _validatePsfCandidates(self, psfCandidateList, kernelSize, samplingSize)
def determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None)
Fit spatial kernel using approximate fluxes for candidates, and solving a linear system of equations.
def getGoodPixels(maskedImage, zeroWeightMaskBits)
def applyMaxSNR(imArr, weightArr, good, maxSNR)
def computeWeight(maskedImage, maxSNR, good)