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