30from lsst.ip.diffim.utils import getPsfFwhm, angleMean, evaluateMaskFraction, getKernelCenterDisplacement
31from lsst.meas.algorithms
import SkyObjectsTask
33from lsst.utils.timer
import timeMethod
37__all__ = [
"SpatiallySampledMetricsConfig",
"SpatiallySampledMetricsTask"]
41 dimensions=(
"instrument",
"visit",
"detector"),
42 defaultTemplates={
"coaddName":
"deep",
45 science = pipeBase.connectionTypes.Input(
46 doc=
"Input science exposure.",
47 dimensions=(
"instrument",
"visit",
"detector"),
48 storageClass=
"ExposureF",
49 name=
"{fakesType}calexp"
51 template = pipeBase.connectionTypes.Input(
52 doc=
"Warped and not PSF-matched template used to create the difference image.",
53 dimensions=(
"instrument",
"visit",
"detector"),
54 storageClass=
"ExposureF",
55 name=
"{fakesType}{coaddName}Diff_templateExp",
57 difference = pipeBase.connectionTypes.Input(
58 doc=
"Difference image with detection mask plane filled in.",
59 dimensions=(
"instrument",
"visit",
"detector"),
60 storageClass=
"ExposureF",
61 name=
"{fakesType}{coaddName}Diff_differenceExp",
63 diaSources = pipeBase.connectionTypes.Input(
64 doc=
"Filtered diaSources on the difference image.",
65 dimensions=(
"instrument",
"visit",
"detector"),
66 storageClass=
"ArrowAstropy",
67 name=
"{fakesType}dia_source_detector",
69 psfMatchingKernel = pipeBase.connectionTypes.Input(
70 doc=
"Kernel used to PSF match the science and template images.",
71 dimensions=(
"instrument",
"visit",
"detector"),
72 storageClass=
"MatchingKernel",
73 name=
"{fakesType}{coaddName}Diff_psfMatchKernel",
75 spatiallySampledMetrics = pipeBase.connectionTypes.Output(
76 doc=
"Summary metrics computed at randomized locations.",
77 dimensions=(
"instrument",
"visit",
"detector"),
78 storageClass=
"ArrowAstropy",
79 name=
"{fakesType}{coaddName}Diff_spatiallySampledMetrics",
83class SpatiallySampledMetricsConfig(pipeBase.PipelineTaskConfig,
84 pipelineConnections=SpatiallySampledMetricsConnections):
85 """Config for SpatiallySampledMetricsTask
87 metricsMaskPlanes = lsst.pex.config.ListField(
89 doc=
"List of mask planes to include in metrics",
90 default=(
'BAD',
'CLIPPED',
'CR',
'DETECTED',
'DETECTED_NEGATIVE',
'EDGE',
91 'INEXACT_PSF',
'INJECTED',
'INJECTED_TEMPLATE',
'INTRP',
'NOT_DEBLENDED',
92 'NO_DATA',
'REJECTED',
'SAT',
'SAT_TEMPLATE',
'SENSOR_EDGE',
'STREAK',
'SUSPECT',
96 metricSources = pexConfig.ConfigurableField(
97 target=SkyObjectsTask,
98 doc=
"Generate QA metric sources",
101 def setDefaults(self):
102 self.metricSources.avoidMask = [
"NO_DATA",
"EDGE"]
105class SpatiallySampledMetricsTask(lsst.pipe.base.PipelineTask):
106 """Detect and measure sources on a difference image.
108 ConfigClass = SpatiallySampledMetricsConfig
109 _DefaultName =
"spatiallySampledMetrics"
111 def __init__(self, **kwargs):
112 super().__init__(**kwargs)
114 self.makeSubtask(
"metricSources")
115 self.schema = afwTable.SourceTable.makeMinimalSchema()
116 self.schema.addField(
118 "X location of the metric evaluation.",
120 self.schema.addField(
122 "Y location of the metric evaluation.",
124 self.metricSources.skySourceKey = self.schema.addField(
"sky_source", type=
"Flag",
125 doc=
"Metric evaluation objects.")
126 self.schema.addField(
127 "source_density",
"F",
128 "Density of diaSources at location.",
129 units=
"count/degree^2")
130 self.schema.addField(
131 "dipole_density",
"F",
132 "Density of dipoles at location.",
133 units=
"count/degree^2")
134 self.schema.addField(
135 "dipole_direction",
"F",
136 "Mean dipole orientation.",
138 self.schema.addField(
139 "dipole_separation",
"F",
140 "Mean dipole separation.",
142 self.schema.addField(
143 "template_value",
"F",
144 "Median of template at location.",
146 self.schema.addField(
147 "science_value",
"F",
148 "Median of science at location.",
150 self.schema.addField(
152 "Median of diffim at location.",
154 self.schema.addField(
155 "science_psfSize",
"F",
156 "Width of the science image PSF at location.",
158 self.schema.addField(
159 "template_psfSize",
"F",
160 "Width of the template image PSF at location.",
162 for maskPlane
in self.config.metricsMaskPlanes:
163 self.schema.addField(
164 "%s_mask_fraction"%maskPlane.lower(),
"F",
165 "Fraction of pixels with %s mask"%maskPlane
167 self.schema.addField(
168 "psfMatchingKernel_sum",
"F",
169 "PSF matching kernel sum at location.")
170 self.schema.addField(
171 "psfMatchingKernel_dx",
"F",
172 "PSF matching kernel centroid offset in x at location.",
174 self.schema.addField(
175 "psfMatchingKernel_dy",
"F",
176 "PSF matching kernel centroid offset in y at location.",
178 self.schema.addField(
179 "psfMatchingKernel_length",
"F",
180 "PSF matching kernel centroid offset module.",
182 self.schema.addField(
183 "psfMatchingKernel_position_angle",
"F",
184 "PSF matching kernel centroid offset position angle.",
186 self.schema.addField(
187 "psfMatchingKernel_direction",
"F",
188 "PSF matching kernel centroid offset direction in detector plane.",
192 def run(self, science, template, difference, diaSources, psfMatchingKernel):
193 """Calculate difference image metrics on specific locations across the images
197 science : `lsst.afw.image.ExposureF`
198 Science exposure that the template was subtracted from.
199 template : `lsst.afw.image.ExposureF`
200 Warped and non PSF-matched template that was used produce
201 the difference image.
202 difference : `lsst.afw.image.ExposureF`
203 Result of subtracting template from the science image.
204 diaSources : `lsst.afw.table.SourceCatalog`
205 The catalog of detected sources.
206 psfMatchingKernel : `~lsst.afw.math.LinearCombinationKernel`
207 The PSF matching kernel of the subtraction to evaluate.
211 results : `lsst.pipe.base.Struct`
212 ``spatiallySampledMetrics`` : `astropy.table.Table`
213 Image quality metrics spatially sampled locations.
216 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
218 spatiallySampledMetrics = afwTable.SourceCatalog(self.schema)
219 spatiallySampledMetrics.getTable().setIdFactory(idFactory)
221 self.metricSources.
run(mask=science.mask, seed=difference.info.id, catalog=spatiallySampledMetrics)
223 metricsMaskPlanes = []
224 for maskPlane
in self.config.metricsMaskPlanes:
226 metricsMaskPlanes.append(maskPlane)
227 except InvalidParameterError:
228 self.log.info(
"Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
230 for src
in spatiallySampledMetrics:
231 self._evaluateLocalMetric(src, science, template, difference, diaSources,
232 metricsMaskPlanes=metricsMaskPlanes,
233 psfMatchingKernel=psfMatchingKernel)
235 return pipeBase.Struct(spatiallySampledMetrics=spatiallySampledMetrics.asAstropy())
237 def _evaluateLocalMetric(self, src, science, template, difference, diaSources,
238 metricsMaskPlanes, psfMatchingKernel):
239 """Calculate image quality metrics at spatially sampled locations.
243 src : `lsst.afw.table.SourceRecord`
244 The source record to be updated with metric calculations.
245 diaSources : `lsst.afw.table.SourceCatalog`
246 The catalog of detected sources.
247 science : `lsst.afw.image.Exposure`
249 difference : `lsst.afw.image.Exposure`
250 Result of subtracting template from the science image.
251 metricsMaskPlanes : `list` of `str`
252 Mask planes to calculate metrics from.
253 psfMatchingKernel : `~lsst.afw.math.LinearCombinationKernel`
254 The PSF matching kernel of the subtraction to evaluate.
256 bbox = src.getFootprint().getBBox()
257 pix = bbox.getCenter()
258 src.set(
'science_psfSize', getPsfFwhm(science.psf, position=pix))
260 src.set(
'template_psfSize', getPsfFwhm(template.psf, position=pix))
261 except (InvalidParameterError, RangeError):
262 src.set(
'template_psfSize', np.nan)
264 metricRegionSize = 100
265 bbox.grow(metricRegionSize)
266 bbox = bbox.clippedTo(science.getBBox())
267 nPix = bbox.getArea()
268 pixScale = science.wcs.getPixelScale(bbox.getCenter())
269 area = nPix*pixScale.asDegrees()**2
270 peak = src.getFootprint().getPeaks()[0]
271 src.set(
'x', peak[
'i_x'])
272 src.set(
'y', peak[
'i_y'])
273 src.setCoord(science.wcs.pixelToSky(peak[
'i_x'], peak[
'i_y']))
274 selectSources = diaSources[bbox.contains(diaSources[
'x'], diaSources[
'y'])]
275 sourceDensity = len(selectSources)/area
276 dipoleSources = selectSources[selectSources[
"isDipole"]]
277 dipoleDensity = len(dipoleSources)/area
280 meanDipoleOrientation = angleMean(dipoleSources[
"dipoleAngle"])
281 src.set(
'dipole_direction', meanDipoleOrientation)
282 meanDipoleSeparation = np.mean(dipoleSources[
"dipoleLength"])
283 src.set(
'dipole_separation', meanDipoleSeparation)
285 templateVal = np.median(template[bbox].image.array)
286 scienceVal = np.median(science[bbox].image.array)
287 diffimVal = np.median(difference[bbox].image.array)
288 src.set(
'source_density', sourceDensity)
289 src.set(
'dipole_density', dipoleDensity)
290 src.set(
'template_value', templateVal)
291 src.set(
'science_value', scienceVal)
292 src.set(
'diffim_value', diffimVal)
293 for maskPlane
in metricsMaskPlanes:
294 src.set(
"%s_mask_fraction"%maskPlane.lower(),
295 evaluateMaskFraction(difference.mask[bbox], maskPlane)
298 krnlSum, dx, dy, direction, length = getKernelCenterDisplacement(
299 psfMatchingKernel, src.get(
'x'), src.get(
'y'))
302 src.get(
'coord_ra'), src.get(
'coord_dec'),
304 point2 = science.wcs.pixelToSky(src.get(
'x') + dx, src.get(
'y') + dy)
305 bearing = point1.bearingTo(point2)
307 pa = pa_ref_angle - bearing
310 position_angle = pa.asRadians()
312 src.set(
'psfMatchingKernel_sum', krnlSum)
313 src.set(
'psfMatchingKernel_dx', dx)
314 src.set(
'psfMatchingKernel_dy', dy)
315 src.set(
'psfMatchingKernel_length', length*pixScale.asArcseconds())
316 src.set(
'psfMatchingKernel_position_angle', position_angle)
317 src.set(
'psfMatchingKernel_direction', direction)
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter)