Coverage for python/lsst/meas/extensions/psfex/psfexPsfDeterminer.py: 12%
212 statements
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-26 03:29 -0700
« prev ^ index » next coverage.py v7.4.4, created at 2024-03-26 03:29 -0700
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/>.
22__all__ = ("PsfexPsfDeterminerConfig", "PsfexPsfDeterminerTask")
24import os
25import numpy as np
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
40class PsfexPsfDeterminerConfig(measAlg.BasePsfDeterminerConfig):
41 spatialOrder = pexConfig.Field[int]( 41 ↛ exitline 41 didn't jump to the function exit
42 doc="specify spatial order for PSF kernel creation",
43 default=2,
44 check=lambda x: x >= 1,
45 )
46 sizeCellX = pexConfig.Field[int]( 46 ↛ exitline 46 didn't jump to the function exit
47 doc="size of cell used to determine PSF (pixels, column direction)",
48 default=256,
49 # minValue = 10,
50 check=lambda x: x >= 10,
51 )
52 sizeCellY = pexConfig.Field[int]( 52 ↛ exitline 52 didn't jump to the function exit
53 doc="size of cell used to determine PSF (pixels, row direction)",
54 default=sizeCellX.default,
55 # minValue = 10,
56 check=lambda x: x >= 10,
57 )
58 samplingSize = pexConfig.Field[float](
59 doc="Resolution of the internal PSF model relative to the pixel size; "
60 "e.g. 0.5 is equal to 2x oversampling",
61 default=0.5,
62 )
63 badMaskBits = pexConfig.ListField[str](
64 doc="List of mask bits which cause a source to be rejected as bad "
65 "N.b. INTRP is used specially in PsfCandidateSet; it means \"Contaminated by neighbour\"",
66 default=["INTRP", "SAT"],
67 )
68 psfexBasis = pexConfig.ChoiceField[str](
69 doc="BASIS value given to psfex. PIXEL_AUTO will use the requested samplingSize only if "
70 "the FWHM < 3 pixels. Otherwise, it will use samplingSize=1. PIXEL will always use the "
71 "requested samplingSize",
72 allowed={
73 "PIXEL": "Always use requested samplingSize",
74 "PIXEL_AUTO": "Only use requested samplingSize when FWHM < 3",
75 },
76 default='PIXEL_AUTO',
77 optional=False,
78 )
79 tolerance = pexConfig.Field[float](
80 doc="tolerance of spatial fitting",
81 default=1e-2,
82 )
83 lam = pexConfig.Field[float](
84 doc="floor for variance is lam*data",
85 default=0.05,
86 )
87 reducedChi2ForPsfCandidates = pexConfig.Field[float](
88 doc="for psf candidate evaluation",
89 default=2.0,
90 )
91 spatialReject = pexConfig.Field[float](
92 doc="Rejection threshold (stdev) for candidates based on spatial fit",
93 default=3.0,
94 )
95 recentroid = pexConfig.Field[bool](
96 doc="Should PSFEX be permitted to recentroid PSF candidates?",
97 default=False,
98 )
99 photometricFluxField = pexConfig.Field[str](
100 doc="Flux field to use for photometric normalization. This overrides the "
101 "``PHOTFLUX_KEY`` field for psfex. The associated flux error is "
102 "derived by appending ``Err`` to this field.",
103 default="base_CircularApertureFlux_9_0_instFlux",
104 )
106 def setDefaults(self):
107 super().setDefaults()
108 self.stampSize = 41
111class PsfexPsfDeterminerTask(measAlg.BasePsfDeterminerTask):
112 ConfigClass = PsfexPsfDeterminerConfig
114 def determinePsf(self, exposure, psfCandidateList, metadata=None, flagKey=None):
115 """Determine a PSFEX PSF model for an exposure given a list of PSF
116 candidates.
118 Parameters
119 ----------
120 exposure: `lsst.afw.image.Exposure`
121 Exposure containing the PSF candidates.
122 psfCandidateList: iterable of `lsst.meas.algorithms.PsfCandidate`
123 Sequence of PSF candidates typically obtained by detecting sources
124 and then running them through a star selector.
125 metadata: metadata, optional
126 A home for interesting tidbits of information.
127 flagKey: `lsst.afw.table.Key`, optional
128 Schema key used to mark sources actually used in PSF determination.
130 Returns
131 -------
132 psf: `lsst.meas.extensions.psfex.PsfexPsf`
133 The determined PSF.
134 """
136 import lsstDebug
137 display = lsstDebug.Info(__name__).display
138 displayExposure = display and \
139 lsstDebug.Info(__name__).displayExposure # display the Exposure + spatialCells
140 displayPsfComponents = display and \
141 lsstDebug.Info(__name__).displayPsfComponents # show the basis functions
142 showBadCandidates = display and \
143 lsstDebug.Info(__name__).showBadCandidates # Include bad candidates (meaningless, methinks)
144 displayResiduals = display and \
145 lsstDebug.Info(__name__).displayResiduals # show residuals
146 displayPsfMosaic = display and \
147 lsstDebug.Info(__name__).displayPsfMosaic # show mosaic of reconstructed PSF(x,y)
148 normalizeResiduals = lsstDebug.Info(__name__).normalizeResiduals
149 afwDisplay.setDefaultMaskTransparency(75)
150 # Normalise residuals by object amplitude
152 mi = exposure.getMaskedImage()
154 nCand = len(psfCandidateList)
155 if nCand == 0:
156 raise RuntimeError("No PSF candidates supplied.")
157 #
158 # How big should our PSF models be?
159 #
160 if display: # only needed for debug plots
161 # construct and populate a spatial cell set
162 bbox = mi.getBBox(afwImage.PARENT)
163 psfCellSet = afwMath.SpatialCellSet(bbox, self.config.sizeCellX, self.config.sizeCellY)
164 else:
165 psfCellSet = None
167 sizes = np.empty(nCand)
168 for i, psfCandidate in enumerate(psfCandidateList):
169 try:
170 if psfCellSet:
171 psfCellSet.insertCandidate(psfCandidate)
172 except Exception as e:
173 self.log.error("Skipping PSF candidate %d of %d: %s", i, len(psfCandidateList), e)
174 continue
176 source = psfCandidate.getSource()
177 quad = afwEll.Quadrupole(source.getIxx(), source.getIyy(), source.getIxy())
178 rmsSize = quad.getTraceRadius()
179 sizes[i] = rmsSize
181 pixKernelSize = self.config.stampSize
182 actualKernelSize = int(2*np.floor(0.5*pixKernelSize/self.config.samplingSize) + 1)
184 if display:
185 rms = np.median(sizes)
186 self.log.debug("Median PSF RMS size=%.2f pixels (\"FWHM\"=%.2f)",
187 rms, 2*np.sqrt(2*np.log(2))*rms)
189 self.log.trace("Psfex Kernel size=%.2f, Image Kernel Size=%.2f", actualKernelSize, pixKernelSize)
191 # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- BEGIN PSFEX
192 #
193 # Insert the good candidates into the set
194 #
195 defaultsFile = os.path.join(os.environ["MEAS_EXTENSIONS_PSFEX_DIR"], "config", "default-lsst.psfex")
196 args_md = dafBase.PropertySet()
197 args_md.set("BASIS_TYPE", str(self.config.psfexBasis))
198 args_md.set("PSFVAR_DEGREES", str(self.config.spatialOrder))
199 args_md.set("PSF_SIZE", str(actualKernelSize))
200 args_md.set("PSF_SAMPLING", str(self.config.samplingSize))
201 args_md.set("PHOTFLUX_KEY", str(self.config.photometricFluxField))
202 args_md.set("PHOTFLUXERR_KEY", str(self.config.photometricFluxField) + "Err")
203 prefs = psfex.Prefs(defaultsFile, args_md)
204 prefs.setCommandLine([])
205 prefs.addCatalog("psfexPsfDeterminer")
207 prefs.use()
208 principalComponentExclusionFlag = bool(bool(psfex.Context.REMOVEHIDDEN)
209 if False else psfex.Context.KEEPHIDDEN)
210 context = psfex.Context(prefs.getContextName(), prefs.getContextGroup(),
211 prefs.getGroupDeg(), principalComponentExclusionFlag)
212 psfSet = psfex.Set(context)
213 psfSet.setVigSize(pixKernelSize, pixKernelSize)
214 psfSet.setFwhm(2*np.sqrt(2*np.log(2))*np.median(sizes))
215 psfSet.setRecentroid(self.config.recentroid)
217 catindex, ext = 0, 0
218 backnoise2 = afwMath.makeStatistics(mi.getImage(), afwMath.VARIANCECLIP).getValue()
219 ccd = exposure.getDetector()
220 if ccd:
221 gain = np.mean(np.array([a.getGain() for a in ccd]))
222 else:
223 gain = 1.0
224 self.log.warning("Setting gain to %g", gain)
226 contextvalp = []
227 for i, key in enumerate(context.getName()):
228 if key[0] == ':':
229 try:
230 contextvalp.append(exposure.getMetadata().getScalar(key[1:]))
231 except KeyError as e:
232 raise RuntimeError("%s parameter not found in the header of %s" %
233 (key[1:], prefs.getContextName())) from e
234 else:
235 try:
236 contextvalp.append(np.array([psfCandidateList[_].getSource().get(key)
237 for _ in range(nCand)]))
238 except KeyError as e:
239 raise RuntimeError("%s parameter not found" % (key,)) from e
240 psfSet.setContextname(i, key)
242 if display:
243 frame = 0
244 if displayExposure:
245 disp = afwDisplay.Display(frame=frame)
246 disp.mtv(exposure, title="psf determination")
248 badBits = mi.getMask().getPlaneBitMask(self.config.badMaskBits)
249 fluxName = prefs.getPhotfluxRkey()
250 fluxFlagName = "base_" + fluxName + "_flag"
252 xpos, ypos = [], []
253 for i, psfCandidate in enumerate(psfCandidateList):
254 source = psfCandidate.getSource()
256 # skip sources with bad centroids
257 xc, yc = source.getX(), source.getY()
258 if not np.isfinite(xc) or not np.isfinite(yc):
259 continue
260 # skip flagged sources
261 if fluxFlagName in source.schema and source.get(fluxFlagName):
262 continue
263 # skip nonfinite and negative sources
264 flux = source.get(fluxName)
265 if flux < 0 or not np.isfinite(flux):
266 continue
268 try:
269 pstamp = psfCandidate.getMaskedImage(pixKernelSize, pixKernelSize).clone()
270 except pexExcept.LengthError:
271 self.log.warning("Could not get stamp image for psfCandidate: %s with kernel size: %s",
272 psfCandidate, pixKernelSize)
273 continue
275 # From this point, we're configuring the "sample" (PSFEx's version
276 # of a PSF candidate).
277 # Having created the sample, we must proceed to configure it, and
278 # then fini (finalize), or it will be malformed.
279 try:
280 sample = psfSet.newSample()
281 sample.setCatindex(catindex)
282 sample.setExtindex(ext)
283 sample.setObjindex(i)
285 imArray = pstamp.getImage().getArray()
286 imArray[np.where(np.bitwise_and(pstamp.getMask().getArray(), badBits))] = \
287 -2*psfex.BIG
288 sample.setVig(imArray)
290 sample.setNorm(flux)
291 sample.setBacknoise2(backnoise2)
292 sample.setGain(gain)
293 sample.setX(xc)
294 sample.setY(yc)
295 sample.setFluxrad(sizes[i])
297 for j in range(psfSet.getNcontext()):
298 sample.setContext(j, float(contextvalp[j][i]))
299 except Exception as e:
300 self.log.error("Exception when processing sample at (%f,%f): %s", xc, yc, e)
301 continue
302 else:
303 psfSet.finiSample(sample)
305 xpos.append(xc) # for QA
306 ypos.append(yc)
308 if displayExposure:
309 with disp.Buffering():
310 disp.dot("o", xc, yc, ctype=afwDisplay.CYAN, size=4)
312 if psfSet.getNsample() == 0:
313 raise RuntimeError("No good PSF candidates to pass to PSFEx")
315 # ---- Update min and max and then the scaling
316 for i in range(psfSet.getNcontext()):
317 cmin = contextvalp[i].min()
318 cmax = contextvalp[i].max()
319 psfSet.setContextScale(i, cmax - cmin)
320 psfSet.setContextOffset(i, (cmin + cmax)/2.0)
322 # Don't waste memory!
323 psfSet.trimMemory()
325 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- END PSFEX
326 #
327 # Do a PSFEX decomposition of those PSF candidates
328 #
329 fields = []
330 field = psfex.Field("Unknown")
331 field.addExt(exposure.getWcs(), exposure.getWidth(), exposure.getHeight(), psfSet.getNsample())
332 field.finalize()
334 fields.append(field)
336 sets = []
337 sets.append(psfSet)
339 psfex.makeit(fields, sets)
340 psfs = field.getPsfs()
342 # Flag which objects were actually used in psfex by
343 good_indices = []
344 for i in range(sets[0].getNsample()):
345 index = sets[0].getSample(i).getObjindex()
346 if index > -1:
347 good_indices.append(index)
349 if flagKey is not None:
350 for i, psfCandidate in enumerate(psfCandidateList):
351 source = psfCandidate.getSource()
352 if i in good_indices:
353 source.set(flagKey, True)
355 xpos = np.array(xpos)
356 ypos = np.array(ypos)
357 numGoodStars = len(good_indices)
358 avgX, avgY = np.mean(xpos), np.mean(ypos)
360 psf = psfex.PsfexPsf(psfs[0], geom.Point2D(avgX, avgY))
362 # If there are too few stars, the PSFEx psf model will reduce the order
363 # to 0, which the Science Pipelines code cannot handle (see
364 # https://github.com/lsst/meas_extensions_psfex/blob/f0d5218b5446faf5e39edc30e31d2e6f673ef294/src/PsfexPsf.cc#L118
365 # ). The easiest way to test for this condition is trying to compute
366 # the PSF kernel and checking for an InvalidParameterError.
367 try:
368 _ = psf.getKernel(psf.getAveragePosition())
369 except pexExcept.InvalidParameterError:
370 raise RuntimeError("Failed to determine psfex psf: too few good stars.")
372 #
373 # Display code for debugging
374 #
375 if display:
376 assert psfCellSet is not None
378 if displayExposure:
379 maUtils.showPsfSpatialCells(exposure, psfCellSet, showChi2=True,
380 symb="o", ctype=afwDisplay.YELLOW, ctypeBad=afwDisplay.RED,
381 size=8, display=disp)
382 if displayResiduals:
383 disp4 = afwDisplay.Display(frame=4)
384 maUtils.showPsfCandidates(exposure, psfCellSet, psf=psf, display=disp4,
385 normalize=normalizeResiduals,
386 showBadCandidates=showBadCandidates)
387 if displayPsfComponents:
388 disp6 = afwDisplay.Display(frame=6)
389 maUtils.showPsf(psf, display=disp6)
390 if displayPsfMosaic:
391 disp7 = afwDisplay.Display(frame=7)
392 maUtils.showPsfMosaic(exposure, psf, display=disp7, showFwhm=True)
393 disp.scale('linear', 0, 1)
394 #
395 # Generate some QA information
396 #
397 # Count PSF stars
398 #
399 if metadata is not None:
400 metadata["spatialFitChi2"] = np.nan
401 metadata["numAvailStars"] = nCand
402 metadata["numGoodStars"] = numGoodStars
403 metadata["avgX"] = avgX
404 metadata["avgY"] = avgY
406 return psf, psfCellSet
409measAlg.psfDeterminerRegistry.register("psfex", PsfexPsfDeterminerTask)