LSST Applications g0f08755f38+c89d42e150,g1635faa6d4+b6cf076a36,g1653933729+a8ce1bb630,g1a0ca8cf93+4c08b13bf7,g28da252d5a+f33f8200ef,g29321ee8c0+0187be18b1,g2bbee38e9b+9634bc57db,g2bc492864f+9634bc57db,g2cdde0e794+c2c89b37c4,g3156d2b45e+41e33cbcdc,g347aa1857d+9634bc57db,g35bb328faa+a8ce1bb630,g3a166c0a6a+9634bc57db,g3e281a1b8c+9f2c4e2fc3,g414038480c+077ccc18e7,g41af890bb2+e740673f1a,g5fbc88fb19+17cd334064,g7642f7d749+c89d42e150,g781aacb6e4+a8ce1bb630,g80478fca09+f8b2ab54e1,g82479be7b0+e2bd23ab8b,g858d7b2824+c89d42e150,g9125e01d80+a8ce1bb630,g9726552aa6+10f999ec6a,ga5288a1d22+065360aec4,gacf8899fa4+9553554aa7,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gbd46683f8f+ac57cbb13d,gc28159a63d+9634bc57db,gcf0d15dbbd+e37acf7834,gda3e153d99+c89d42e150,gda6a2b7d83+e37acf7834,gdaeeff99f8+1711a396fd,ge2409df99d+cb1e6652d6,ge79ae78c31+9634bc57db,gf0baf85859+147a0692ba,gf3967379c6+02b11634a5,w.2024.45
LSST Data Management Base Package
Loading...
Searching...
No Matches
psfexPsfDeterminer.py
Go to the documentation of this file.
1# This file is part of meas_extensions_psfex.
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__ = ("PsfexPsfDeterminerConfig", "PsfexPsfDeterminerTask")
23
24import os
25import numpy as np
26
27import lsst.daf.base as dafBase
28import lsst.pex.config as pexConfig
29import lsst.pex.exceptions as pexExcept
30import lsst.geom as geom
31import lsst.afw.geom.ellipses as afwEll
32import lsst.afw.display as afwDisplay
33import lsst.afw.image as afwImage
34import lsst.afw.math as afwMath
35import lsst.meas.algorithms as measAlg
36import lsst.meas.algorithms.utils as maUtils
37import lsst.meas.extensions.psfex as psfex
38from lsst.pipe.base import AlgorithmError
39
40
41class PsfexTooFewGoodStarsError(AlgorithmError):
42 """Raised if too few good stars are available for PSF determination.
43
44 Parameters
45 ----------
46 num_available_stars : `int`
47 Number of available stars for PSF determination.
48 num_good_stars : `int`
49 Number of good stars used for PSF determination.
50 poly_ndim_initial : `int`
51 Initial number of dependency parameters (dimensions) used in
52 polynomial fitting.
53 poly_ndim_final : `int`
54 Final number of dependency parameters (dimensions) set in the PSFEx
55 model after initializing the PSF structure.
56 """
57
59 self,
60 num_available_stars,
61 num_good_stars,
62 poly_ndim_initial,
63 poly_ndim_final,
64 ) -> None:
65 self._num_available_stars = num_available_stars
66 self._num_good_stars = num_good_stars
67 self._poly_ndim_initial = poly_ndim_initial
68 self._poly_ndim_final = poly_ndim_final
69 super().__init__(
70 f"Failed to determine psfex psf: too few good stars ({num_good_stars}) out of "
71 f"{num_available_stars} available. To accommodate this low count, the polynomial dimension was "
72 f"lowered from {poly_ndim_initial} to {poly_ndim_final}, which is not handled by the Science "
73 "Pipelines code."
74 )
75
76 @property
77 def metadata(self) -> dict:
78 return {
79 "num_available_stars": self._num_available_stars,
80 "num_good_stars": self._num_good_stars,
81 "poly_ndim_initial": self._poly_ndim_initial,
82 "poly_ndim_final": self._poly_ndim_final,
83 }
84
85
86class PsfexPsfDeterminerConfig(measAlg.BasePsfDeterminerConfig):
87 spatialOrder = pexConfig.Field[int](
88 doc="specify spatial order for PSF kernel creation",
89 default=2,
90 check=lambda x: x >= 1,
91 )
92 sizeCellX = pexConfig.Field[int](
93 doc="size of cell used to determine PSF (pixels, column direction)",
94 default=256,
95 # minValue = 10,
96 check=lambda x: x >= 10,
97 )
98 sizeCellY = pexConfig.Field[int](
99 doc="size of cell used to determine PSF (pixels, row direction)",
100 default=sizeCellX.default,
101 # minValue = 10,
102 check=lambda x: x >= 10,
103 )
104 samplingSize = pexConfig.Field[float](
105 doc="Resolution of the internal PSF model relative to the pixel size; "
106 "e.g. 0.5 is equal to 2x oversampling",
107 default=0.5,
108 )
109 badMaskBits = pexConfig.ListField[str](
110 doc="List of mask bits which cause a source to be rejected as bad "
111 "N.b. INTRP is used specially in PsfCandidateSet; it means \"Contaminated by neighbour\"",
112 default=["INTRP", "SAT"],
113 )
114 psfexBasis = pexConfig.ChoiceField[str](
115 doc="BASIS value given to psfex. PIXEL_AUTO will use the requested samplingSize only if "
116 "the FWHM < 3 pixels. Otherwise, it will use samplingSize=1. PIXEL will always use the "
117 "requested samplingSize",
118 allowed={
119 "PIXEL": "Always use requested samplingSize",
120 "PIXEL_AUTO": "Only use requested samplingSize when FWHM < 3",
121 },
122 default='PIXEL_AUTO',
123 optional=False,
124 )
125 tolerance = pexConfig.Field[float](
126 doc="tolerance of spatial fitting",
127 default=1e-2,
128 )
129 lam = pexConfig.Field[float](
130 doc="floor for variance is lam*data",
131 default=0.05,
132 )
133 reducedChi2ForPsfCandidates = pexConfig.Field[float](
134 doc="for psf candidate evaluation",
135 default=2.0,
136 )
137 spatialReject = pexConfig.Field[float](
138 doc="Rejection threshold (stdev) for candidates based on spatial fit",
139 default=3.0,
140 )
141 recentroid = pexConfig.Field[bool](
142 doc="Should PSFEX be permitted to recentroid PSF candidates?",
143 default=False,
144 )
145 photometricFluxField = pexConfig.Field[str](
146 doc="Flux field to use for photometric normalization. This overrides the "
147 "``PHOTFLUX_KEY`` field for psfex. The associated flux error is "
148 "derived by appending ``Err`` to this field.",
149 default="base_CircularApertureFlux_9_0_instFlux",
150 )
151
152 def setDefaults(self):
153 super().setDefaults()
154 self.stampSize = 41
155
156
157class PsfexPsfDeterminerTask(measAlg.BasePsfDeterminerTask):
158 ConfigClass = PsfexPsfDeterminerConfig
159
160 def determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None):
161 """Determine a PSFEX PSF model for an exposure given a list of PSF
162 candidates.
163
164 Parameters
165 ----------
166 exposure: `lsst.afw.image.Exposure`
167 Exposure containing the PSF candidates.
168 psfCandidateList: iterable of `lsst.meas.algorithms.PsfCandidate`
169 Sequence of PSF candidates typically obtained by detecting sources
170 and then running them through a star selector.
171 metadata: metadata, optional
172 A home for interesting tidbits of information.
173 flagKey: `lsst.afw.table.Key`, optional
174 Schema key used to mark sources actually used in PSF determination.
175
176 Returns
177 -------
178 psf: `lsst.meas.extensions.psfex.PsfexPsf`
179 The determined PSF.
180 """
181 psfCandidateList = self.downsampleCandidates(psfCandidateList)
182
183 import lsstDebug
184 display = lsstDebug.Info(__name__).display
185 displayExposure = display and \
186 lsstDebug.Info(__name__).displayExposure # display the Exposure + spatialCells
187 displayPsfComponents = display and \
188 lsstDebug.Info(__name__).displayPsfComponents # show the basis functions
189 showBadCandidates = display and \
190 lsstDebug.Info(__name__).showBadCandidates # Include bad candidates (meaningless, methinks)
191 displayResiduals = display and \
192 lsstDebug.Info(__name__).displayResiduals # show residuals
193 displayPsfMosaic = display and \
194 lsstDebug.Info(__name__).displayPsfMosaic # show mosaic of reconstructed PSF(x,y)
195 normalizeResiduals = lsstDebug.Info(__name__).normalizeResiduals
196 afwDisplay.setDefaultMaskTransparency(75)
197 # Normalise residuals by object amplitude
198
199 mi = exposure.getMaskedImage()
200
201 nCand = len(psfCandidateList)
202 if nCand == 0:
203 raise RuntimeError("No PSF candidates supplied.")
204 #
205 # How big should our PSF models be?
206 #
207 if display: # only needed for debug plots
208 # construct and populate a spatial cell set
209 bbox = mi.getBBox(afwImage.PARENT)
210 psfCellSet = afwMath.SpatialCellSet(bbox, self.config.sizeCellX, self.config.sizeCellY)
211 else:
212 psfCellSet = None
213
214 sizes = np.empty(nCand)
215 for i, psfCandidate in enumerate(psfCandidateList):
216 try:
217 if psfCellSet:
218 psfCellSet.insertCandidate(psfCandidate)
219 except Exception as e:
220 self.log.error("Skipping PSF candidate %d of %d: %s", i, len(psfCandidateList), e)
221 continue
222
223 source = psfCandidate.getSource()
224 quad = afwEll.Quadrupole(source.getIxx(), source.getIyy(), source.getIxy())
225 rmsSize = quad.getTraceRadius()
226 sizes[i] = rmsSize
227
228 pixKernelSize = self.config.stampSize
229 actualKernelSize = int(2*np.floor(0.5*pixKernelSize/self.config.samplingSize) + 1)
230
231 if display:
232 rms = np.median(sizes)
233 self.log.debug("Median PSF RMS size=%.2f pixels (\"FWHM\"=%.2f)",
234 rms, 2*np.sqrt(2*np.log(2))*rms)
235
236 self.log.trace("Psfex Kernel size=%.2f, Image Kernel Size=%.2f", actualKernelSize, pixKernelSize)
237
238 # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- BEGIN PSFEX
239 #
240 # Insert the good candidates into the set
241 #
242 defaultsFile = os.path.join(os.environ["MEAS_EXTENSIONS_PSFEX_DIR"], "config", "default-lsst.psfex")
243 args_md = dafBase.PropertySet()
244 args_md.set("BASIS_TYPE", str(self.config.psfexBasis))
245 args_md.set("PSFVAR_DEGREES", str(self.config.spatialOrder))
246 args_md.set("PSF_SIZE", str(actualKernelSize))
247 args_md.set("PSF_SAMPLING", str(self.config.samplingSize))
248 args_md.set("PHOTFLUX_KEY", str(self.config.photometricFluxField))
249 args_md.set("PHOTFLUXERR_KEY", str(self.config.photometricFluxField) + "Err")
250 prefs = psfex.Prefs(defaultsFile, args_md)
251 prefs.setCommandLine([])
252 prefs.addCatalog("psfexPsfDeterminer")
253
254 prefs.use()
255 principalComponentExclusionFlag = bool(bool(psfex.Context.REMOVEHIDDEN)
256 if False else psfex.Context.KEEPHIDDEN)
257 context = psfex.Context(prefs.getContextName(), prefs.getContextGroup(),
258 prefs.getGroupDeg(), principalComponentExclusionFlag)
259 psfSet = psfex.Set(context)
260 psfSet.setVigSize(pixKernelSize, pixKernelSize)
261 psfSet.setFwhm(2*np.sqrt(2*np.log(2))*np.median(sizes))
262 psfSet.setRecentroid(self.config.recentroid)
263
264 catindex, ext = 0, 0
265 backnoise2 = afwMath.makeStatistics(mi.getImage(), afwMath.VARIANCECLIP).getValue()
266 ccd = exposure.getDetector()
267 if ccd:
268 gain = np.mean(np.array([a.getGain() for a in ccd]))
269 else:
270 gain = 1.0
271 self.log.warning("Setting gain to %g", gain)
272
273 contextvalp = []
274 for i, key in enumerate(context.getName()):
275 if key[0] == ':':
276 try:
277 contextvalp.append(exposure.getMetadata().getScalar(key[1:]))
278 except KeyError as e:
279 raise RuntimeError("%s parameter not found in the header of %s" %
280 (key[1:], prefs.getContextName())) from e
281 else:
282 try:
283 contextvalp.append(np.array([psfCandidateList[_].getSource().get(key)
284 for _ in range(nCand)]))
285 except KeyError as e:
286 raise RuntimeError("%s parameter not found" % (key,)) from e
287 psfSet.setContextname(i, key)
288
289 if display:
290 frame = 0
291 if displayExposure:
292 disp = afwDisplay.Display(frame=frame)
293 disp.mtv(exposure, title="psf determination")
294
295 badBits = mi.getMask().getPlaneBitMask(self.config.badMaskBits)
296 fluxName = prefs.getPhotfluxRkey()
297 fluxFlagName = "base_" + fluxName + "_flag"
298
299 xpos, ypos = [], []
300 for i, psfCandidate in enumerate(psfCandidateList):
301 source = psfCandidate.getSource()
302
303 # skip sources with bad centroids
304 xc, yc = source.getX(), source.getY()
305 if not np.isfinite(xc) or not np.isfinite(yc):
306 continue
307 # skip flagged sources
308 if fluxFlagName in source.schema and source.get(fluxFlagName):
309 continue
310 # skip nonfinite and negative sources
311 flux = source.get(fluxName)
312 if flux < 0 or not np.isfinite(flux):
313 continue
314
315 try:
316 pstamp = psfCandidate.getMaskedImage(pixKernelSize, pixKernelSize).clone()
318 self.log.warning("Could not get stamp image for psfCandidate: %s with kernel size: %s",
319 psfCandidate, pixKernelSize)
320 continue
321
322 # From this point, we're configuring the "sample" (PSFEx's version
323 # of a PSF candidate).
324 # Having created the sample, we must proceed to configure it, and
325 # then fini (finalize), or it will be malformed.
326 try:
327 sample = psfSet.newSample()
328 sample.setCatindex(catindex)
329 sample.setExtindex(ext)
330 sample.setObjindex(i)
331
332 imArray = pstamp.getImage().getArray()
333 imArray[np.where(np.bitwise_and(pstamp.getMask().getArray(), badBits))] = \
334 -2*psfex.BIG
335 sample.setVig(imArray)
336
337 sample.setNorm(flux)
338 sample.setBacknoise2(backnoise2)
339 sample.setGain(gain)
340 sample.setX(xc)
341 sample.setY(yc)
342 sample.setFluxrad(sizes[i])
343
344 for j in range(psfSet.getNcontext()):
345 sample.setContext(j, float(contextvalp[j][i]))
346 except Exception as e:
347 self.log.error("Exception when processing sample at (%f,%f): %s", xc, yc, e)
348 continue
349 else:
350 psfSet.finiSample(sample)
351
352 xpos.append(xc) # for QA
353 ypos.append(yc)
354
355 if displayExposure:
356 with disp.Buffering():
357 disp.dot("o", xc, yc, ctype=afwDisplay.CYAN, size=4)
358
359 if psfSet.getNsample() == 0:
360 raise RuntimeError("No good PSF candidates to pass to PSFEx")
361
362 # ---- Update min and max and then the scaling
363 for i in range(psfSet.getNcontext()):
364 cmin = contextvalp[i].min()
365 cmax = contextvalp[i].max()
366 psfSet.setContextScale(i, cmax - cmin)
367 psfSet.setContextOffset(i, (cmin + cmax)/2.0)
368
369 # Don't waste memory!
370 psfSet.trimMemory()
371
372 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- END PSFEX
373 #
374 # Do a PSFEX decomposition of those PSF candidates
375 #
376 fields = []
377 field = psfex.Field("Unknown")
378 field.addExt(exposure.getWcs(), exposure.getWidth(), exposure.getHeight(), psfSet.getNsample())
379 field.finalize()
380
381 fields.append(field)
382
383 sets = []
384 sets.append(psfSet)
385
386 psfex.makeit(fields, sets)
387 psfs = field.getPsfs()
388
389 # Flag which objects were actually used in psfex by
390 good_indices = []
391 for i in range(sets[0].getNsample()):
392 index = sets[0].getSample(i).getObjindex()
393 if index > -1:
394 good_indices.append(index)
395
396 if flagKey is not None:
397 for i, psfCandidate in enumerate(psfCandidateList):
398 source = psfCandidate.getSource()
399 if i in good_indices:
400 source.set(flagKey, True)
401
402 xpos = np.array(xpos)
403 ypos = np.array(ypos)
404 numGoodStars = len(good_indices)
405 avgX, avgY = np.mean(xpos), np.mean(ypos)
406
407 psf = psfex.PsfexPsf(psfs[0], geom.Point2D(avgX, avgY))
408
409 # If there are too few stars, the PSFEx psf model will reduce the order
410 # to 0, which the Science Pipelines code cannot handle (see
411 # https://github.com/lsst/meas_extensions_psfex/blob/f0d5218b5446faf5e39edc30e31d2e6f673ef294/src/PsfexPsf.cc#L118
412 # ).
413 if (ndim := psf.getNdim()) == 0:
415 num_available_stars=nCand,
416 num_good_stars=numGoodStars,
417 poly_ndim_initial=psfSet.getNcontext(),
418 poly_ndim_final=ndim,
419 )
420
421 #
422 # Display code for debugging
423 #
424 if display:
425 assert psfCellSet is not None
426
427 if displayExposure:
428 maUtils.showPsfSpatialCells(exposure, psfCellSet, showChi2=True,
429 symb="o", ctype=afwDisplay.YELLOW, ctypeBad=afwDisplay.RED,
430 size=8, display=disp)
431 if displayResiduals:
432 disp4 = afwDisplay.Display(frame=4)
433 maUtils.showPsfCandidates(exposure, psfCellSet, psf=psf, display=disp4,
434 normalize=normalizeResiduals,
435 showBadCandidates=showBadCandidates)
436 if displayPsfComponents:
437 disp6 = afwDisplay.Display(frame=6)
438 maUtils.showPsf(psf, display=disp6)
439 if displayPsfMosaic:
440 disp7 = afwDisplay.Display(frame=7)
441 maUtils.showPsfMosaic(exposure, psf, display=disp7, showFwhm=True)
442 disp.scale('linear', 0, 1)
443 #
444 # Generate some QA information
445 #
446 # Count PSF stars
447 #
448 if metadata is not None:
449 metadata["spatialFitChi2"] = np.nan
450 metadata["numAvailStars"] = nCand
451 metadata["numGoodStars"] = numGoodStars
452 metadata["avgX"] = avgX
453 metadata["avgY"] = avgY
454
455 return psf, psfCellSet
456
457
458measAlg.psfDeterminerRegistry.register("psfex", PsfexPsfDeterminerTask)
int min
int max
A collection of SpatialCells covering an entire image.
Class for storing generic metadata.
Definition PropertySet.h:66
Represent a PSF as a linear combination of PSFEX (== Karhunen-Loeve) basis functions.
Definition PsfexPsf.h:40
determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None)
None __init__(self, num_available_stars, num_good_stars, poly_ndim_initial, poly_ndim_final)
Reports attempts to exceed implementation-defined length limits for some classes.
Definition Runtime.h:76
Statistics makeStatistics(lsst::afw::image::Image< Pixel > const &img, lsst::afw::image::Mask< image::MaskPixel > const &msk, int const flags, StatisticsControl const &sctrl=StatisticsControl())
Handle a watered-down front-end to the constructor (no variance)
Definition Statistics.h:361