Coverage for python/lsst/meas/extensions/convolved/convolved.py: 31%
156 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-07 18:32 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-07 18:32 +0000
1#
2# LSST Data Management System
3# Copyright 2008-2016 AURA/LSST.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <http://www.lsstcorp.org/LegalNotices/>.
21#
23import math
24import numpy as np
26from lsst.pex.config import Config, Field, ListField, ConfigField, makeConfigClass
27from lsst.pipe.base import Struct
28from lsst.meas.extensions.photometryKron import KronAperture, KronFluxPlugin
29from lsst.meas.base.wrappers import WrappedSingleFramePlugin, WrappedForcedPlugin
31import lsst.meas.base
32import lsst.afw.math
33import lsst.afw.image
34import lsst.geom
35from lsst.afw.geom.skyWcs import makeWcsPairTransform
37__all__ = ("SingleFrameConvolvedFluxPlugin", "SingleFrameConvolvedFluxConfig",
38 "ForcedConvolvedFluxPlugin", "ForcedConvolvedFluxConfig",)
41SIGMA_TO_FWHM = 2.0*math.sqrt(2.0*(math.log(2.0))) # Multiply sigma by this to get FWHM
42PLUGIN_NAME = "ext_convolved_ConvolvedFlux" # Usual name for plugin
45class DeconvolutionError(RuntimeError):
46 """Convolving to the target seeing would require deconvolution"""
47 pass
50ApertureFluxConfig = makeConfigClass(lsst.meas.base.ApertureFluxControl)
53class ConvolvedFluxData(Struct):
54 """A `lsst.pipe.base.Struct` for convolved fluxes
56 Attributes
57 ----------
58 deconvKey : `lsst.afw.table.Key_Flag`
59 Key to set flag indicating no measurement was made due to the need to deconvolve
60 aperture : `lsst.meas.base.CircularApertureFluxAlgorithm`
61 Measurement algorithm to perform aperture flux measurements
62 kronKeys : `lsst.pipe.base.Struct`
63 Container for Kron results or `None` if no Kron radius is available; when set,
64 includes `result` (`lsst.meas.base.FluxResultKey`: keys to set results from Kron
65 flux measurement) and `flag` (`lsst.afw.table.Key_Flag`: key to set failure flag
66 for Kron measurement).
67 """
69 def __init__(self, name, schema, seeing, config, metadata):
70 deconvKey = schema.addField(name + "_deconv", type="Flag",
71 doc="deconvolution required for seeing %f; no measurement made" %
72 (seeing,))
73 aperture = lsst.meas.base.CircularApertureFluxAlgorithm(config.aperture.makeControl(), name,
74 schema, metadata)
75 kronKeys = Struct(
76 result=lsst.meas.base.FluxResultKey.addFields(schema, name + "_kron",
77 doc="convolved Kron flux: seeing %f" % (seeing,)),
78 flag=schema.addField(name + "_kron_flag", type="Flag",
79 doc="convolved Kron flux failed: seeing %f" % (seeing,)),
80 )
81 Struct.__init__(self, deconvKey=deconvKey, aperture=aperture, kronKeys=kronKeys)
84class BaseConvolvedFluxConfig(Config):
85 # convolution
86 seeing = ListField(dtype=float, default=[3.5, 5.0, 6.5], doc="list of target seeings (FWHM, pixels)")
87 kernelScale = Field(dtype=float, default=4.0, doc="scaling factor of kernel sigma for kernel size")
88 # aperture flux
89 aperture = ConfigField(dtype=ApertureFluxConfig, doc="Aperture photometry parameters")
90 # Kron flux
91 kronRadiusName = Field(dtype=str, default="ext_photometryKron_KronFlux_radius",
92 doc="name of Kron radius field in reference")
93 maxSincRadius = Field(dtype=float, default=10.0,
94 doc="Largest aperture for which to use the sinc aperture code for Kron (pixels)")
95 kronRadiusForFlux = Field(dtype=float, default=2.5, doc="Number of Kron radii for Kron flux")
96 registerForApCorr = Field(dtype=bool, default=True,
97 doc="Register measurements for aperture correction?\n"
98 "The aperture correction registration is done when the plugin is\n"
99 "instantiated because the column names are derived from the configuration\n"
100 "rather than being static. Sometimes you want to turn this off, e.g.,\n"
101 "when you will use aperture corrections derived from somewhere else\n"
102 "through the 'proxy' mechanism.")
104 def setDefaults(self):
105 Config.setDefaults(self)
106 # Don't need the full set of apertures because the larger ones aren't affected by the convolution
107 self.aperture.radii = [3.3, 4.5, 6.0]
109 def getBaseNameForSeeing(self, seeing, name=PLUGIN_NAME):
110 """Return base name for measurement, given seeing
112 Parameters
113 ----------
114 seeing : `float`
115 The seeing value; it is required that the `ConvolvedFluxConfig.seeing` list
116 include this value.
117 name : `str`, optional
118 The name of the plugin.
120 Returns
121 -------
122 baseName : `str`
123 Base name for measurement with nominated seeing.
124 """
125 indices = [ii for ii, target in enumerate(self.seeing) if seeing == target]
126 if len(indices) != 1:
127 raise RuntimeError("Unable to uniquely identify index for seeing %f: %s" % (seeing, indices))
128 return name + "_%d" % (indices[0],)
130 def getApertureResultName(self, seeing, radius, name=PLUGIN_NAME):
131 """Return name for aperture measurement result
133 Parameters
134 ----------
135 seeing : `float`
136 The seeing value; it is required that the `ConvolvedFluxConfig.seeing` list
137 include this value.
138 radius : `float`
139 The aperture radius. If this doesn't correspond to a value in the
140 `ConvolvedFluxConfig.aperture.radii` then the returned name may not be useful.
141 name : `str`, optional
142 The name of the plugin.
144 Returns
145 -------
146 resultName : `str`
147 Result name for aperture measurement with nominated seeing and radius.
148 """
149 baseName = self.getBaseNameForSeeing(seeing, name=name)
150 return lsst.meas.base.CircularApertureFluxAlgorithm.makeFieldPrefix(baseName, radius)
152 def getKronResultName(self, seeing, name=PLUGIN_NAME):
153 """Return name for Kron measurement result
155 Parameters
156 ----------
157 seeing : `float`
158 The seeing value; it is required that the `ConvolvedFluxConfig.seeing` list
159 include this value.
160 name : `str`, optional
161 The name of the plugin.
163 Returns
164 -------
165 resultName : `str`
166 Result name for Kron measurement with nominated seeing.
167 """
168 return self.getBaseNameForSeeing(seeing, name=name) + "_kron"
170 def getAllApertureResultNames(self, name=PLUGIN_NAME):
171 """Return all names for aperture measurements
173 Parameters
174 ----------
175 name : `str`, optional
176 The name of the plugin.
178 Returns
179 -------
180 results : `list` of `str`
181 List of names for aperture measurements (for all seeings)
182 """
183 return [lsst.meas.base.CircularApertureFluxAlgorithm.makeFieldPrefix(seeingName, radius) for
184 seeingName in [name + "_%d" % (ii,) for ii in range(len(self.seeing))] for
185 radius in self.aperture.radii]
187 def getAllKronResultNames(self, name=PLUGIN_NAME):
188 """Return all names for Kron measurements
190 Parameters
191 ----------
192 name : `str`, optional
193 The name of the plugin.
195 Returns
196 -------
197 results : `list` of `str`
198 List of names for Kron measurements (for all seeings)
199 """
200 return [name + "_%d_kron" % (ii,) for ii in range(len(self.seeing))]
202 def getAllResultNames(self, name=PLUGIN_NAME):
203 """Return all names for measurements
205 Parameters
206 ----------
207 name : `str`, optional
208 The name of the plugin.
210 Returns
211 -------
212 results : `list` of `str`
213 List of names for measurements (for all seeings and apertures and Kron)
214 """
215 return self.getAllApertureResultNames(name=name) + self.getAllKronResultNames(name=name)
218class BaseConvolvedFluxPlugin(lsst.meas.base.BaseMeasurementPlugin):
219 """Calculate aperture fluxes on images convolved to target seeing.
221 This measurement plugin convolves the image to match a target seeing
222 and measures fluxes within circular apertures and within the Kron
223 aperture (defined as a multiple of the Kron radius which is already
224 available in the catalog).
226 Throughout, we assume a Gaussian PSF to simplify and optimise the
227 convolution for speed. The results are therefore not exact, but should
228 be good enough to be useful.
230 The measurements produced by this plugin are useful for:
231 * Fiber mags: the flux within a circular aperture in a particular seeing
232 can be used to calibrate fiber-fed spectroscopic observations.
233 * Galaxy photometry: the flux within an aperture in common seeing can
234 be used to measure good colors for an object without assuming a model.
236 The error handling is a bit different from most measurement plugins (which
237 are content to fail anywhere and have the entire algorithm flagged as having
238 failed), because we have multiple components (circular apertures and Kron)
239 and we don't want the whole to fail because one component failed. Therefore,
240 there's a few more try/except blocks than might be otherwise expected.
241 """
243 @classmethod
244 def getExecutionOrder(cls):
245 return KronFluxPlugin.getExecutionOrder() + 0.1 # Should run after Kron because we need the radius
247 def __init__(self, config, name, schema, metadata):
248 """Ctor
250 Parameters
251 ----------
252 config : `ConvolvedFluxConfig`
253 Configuration for plugin.
254 name : `str`
255 Name of plugin (used as prefix for columns in schema).
256 schema : `lsst.afw.table.Schema`
257 Catalog schema.
258 metadata : `lsst.daf.base.PropertyList`
259 Algorithm metadata to be recorded in the catalog header.
260 """
261 lsst.meas.base.BaseMeasurementPlugin.__init__(self, config, name)
262 self.seeingKey = schema.addField(name + "_seeing", type="F",
263 doc="original seeing (Gaussian sigma) at position",
264 units="pixel")
265 self.data = [ConvolvedFluxData(self.config.getBaseNameForSeeing(seeing, name=name), schema, seeing,
266 self.config, metadata) for seeing in self.config.seeing]
268 flagDefs = lsst.meas.base.FlagDefinitionList()
269 flagDefs.addFailureFlag("error in running ConvolvedFluxPlugin")
270 self.flagHandler = lsst.meas.base.FlagHandler.addFields(schema, name, flagDefs)
271 if self.config.registerForApCorr:
272 # Trigger aperture corrections for all flux measurements
273 for apName in self.config.getAllApertureResultNames(name):
274 lsst.meas.base.addApCorrName(apName)
275 for kronName in self.config.getAllKronResultNames(name):
276 lsst.meas.base.addApCorrName(kronName)
278 self.centroidExtractor = lsst.meas.base.SafeCentroidExtractor(schema, name)
280 def measure(self, measRecord, exposure):
281 """Measure source on image
283 Parameters
284 ----------
285 measRecord : `lsst.afw.table.SourceRecord`
286 Record for source to be measured.
287 exposure : `lsst.afw.image.Exposure`
288 Image to be measured.
289 """
290 return self.measureForced(measRecord, exposure, measRecord, None)
292 def measureForced(self, measRecord, exposure, refRecord, refWcs):
293 """Measure source on image in forced mode
295 Parameters
296 ----------
297 measRecord : `lsst.afw.table.SourceRecord`
298 Record for source to be measured.
299 exposure : `lsst.afw.image.Exposure`
300 Image to be measured.
301 refRecord : `lsst.afw.table.SourceRecord`
302 Record providing reference position and aperture.
303 refWcs : `lsst.afw.geom.SkyWcs` or `None`
304 Astrometric solution for reference, or `None` for no conversion
305 from reference to measurement frame.
306 """
307 psf = exposure.getPsf()
308 if psf is None:
309 raise lsst.meas.base.MeasurementError("No PSF in exposure")
311 refCenter = self.centroidExtractor(refRecord, self.flagHandler)
313 if refWcs is not None:
314 measWcs = exposure.getWcs()
315 if measWcs is None:
316 raise lsst.meas.base.MeasurementError("No WCS in exposure")
317 fullTransform = makeWcsPairTransform(refWcs, measWcs)
318 transform = lsst.afw.geom.linearizeTransform(fullTransform, refCenter)
319 else:
320 transform = lsst.geom.AffineTransform()
322 kron = self.getKronAperture(refRecord, transform)
324 center = refCenter if transform is None else transform(refCenter)
325 seeing = psf.computeShape(center).getDeterminantRadius()
326 measRecord.set(self.seeingKey, seeing)
328 maxRadius = self.getMaxRadius(kron)
329 for ii, target in enumerate(self.config.seeing):
330 try:
331 convolved = self.convolve(exposure, seeing, target/SIGMA_TO_FWHM, measRecord.getFootprint(),
332 maxRadius)
333 except (DeconvolutionError, RuntimeError):
334 # Record the problem, but allow the measurement to run in case it's useful
335 measRecord.set(self.data[ii].deconvKey, True)
336 convolved = exposure
337 self.measureAperture(measRecord, convolved, self.data[ii].aperture)
338 if kron is not None:
339 self.measureForcedKron(measRecord, self.data[ii].kronKeys, convolved.getMaskedImage(), kron)
341 def fail(self, measRecord, error=None):
342 """Record failure
344 Called by the measurement framework when it catches an exception.
346 Parameters
347 ----------
348 measRecord : `lsst.afw.table.SourceRecord`
349 Record for source on which measurement failed.
350 error : `Exception`, optional
351 Error that occurred, or None.
352 """
353 self.flagHandler.handleFailure(measRecord)
355 def getKronAperture(self, refRecord, transform):
356 """Determine the Kron radius
358 Because we need to know the size of the area beforehand (we don't want to convolve
359 the entire image just for this source), we are not measuring an independent Kron
360 radius, but using the Kron radius that's already provided in the `refRecord` as
361 `ConvolvedFluxConfig.kronRadiusName`.
363 Parameters
364 ----------
365 refRecord : `lsst.afw.table.SourceRecord`
366 Record for source defining Kron aperture.
367 transform : `lsst.geom.AffineTransform`
368 Transformation to apply to reference aperture.
370 Returns
371 -------
372 aperture : `lsst.meas.extensions.photometryKron.KronAperture`
373 Kron aperture.
374 """
375 try:
376 radius = refRecord.get(self.config.kronRadiusName)
377 except KeyError:
378 return None
379 if not np.isfinite(radius):
380 return None
381 return KronAperture(refRecord, transform, radius)
383 def getMaxRadius(self, kron):
384 """Determine the maximum radius we care about
386 Because we don't want to convolve the entire image just for this source,
387 we determine the maximum radius we care about for this source and will
388 convolve only that.
390 Parameters
391 ----------
392 kron : `lsst.meas.extensions.photometryKron.KronAperture` or `None`
393 Kron aperture, or `None` if unavailable.
395 Returns
396 -------
397 maxRadius : `int`
398 Maximum radius of interest.
399 """
400 kronRadius = kron.getAxes().getDeterminantRadius() if kron is not None else 0.0
401 return int(max(max(self.config.aperture.radii), self.config.kronRadiusForFlux*kronRadius) + 0.5)
403 def convolve(self, exposure, seeing, target, footprint, maxRadius):
404 """Convolve image around source to target seeing
406 We also record the original seeing at the source position.
408 Because we don't want to convolve the entire image just for this source,
409 we cut out an area corresponding to the source's footprint, grown by the
410 radius provided by `maxRadius`.
412 We assume a Gaussian PSF to simplify and speed the convolution.
413 The `seeing` and `target` may be either Gaussian sigma or FWHM, so long
414 as they are the same.
416 Parameters
417 ----------
418 exposure : `lsst.afw.image.Exposure`
419 Image to convolve.
420 seeing : `float`
421 Current seeing, pixels.
422 target : `float`
423 Desired target seeing, pixels.
424 footprint : `lsst.afw.detection.Footprint`
425 Footprint for source.
426 maxRadius : `int`
427 Maximum radius required by measurement algorithms.
429 Returns
430 -------
431 convExp : `lsst.afw.image.Exposure`
432 Sub-image containing the source, convolved to the target seeing.
434 Raises
435 ------
436 DeconvolutionError
437 If the target seeing requires deconvolution.
438 RuntimeError
439 If the bounding box is too small after clipping.
440 """
442 if target < seeing:
443 raise DeconvolutionError("Target seeing requires deconvolution")
444 kernelSigma = math.sqrt(target*target - seeing*seeing)
445 kernelRadius = int(self.config.kernelScale*kernelSigma + 0.5)
446 kernelWidth = 2*kernelRadius + 1
447 gauss = lsst.afw.math.GaussianFunction1D(kernelSigma)
448 kernel = lsst.afw.math.SeparableKernel(kernelWidth, kernelWidth, gauss, gauss)
450 bbox = footprint.getBBox()
451 bbox.grow(kernelRadius + maxRadius) # add an extra buffer?
452 bbox.clip(exposure.getBBox())
453 if bbox.getWidth() < kernelWidth or bbox.getHeight() < kernelWidth:
454 raise RuntimeError("Bounding box is too small following clipping")
456 image = exposure.getMaskedImage()
457 subImage = image.Factory(image, bbox)
458 convolved = image.Factory(bbox)
459 lsst.afw.math.convolve(convolved, subImage, kernel, lsst.afw.math.ConvolutionControl(True, True))
461 convExp = lsst.afw.image.makeExposure(convolved)
462 convExp.setInfo(lsst.afw.image.ExposureInfo(exposure.getInfo()))
464 return convExp
466 def measureAperture(self, measRecord, exposure, aperturePhot):
467 """Perform aperture photometry
469 Parameters
470 ----------
471 measRecord : `lsst.afw.table.SourceRecord`
472 Record for source to be measured.
473 exposure : `lsst.afw.image.Exposure`
474 Image to be measured.
475 aperturePhot : `lsst.meas.base.CircularApertureFluxAlgorithm`
476 Measurement plugin that will do the measurement.
477 """
478 try:
479 aperturePhot.measure(measRecord, exposure)
480 except Exception:
481 aperturePhot.fail(measRecord)
483 def measureForcedKron(self, measRecord, keys, image, aperture):
484 """Measure forced Kron
486 Because we need to know the size of the area beforehand (we don't want to convolve
487 the entire image just for this source), we are doing forced measurement using the
488 Kron radius previously determined.
490 Parameters
491 ----------
492 measRecord : `lsst.afw.table.SourceRecord`
493 Record for source to be measured.
494 keys : `lsst.pipe.base.Struct`
495 Struct containing `result` (`lsst.meas.base.FluxResult`) and
496 `flag` (`lsst.afw.table.Key_Flag`); provided by
497 `ConvolvedFluxData.kronKeys`.
498 image : `lsst.afw.image.MaskedImage`
499 Image to be measured.
500 aperture : `lsst.meas.extensions.photometryKron.KronAperture`
501 Kron aperture to measure.
502 """
503 measRecord.set(keys.flag, True) # failed unless we survive to switch this back
504 if aperture is None:
505 return # We've already flagged it, so just bail
506 try:
507 flux = aperture.measureFlux(image, self.config.kronRadiusForFlux, self.config.maxSincRadius)
508 except Exception:
509 return # We've already flagged it, so just bail
510 measRecord.set(keys.result.getInstFlux(), flux[0])
511 measRecord.set(keys.result.getInstFluxErr(), flux[1])
512 measRecord.setFlag(keys.flag, bool(np.any(~np.isfinite(flux))))
515def wrapPlugin(Base, PluginClass=BaseConvolvedFluxPlugin, ConfigClass=BaseConvolvedFluxConfig,
516 name=PLUGIN_NAME, factory=BaseConvolvedFluxPlugin):
517 """Wrap plugin for use
519 A plugin has to inherit from a specific base class in order to be used
520 in a particular context (e.g., single frame vs forced measurement).
522 Parameters
523 ----------
524 Base : `type`
525 Base class to give the plugin.
526 PluginClass : `type`
527 Plugin class to wrap.
528 ConfigClass : `type`
529 Configuration class; should subclass `lsst.pex.config.Config`.
530 name : `str`
531 Name of plugin.
532 factory : callable
533 Callable to create an instance of the `PluginClass`.
535 Returns
536 -------
537 WrappedPlugin : `type`
538 The wrapped plugin class (subclass of `Base`).
539 WrappedConfig : `type`
540 The wrapped plugin configuration (subclass of `Base.ConfigClass`).
541 """
542 WrappedConfig = type("ConvolvedFlux" + Base.ConfigClass.__name__, (Base.ConfigClass, ConfigClass), {})
543 typeDict = dict(AlgClass=PluginClass, ConfigClass=WrappedConfig, factory=factory,
544 getExecutionOrder=PluginClass.getExecutionOrder)
545 WrappedPlugin = type("ConvolvedFlux" + Base.__name__, (Base,), typeDict)
546 Base.registry.register(name, WrappedPlugin)
547 return WrappedPlugin, WrappedConfig
550def wrapPluginForced(Base, PluginClass=BaseConvolvedFluxPlugin, ConfigClass=BaseConvolvedFluxConfig,
551 name=PLUGIN_NAME, factory=BaseConvolvedFluxPlugin):
552 """Wrap plugin for use in forced measurement
554 A version of `wrapPlugin` that generates a `factory` suitable for
555 forced measurement. This is important because the required signature
556 for the factory in forced measurement includes a 'schemaMapper' instead
557 of a 'schema'.
559 Parameters
560 ----------
561 Base : `type`
562 Base class to give the plugin.
563 PluginClass : `type`
564 Plugin class to wrap.
565 ConfigClass : `type`
566 Configuration class; should subclass `lsst.pex.config.Config`.
567 name : `str`
568 Name of plugin.
569 factory : callable
570 Callable to create an instance of the `PluginClass`.
572 Returns
573 -------
574 WrappedPlugin : `type`
575 The wrapped plugin class (subclass of `Base`).
576 WrappedConfig : `type`
577 The wrapped plugin configuration (subclass of `Base.ConfigClass`).
578 """
580 def forcedPluginFactory(name, config, schemaMapper, metadata):
581 return factory(name, config, schemaMapper.editOutputSchema(), metadata)
582 return wrapPlugin(Base, PluginClass=PluginClass, ConfigClass=ConfigClass, name=name,
583 factory=staticmethod(forcedPluginFactory))
586SingleFrameConvolvedFluxPlugin, SingleFrameConvolvedFluxConfig = wrapPlugin(WrappedSingleFramePlugin)
587ForcedConvolvedFluxPlugin, ForcedConvolvedFluxConfig = wrapPluginForced(WrappedForcedPlugin)