LSST Applications 28.0.0,g1653933729+a8ce1bb630,g1a997c3884+a8ce1bb630,g28da252d5a+5bd70b7e6d,g2bbee38e9b+638fca75ac,g2bc492864f+638fca75ac,g3156d2b45e+07302053f8,g347aa1857d+638fca75ac,g35bb328faa+a8ce1bb630,g3a166c0a6a+638fca75ac,g3e281a1b8c+7bbb0b2507,g4005a62e65+17cd334064,g414038480c+5b5cd4fff3,g41af890bb2+4ffae9de63,g4e1a3235cc+0f1912dca3,g6249c6f860+3c3976f90c,g80478fca09+46aba80bd6,g82479be7b0+77990446f6,g858d7b2824+78ba4d1ce1,g89c8672015+f667a5183b,g9125e01d80+a8ce1bb630,ga5288a1d22+2a6264e9ca,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc22bb204ba+78ba4d1ce1,gc28159a63d+638fca75ac,gcf0d15dbbd+32ddb6096f,gd6b7c0dfd1+3e339405e9,gda3e153d99+78ba4d1ce1,gda6a2b7d83+32ddb6096f,gdaeeff99f8+1711a396fd,gdd5a9049c5+b18c39e5e3,ge2409df99d+a5e4577cdc,ge33fd446bb+78ba4d1ce1,ge79ae78c31+638fca75ac,gf0baf85859+64e8883e75,gf5289d68f6+e1b046a8d7,gfa443fc69c+91d9ed1ecf,gfda6b12a05+8419469a56
LSST Data Management Base Package
Loading...
Searching...
No Matches
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
28import logging
29
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
37
38
39def _validateGalsimInterpolant(name: str) -> bool:
40 """A helper function to validate the GalSim interpolant at config time.
41
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
53
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)
65
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
71
72
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 modelSize = pexConfig.Field[int](
84 doc="Internal model size for PIFF (typically odd, but not enforced)",
85 default=25,
86 )
87 outlierNSigma = pexConfig.Field[float](
88 doc="n sigma for chisq outlier rejection",
89 default=4.0
90 )
91 outlierMaxRemove = pexConfig.Field[float](
92 doc="Max fraction of stars to remove as outliers each iteration",
93 default=0.05
94 )
95 maxSNR = pexConfig.Field[float](
96 doc="Rescale the weight of bright stars such that their SNR is less "
97 "than this value.",
98 default=200.0
99 )
100 zeroWeightMaskBits = pexConfig.ListField[str](
101 doc="List of mask bits for which to set pixel weights to zero.",
102 default=['BAD', 'CR', 'INTRP', 'SAT', 'SUSPECT', 'NO_DATA']
103 )
104 minimumUnmaskedFraction = pexConfig.Field[float](
105 doc="Minimum fraction of unmasked pixels required to use star.",
106 default=0.5
107 )
108 interpolant = pexConfig.Field[str](
109 doc="GalSim interpolant name for Piff to use. "
110 "Options include 'Lanczos(N)', where N is an integer, along with "
111 "galsim.Cubic, galsim.Delta, galsim.Linear, galsim.Nearest, "
112 "galsim.Quintic, and galsim.SincInterpolant.",
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 )
120 useCoordinates = pexConfig.ChoiceField[str](
121 doc="Which spatial coordinates to regress against in PSF modeling.",
122 allowed=dict(
123 pixel='Regress against pixel coordinates',
124 field='Regress against field angles',
125 sky='Regress against RA/Dec'
126 ),
127 default='pixel'
128 )
129 piffLoggingLevel = pexConfig.RangeField[int](
130 doc="PIFF-specific logging level, from 0 (least chatty) to 3 (very verbose); "
131 "note that logs will come out with unrelated notations (e.g. WARNING does "
132 "not imply a warning.)",
133 default=0,
134 min=0,
135 max=3,
136 )
137
138 def setDefaults(self):
139 super().setDefaults()
140 # stampSize should be at least 25 so that
141 # i) aperture flux with 12 pixel radius can be compared to PSF flux.
142 # ii) fake sources injected to match the 12 pixel aperture flux get
143 # measured correctly
144 # stampSize should also be at least sqrt(2)*modelSize/samplingSize so
145 # that rotated images have support in the model
146
147 self.stampSize = 25
148 # Resize the stamp to accommodate the model, but only if necessary.
149 if self.useCoordinates == "sky":
150 self.stampSize = max(25, 2*int(0.5*self.modelSize*np.sqrt(2)/self.samplingSize) + 1)
151
152 def validate(self):
153 super().validate()
154
155 if (stamp_size := self.stampSize) is not None:
156 model_size = self.modelSize
157 sampling_size = self.samplingSize
158 if self.useCoordinates == 'sky':
159 min_stamp_size = int(np.sqrt(2) * model_size / sampling_size)
160 else:
161 min_stamp_size = int(model_size / sampling_size)
162
163 if stamp_size < min_stamp_size:
164 msg = (f"PIFF model size of {model_size} is too large for stamp size {stamp_size}. "
165 f"Set stampSize >= {min_stamp_size}"
166 )
167 raise pexConfig.FieldValidationError(self.__class__.modelSize, self, msg)
168
169
170def getGoodPixels(maskedImage, zeroWeightMaskBits):
171 """Compute an index array indicating good pixels to use.
172
173 Parameters
174 ----------
175 maskedImage : `afw.image.MaskedImage`
176 PSF candidate postage stamp
177 zeroWeightMaskBits : `List[str]`
178 List of mask bits for which to set pixel weights to zero.
179
180 Returns
181 -------
182 good : `ndarray`
183 Index array indicating good pixels.
184 """
185 imArr = maskedImage.image.array
186 varArr = maskedImage.variance.array
187 bitmask = maskedImage.mask.getPlaneBitMask(zeroWeightMaskBits)
188 good = (
189 (varArr != 0)
190 & (np.isfinite(varArr))
191 & (np.isfinite(imArr))
192 & ((maskedImage.mask.array & bitmask) == 0)
193 )
194 return good
195
196
197def computeWeight(maskedImage, maxSNR, good):
198 """Derive a weight map without Poisson variance component due to signal.
199
200 Parameters
201 ----------
202 maskedImage : `afw.image.MaskedImage`
203 PSF candidate postage stamp
204 maxSNR : `float`
205 Maximum SNR applying variance floor.
206 good : `ndarray`
207 Index array indicating good pixels.
208
209 Returns
210 -------
211 weightArr : `ndarry`
212 Array to use for weight.
213
214 See Also
215 --------
216 `lsst.meas.algorithms.variance_plance.remove_signal_from_variance` :
217 Remove the Poisson contribution from sources in the variance plane of
218 an Exposure.
219 """
220 imArr = maskedImage.image.array
221 varArr = maskedImage.variance.array
222
223 # Fit a straight line to variance vs (sky-subtracted) signal.
224 # The evaluate that line at zero signal to get an estimate of the
225 # signal-free variance.
226 fit = np.polyfit(imArr[good], varArr[good], deg=1)
227 # fit is [1/gain, sky_var]
228 weightArr = np.zeros_like(imArr, dtype=float)
229 weightArr[good] = 1./fit[1]
230
231 applyMaxSNR(imArr, weightArr, good, maxSNR)
232 return weightArr
233
234
235def applyMaxSNR(imArr, weightArr, good, maxSNR):
236 """Rescale weight of bright stars to cap the computed SNR.
237
238 Parameters
239 ----------
240 imArr : `ndarray`
241 Signal (image) array of stamp.
242 weightArr : `ndarray`
243 Weight map array. May be rescaled in place.
244 good : `ndarray`
245 Index array of pixels to use when computing SNR.
246 maxSNR : `float`
247 Threshold for adjusting variance plane implementing maximum SNR.
248 """
249 # We define the SNR value following Piff. Here's the comment from that
250 # code base explaining the calculation.
251 #
252 # The S/N value that we use will be the weighted total flux where the
253 # weight function is the star's profile itself. This is the maximum S/N
254 # value that any flux measurement can possibly produce, which will be
255 # closer to an in-practice S/N than using all the pixels equally.
256 #
257 # F = Sum_i w_i I_i^2
258 # var(F) = Sum_i w_i^2 I_i^2 var(I_i)
259 # = Sum_i w_i I_i^2 <--- Assumes var(I_i) = 1/w_i
260 #
261 # S/N = F / sqrt(var(F))
262 #
263 # Note that if the image is pure noise, this will produce a "signal" of
264 #
265 # F_noise = Sum_i w_i 1/w_i = Npix
266 #
267 # So for a more accurate estimate of the S/N of the actual star itself, one
268 # should subtract off Npix from the measured F.
269 #
270 # The final formula then is:
271 #
272 # F = Sum_i w_i I_i^2
273 # S/N = (F-Npix) / sqrt(F)
274 F = np.sum(weightArr[good]*imArr[good]**2, dtype=float)
275 Npix = np.sum(good)
276 SNR = 0.0 if F < Npix else (F-Npix)/np.sqrt(F)
277 # rescale weight of bright stars. Essentially makes an error floor.
278 if SNR > maxSNR:
279 factor = (maxSNR / SNR)**2
280 weightArr[good] *= factor
281
282
283def _computeWeightAlternative(maskedImage, maxSNR):
284 """Alternative algorithm for creating weight map.
285
286 This version is equivalent to that used by Piff internally. The weight map
287 it produces tends to leave a residual when removing the Poisson component
288 due to the signal. We leave it here as a reference, but without intending
289 that it be used (or be maintained).
290 """
291 imArr = maskedImage.image.array
292 varArr = maskedImage.variance.array
293 good = (varArr != 0) & np.isfinite(varArr) & np.isfinite(imArr)
294
295 fit = np.polyfit(imArr[good], varArr[good], deg=1)
296 # fit is [1/gain, sky_var]
297 gain = 1./fit[0]
298 varArr[good] -= imArr[good] / gain
299 weightArr = np.zeros_like(imArr, dtype=float)
300 weightArr[good] = 1./varArr[good]
301
302 applyMaxSNR(imArr, weightArr, good, maxSNR)
303 return weightArr
304
305
307 """A measurePsfTask PSF estimator using Piff as the implementation.
308 """
309 ConfigClass = PiffPsfDeterminerConfig
310 _DefaultName = "psfDeterminer.Piff"
311
312 def __init__(self, config, schema=None, **kwds):
313 BasePsfDeterminerTask.__init__(self, config, schema=schema, **kwds)
314
315 piffLoggingLevels = {
316 0: logging.CRITICAL,
317 1: logging.WARNING,
318 2: logging.INFO,
319 3: logging.DEBUG,
320 }
321 self.piffLogger = lsst.utils.logging.getLogger(f"{self.log.name}.piff")
322 self.piffLogger.setLevel(piffLoggingLevels[self.config.piffLoggingLevel])
323
325 self, exposure, psfCandidateList, metadata=None, flagKey=None
326 ):
327 """Determine a Piff PSF model for an exposure given a list of PSF
328 candidates.
329
330 Parameters
331 ----------
332 exposure : `lsst.afw.image.Exposure`
333 Exposure containing the PSF candidates.
334 psfCandidateList : `list` of `lsst.meas.algorithms.PsfCandidate`
335 A sequence of PSF candidates typically obtained by detecting sources
336 and then running them through a star selector.
337 metadata : `lsst.daf.base import PropertyList` or `None`, optional
338 A home for interesting tidbits of information.
339 flagKey : `str` or `None`, optional
340 Schema key used to mark sources actually used in PSF determination.
341
342 Returns
343 -------
344 psf : `lsst.meas.extensions.piff.PiffPsf`
345 The measured PSF model.
346 psfCellSet : `None`
347 Unused by this PsfDeterminer.
348 """
349 psfCandidateList = self.downsampleCandidates(psfCandidateList)
350
351 if self.config.stampSize:
352 stampSize = self.config.stampSize
353 if stampSize > psfCandidateList[0].getWidth():
354 self.log.warning("stampSize is larger than the PSF candidate size. Using candidate size.")
355 stampSize = psfCandidateList[0].getWidth()
356 else: # TODO: Only the if block should stay after DM-36311
357 self.log.debug("stampSize not set. Using candidate size.")
358 stampSize = psfCandidateList[0].getWidth()
359
360 scale = exposure.getWcs().getPixelScale(exposure.getBBox().getCenter()).asArcseconds()
361
362 match self.config.useCoordinates:
363 case 'field':
364 detector = exposure.getDetector()
365 pix_to_field = detector.getTransform(PIXELS, FIELD_ANGLE)
366 gswcs = UVWcsWrapper(pix_to_field)
367 pointing = None
368
369 case 'sky':
370 gswcs = CelestialWcsWrapper(exposure.getWcs())
371 skyOrigin = exposure.getWcs().getSkyOrigin()
372 ra = skyOrigin.getLongitude().asDegrees()
373 dec = skyOrigin.getLatitude().asDegrees()
374 pointing = galsim.CelestialCoord(
375 ra*galsim.degrees,
376 dec*galsim.degrees
377 )
378
379 case 'pixel':
380 gswcs = galsim.PixelScale(scale)
381 pointing = None
382
383 stars = []
384 for candidate in psfCandidateList:
385 cmi = candidate.getMaskedImage(stampSize, stampSize)
386 good = getGoodPixels(cmi, self.config.zeroWeightMaskBits)
387 fracGood = np.sum(good)/good.size
388 if fracGood < self.config.minimumUnmaskedFraction:
389 continue
390 weight = computeWeight(cmi, self.config.maxSNR, good)
391
392 bbox = cmi.getBBox()
393 bds = galsim.BoundsI(
394 galsim.PositionI(*bbox.getMin()),
395 galsim.PositionI(*bbox.getMax())
396 )
397 gsImage = galsim.Image(bds, wcs=gswcs, dtype=float)
398 gsImage.array[:] = cmi.image.array
399 gsWeight = galsim.Image(bds, wcs=gswcs, dtype=float)
400 gsWeight.array[:] = weight
401
402 source = candidate.getSource()
403 image_pos = galsim.PositionD(source.getX(), source.getY())
404
405 data = piff.StarData(
406 gsImage,
407 image_pos,
408 weight=gsWeight,
409 pointing=pointing
410 )
411 stars.append(piff.Star(data, None))
412
413 piffConfig = {
414 'type': "Simple",
415 'model': {
416 'type': 'PixelGrid',
417 'scale': scale * self.config.samplingSize,
418 'size': self.config.modelSize,
419 'interp': self.config.interpolant
420 },
421 'interp': {
422 'type': 'BasisPolynomial',
423 'order': self.config.spatialOrder
424 },
425 'outliers': {
426 'type': 'Chisq',
427 'nsigma': self.config.outlierNSigma,
428 'max_remove': self.config.outlierMaxRemove
429 }
430 }
431
432 piffResult = piff.PSF.process(piffConfig)
433 wcs = {0: gswcs}
434
435 piffResult.fit(stars, wcs, pointing, logger=self.piffLogger)
436 drawSize = 2*np.floor(0.5*stampSize/self.config.samplingSize) + 1
437
438 used_image_pos = [s.image_pos for s in piffResult.stars]
439 if flagKey:
440 for candidate in psfCandidateList:
441 source = candidate.getSource()
442 posd = galsim.PositionD(source.getX(), source.getY())
443 if posd in used_image_pos:
444 source.set(flagKey, True)
445
446 if metadata is not None:
447 metadata["spatialFitChi2"] = piffResult.chisq
448 metadata["numAvailStars"] = len(stars)
449 metadata["numGoodStars"] = len(piffResult.stars)
450 metadata["avgX"] = np.mean([p.x for p in piffResult.stars])
451 metadata["avgY"] = np.mean([p.y for p in piffResult.stars])
452
453 if not self.config.debugStarData:
454 for star in piffResult.stars:
455 # Remove large data objects from the stars
456 del star.fit.params
457 del star.fit.params_var
458 del star.fit.A
459 del star.fit.b
460 del star.data.image
461 del star.data.weight
462 del star.data.orig_weight
463
464 return PiffPsf(drawSize, drawSize, piffResult), None
465
466
467measAlg.psfDeterminerRegistry.register("piff", PiffPsfDeterminerTask)
int max
determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None)
getGoodPixels(maskedImage, zeroWeightMaskBits)
applyMaxSNR(imArr, weightArr, good, maxSNR)