Coverage for python/lsst/meas/extensions/piff/piffPsfDeterminer.py: 20%
147 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-11 04:13 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-11 04:13 -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
28import logging
30import lsst.utils.logging
31from lsst.afw.cameraGeom import PIXELS, FIELD_ANGLE
32import lsst.pex.config as pexConfig
33import lsst.meas.algorithms as measAlg
34from lsst.meas.algorithms.psfDeterminer import BasePsfDeterminerTask
35from .piffPsf import PiffPsf
36from .wcs_wrapper import CelestialWcsWrapper, UVWcsWrapper
39def _validateGalsimInterpolant(name: str) -> bool:
40 """A helper function to validate the GalSim interpolant at config time.
42 Parameters
43 ----------
44 name : str
45 The name of the interpolant to use from GalSim. Valid options are:
46 galsim.Lanczos(N) or Lancsos(N), where N is a positive integer
47 galsim.Linear
48 galsim.Cubic
49 galsim.Quintic
50 galsim.Delta
51 galsim.Nearest
52 galsim.SincInterpolant
54 Returns
55 -------
56 is_valid : bool
57 Whether the provided interpolant name is valid.
58 """
59 # First, check if ``name`` is a valid Lanczos interpolant.
60 for pattern in (re.compile(r"Lanczos\(\d+\)"), re.compile(r"galsim.Lanczos\(\d+\)"),):
61 match = re.match(pattern, name) # Search from the start of the string.
62 if match is not None:
63 # Check that the pattern is also the end of the string.
64 return match.end() == len(name)
66 # If not, check if ``name`` is any other valid GalSim interpolant.
67 names = {f"galsim.{interp}" for interp in
68 ("Cubic", "Delta", "Linear", "Nearest", "Quintic", "SincInterpolant")
69 }
70 return name in names
73class PiffPsfDeterminerConfig(BasePsfDeterminerTask.ConfigClass):
74 spatialOrder = pexConfig.Field[int](
75 doc="specify spatial order for PSF kernel creation",
76 default=2,
77 )
78 samplingSize = pexConfig.Field[float](
79 doc="Resolution of the internal PSF model relative to the pixel size; "
80 "e.g. 0.5 is equal to 2x oversampling",
81 default=1,
82 )
83 outlierNSigma = pexConfig.Field[float](
84 doc="n sigma for chisq outlier rejection",
85 default=4.0
86 )
87 outlierMaxRemove = pexConfig.Field[float](
88 doc="Max fraction of stars to remove as outliers each iteration",
89 default=0.05
90 )
91 maxSNR = pexConfig.Field[float](
92 doc="Rescale the weight of bright stars such that their SNR is less "
93 "than this value.",
94 default=200.0
95 )
96 zeroWeightMaskBits = pexConfig.ListField[str](
97 doc="List of mask bits for which to set pixel weights to zero.",
98 default=['BAD', 'CR', 'INTRP', 'SAT', 'SUSPECT', 'NO_DATA']
99 )
100 minimumUnmaskedFraction = pexConfig.Field[float](
101 doc="Minimum fraction of unmasked pixels required to use star.",
102 default=0.5
103 )
104 interpolant = pexConfig.Field[str](
105 doc="GalSim interpolant name for Piff to use. "
106 "Options include 'Lanczos(N)', where N is an integer, along with "
107 "galsim.Cubic, galsim.Delta, galsim.Linear, galsim.Nearest, "
108 "galsim.Quintic, and galsim.SincInterpolant.",
109 check=_validateGalsimInterpolant,
110 default="Lanczos(11)",
111 )
112 debugStarData = pexConfig.Field[bool](
113 doc="Include star images used for fitting in PSF model object.",
114 default=False
115 )
116 useCoordinates = pexConfig.ChoiceField[str](
117 doc="Which spatial coordinates to regress against in PSF modeling.",
118 allowed=dict(
119 pixel='Regress against pixel coordinates',
120 field='Regress against field angles',
121 sky='Regress against RA/Dec'
122 ),
123 default='pixel'
124 )
125 piffLoggingLevel = pexConfig.RangeField[int](
126 doc="PIFF-specific logging level, from 0 (least chatty) to 3 (very verbose); "
127 "note that logs will come out with unrelated notations (e.g. WARNING does "
128 "not imply a warning.)",
129 default=0,
130 min=0,
131 max=3,
132 )
134 def setDefaults(self):
135 super().setDefaults()
136 # stampSize should be at least 25 so that
137 # i) aperture flux with 12 pixel radius can be compared to PSF flux.
138 # ii) fake sources injected to match the 12 pixel aperture flux get
139 # measured correctly
140 self.stampSize = 25
143def getGoodPixels(maskedImage, zeroWeightMaskBits):
144 """Compute an index array indicating good pixels to use.
146 Parameters
147 ----------
148 maskedImage : `afw.image.MaskedImage`
149 PSF candidate postage stamp
150 zeroWeightMaskBits : `List[str]`
151 List of mask bits for which to set pixel weights to zero.
153 Returns
154 -------
155 good : `ndarray`
156 Index array indicating good pixels.
157 """
158 imArr = maskedImage.image.array
159 varArr = maskedImage.variance.array
160 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits)
161 good = (
162 (varArr != 0)
163 & (np.isfinite(varArr))
164 & (np.isfinite(imArr))
165 & ((maskedImage.mask.array & bitmask) == 0)
166 )
167 return good
170def computeWeight(maskedImage, maxSNR, good):
171 """Derive a weight map without Poisson variance component due to signal.
173 Parameters
174 ----------
175 maskedImage : `afw.image.MaskedImage`
176 PSF candidate postage stamp
177 maxSNR : `float`
178 Maximum SNR applying variance floor.
179 good : `ndarray`
180 Index array indicating good pixels.
182 Returns
183 -------
184 weightArr : `ndarry`
185 Array to use for weight.
187 See Also
188 --------
189 `lsst.meas.algorithms.variance_plance.remove_signal_from_variance` :
190 Remove the Poisson contribution from sources in the variance plane of
191 an Exposure.
192 """
193 imArr = maskedImage.image.array
194 varArr = maskedImage.variance.array
196 # Fit a straight line to variance vs (sky-subtracted) signal.
197 # The evaluate that line at zero signal to get an estimate of the
198 # signal-free variance.
199 fit = np.polyfit(imArr[good], varArr[good], deg=1)
200 # fit is [1/gain, sky_var]
201 weightArr = np.zeros_like(imArr, dtype=float)
202 weightArr[good] = 1./fit[1]
204 applyMaxSNR(imArr, weightArr, good, maxSNR)
205 return weightArr
208def applyMaxSNR(imArr, weightArr, good, maxSNR):
209 """Rescale weight of bright stars to cap the computed SNR.
211 Parameters
212 ----------
213 imArr : `ndarray`
214 Signal (image) array of stamp.
215 weightArr : `ndarray`
216 Weight map array. May be rescaled in place.
217 good : `ndarray`
218 Index array of pixels to use when computing SNR.
219 maxSNR : `float`
220 Threshold for adjusting variance plane implementing maximum SNR.
221 """
222 # We define the SNR value following Piff. Here's the comment from that
223 # code base explaining the calculation.
224 #
225 # The S/N value that we use will be the weighted total flux where the
226 # weight function is the star's profile itself. This is the maximum S/N
227 # value that any flux measurement can possibly produce, which will be
228 # closer to an in-practice S/N than using all the pixels equally.
229 #
230 # F = Sum_i w_i I_i^2
231 # var(F) = Sum_i w_i^2 I_i^2 var(I_i)
232 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i
233 #
234 # S/N = F / sqrt(var(F))
235 #
236 # Note that if the image is pure noise, this will produce a "signal" of
237 #
238 # F_noise = Sum_i w_i 1/w_i = Npix
239 #
240 # So for a more accurate estimate of the S/N of the actual star itself, one
241 # should subtract off Npix from the measured F.
242 #
243 # The final formula then is:
244 #
245 # F = Sum_i w_i I_i^2
246 # S/N = (F-Npix) / sqrt(F)
247 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float)
248 Npix = np.sum(good)
249 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F)
250 # rescale weight of bright stars. Essentially makes an error floor.
251 if SNR > maxSNR:
252 factor = (maxSNR / SNR)**2
253 weightArr[good] *= factor
256def _computeWeightAlternative(maskedImage, maxSNR):
257 """Alternative algorithm for creating weight map.
259 This version is equivalent to that used by Piff internally. The weight map
260 it produces tends to leave a residual when removing the Poisson component
261 due to the signal. We leave it here as a reference, but without intending
262 that it be used (or be maintained).
263 """
264 imArr = maskedImage.image.array
265 varArr = maskedImage.variance.array
266 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
268 fit = np.polyfit(imArr[good], varArr[good], deg=1)
269 # fit is [1/gain, sky_var]
270 gain = 1./fit[0]
271 varArr[good] -= imArr[good] / gain
272 weightArr = np.zeros_like(imArr, dtype=float)
273 weightArr[good] = 1./varArr[good]
275 applyMaxSNR(imArr, weightArr, good, maxSNR)
276 return weightArr
279class PiffPsfDeterminerTask(BasePsfDeterminerTask):
280 """A measurePsfTask PSF estimator using Piff as the implementation.
281 """
282 ConfigClass = PiffPsfDeterminerConfig
283 _DefaultName = "psfDeterminer.Piff"
285 def __init__(self, config, schema=None, **kwds):
286 BasePsfDeterminerTask.__init__(self, config, schema=schema, **kwds)
288 piffLoggingLevels = {
289 0: logging.CRITICAL,
290 1: logging.WARNING,
291 2: logging.INFO,
292 3: logging.DEBUG,
293 }
294 self.piffLogger = lsst.utils.logging.getLogger(f"{self.log.name}.piff")
295 self.piffLogger.setLevel(piffLoggingLevels[self.config.piffLoggingLevel])
297 def determinePsf(
298 self, exposure, psfCandidateList, metadata=None, flagKey=None
299 ):
300 """Determine a Piff PSF model for an exposure given a list of PSF
301 candidates.
303 Parameters
304 ----------
305 exposure : `lsst.afw.image.Exposure`
306 Exposure containing the PSF candidates.
307 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
308 A sequence of PSF candidates typically obtained by detecting sources
309 and then running them through a star selector.
310 metadata : `lsst.daf.base import PropertyList` or `None`, optional
311 A home for interesting tidbits of information.
312 flagKey : `str` or `None`, optional
313 Schema key used to mark sources actually used in PSF determination.
315 Returns
316 -------
317 psf : `lsst.meas.extensions.piff.PiffPsf`
318 The measured PSF model.
319 psfCellSet : `None`
320 Unused by this PsfDeterminer.
321 """
322 psfCandidateList = self.downsampleCandidates(psfCandidateList)
324 if self.config.stampSize:
325 stampSize = self.config.stampSize
326 if stampSize > psfCandidateList[0].getWidth():
327 self.log.warning("stampSize is larger than the PSF candidate size. Using candidate size.")
328 stampSize = psfCandidateList[0].getWidth()
329 else: # TODO: Only the if block should stay after DM-36311
330 self.log.debug("stampSize not set. Using candidate size.")
331 stampSize = psfCandidateList[0].getWidth()
333 scale = exposure.getWcs().getPixelScale().asArcseconds()
334 match self.config.useCoordinates:
335 case 'field':
336 detector = exposure.getDetector()
337 pix_to_field = detector.getTransform(PIXELS, FIELD_ANGLE)
338 gswcs = UVWcsWrapper(pix_to_field)
339 pointing = None
340 case 'sky':
341 gswcs = CelestialWcsWrapper(exposure.getWcs())
342 skyOrigin = exposure.getWcs().getSkyOrigin()
343 ra = skyOrigin.getLongitude().asDegrees()
344 dec = skyOrigin.getLatitude().asDegrees()
345 pointing = galsim.CelestialCoord(
346 ra*galsim.degrees,
347 dec*galsim.degrees
348 )
349 case 'pixel':
350 gswcs = galsim.PixelScale(scale)
351 pointing = None
353 stars = []
354 for candidate in psfCandidateList:
355 cmi = candidate.getMaskedImage(stampSize, stampSize)
356 good = getGoodPixels(cmi, self.config.zeroWeightMaskBits)
357 fracGood = np.sum(good)/good.size
358 if fracGood < self.config.minimumUnmaskedFraction:
359 continue
360 weight = computeWeight(cmi, self.config.maxSNR, good)
362 bbox = cmi.getBBox()
363 bds = galsim.BoundsI(
364 galsim.PositionI(*bbox.getMin()),
365 galsim.PositionI(*bbox.getMax())
366 )
367 gsImage = galsim.Image(bds, wcs=gswcs, dtype=float)
368 gsImage.array[:] = cmi.image.array
369 gsWeight = galsim.Image(bds, wcs=gswcs, dtype=float)
370 gsWeight.array[:] = weight
372 source = candidate.getSource()
373 image_pos = galsim.PositionD(source.getX(), source.getY())
375 data = piff.StarData(
376 gsImage,
377 image_pos,
378 weight=gsWeight,
379 pointing=pointing
380 )
381 stars.append(piff.Star(data, None))
383 piffConfig = {
384 'type': "Simple",
385 'model': {
386 'type': 'PixelGrid',
387 'scale': scale * self.config.samplingSize,
388 'size': stampSize,
389 'interp': self.config.interpolant
390 },
391 'interp': {
392 'type': 'BasisPolynomial',
393 'order': self.config.spatialOrder
394 },
395 'outliers': {
396 'type': 'Chisq',
397 'nsigma': self.config.outlierNSigma,
398 'max_remove': self.config.outlierMaxRemove
399 }
400 }
402 piffResult = piff.PSF.process(piffConfig)
403 wcs = {0: gswcs}
405 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger)
406 drawSize = 2*np.floor(0.5*stampSize/self.config.samplingSize) + 1
408 used_image_pos = [s.image_pos for s in piffResult.stars]
409 if flagKey:
410 for candidate in psfCandidateList:
411 source = candidate.getSource()
412 posd = galsim.PositionD(source.getX(), source.getY())
413 if posd in used_image_pos:
414 source.set(flagKey, True)
416 if metadata is not None:
417 metadata["spatialFitChi2"] = piffResult.chisq
418 metadata["numAvailStars"] = len(stars)
419 metadata["numGoodStars"] = len(piffResult.stars)
420 metadata["avgX"] = np.mean([p.x for p in piffResult.stars])
421 metadata["avgY"] = np.mean([p.y for p in piffResult.stars])
423 if not self.config.debugStarData:
424 for star in piffResult.stars:
425 # Remove large data objects from the stars
426 del star.fit.params
427 del star.fit.params_var
428 del star.fit.A
429 del star.fit.b
430 del star.data.image
431 del star.data.weight
432 del star.data.orig_weight
434 return PiffPsf(drawSize, drawSize, piffResult), None
437measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)