35from lsst.meas.algorithms
import SkyObjectsTask
37from lsst.utils.timer
import timeMethod
41__all__ = [
"SpatiallySampledMetricsConfig",
"SpatiallySampledMetricsTask"]
45 dimensions=(
"instrument",
"visit",
"detector"),
46 defaultTemplates={
"coaddName":
"deep",
49 science = pipeBase.connectionTypes.Input(
50 doc=
"Input science exposure.",
51 dimensions=(
"instrument",
"visit",
"detector"),
52 storageClass=
"ExposureF",
53 name=
"{fakesType}calexp"
55 template = pipeBase.connectionTypes.Input(
56 doc=
"Warped and not PSF-matched template used to create the difference image.",
57 dimensions=(
"instrument",
"visit",
"detector"),
58 storageClass=
"ExposureF",
59 name=
"{fakesType}{coaddName}Diff_templateExp",
61 difference = pipeBase.connectionTypes.Input(
62 doc=
"Difference image with detection mask plane filled in.",
63 dimensions=(
"instrument",
"visit",
"detector"),
64 storageClass=
"ExposureF",
65 name=
"{fakesType}{coaddName}Diff_differenceExp",
67 diaSources = pipeBase.connectionTypes.Input(
68 doc=
"Filtered diaSources on the difference image.",
69 dimensions=(
"instrument",
"visit",
"detector"),
70 storageClass=
"ArrowAstropy",
71 name=
"{fakesType}dia_source_detector",
73 psfMatchingKernel = pipeBase.connectionTypes.Input(
74 doc=
"Kernel used to PSF match the science and template images.",
75 dimensions=(
"instrument",
"visit",
"detector"),
76 storageClass=
"MatchingKernel",
77 name=
"{fakesType}{coaddName}Diff_psfMatchKernel",
79 spatiallySampledMetrics = pipeBase.connectionTypes.Output(
80 doc=
"Summary metrics computed at randomized locations.",
81 dimensions=(
"instrument",
"visit",
"detector"),
82 storageClass=
"ArrowAstropy",
83 name=
"{fakesType}{coaddName}Diff_spatiallySampledMetrics",
86 def __init__(self, *, config=None):
87 super().__init__(config=config)
88 if config.image_type ==
"future":
89 self.psfMatchingKernel = dataclasses.replace(self.psfMatchingKernel,
90 storageClass=
"ConvolutionKernel")
93class SpatiallySampledMetricsConfig(pipeBase.PipelineTaskConfig,
94 pipelineConnections=SpatiallySampledMetricsConnections):
95 """Config for SpatiallySampledMetricsTask
97 metricsMaskPlanes = lsst.pex.config.ListField(
99 doc=
"List of mask planes to include in metrics. "
100 "A mask plane that is not registered reports 0 pixels set.",
101 default=(
'BAD',
'CLIPPED',
'CR',
'DETECTED',
'DETECTED_NEGATIVE',
'EDGE',
102 'INEXACT_PSF',
'INJECTED',
'INJECTED_TEMPLATE',
'INTRP',
'NOT_DEBLENDED',
103 'NO_DATA',
'REJECTED',
'SAT',
'SAT_TEMPLATE',
'SENSOR_EDGE',
'STREAK',
'SUSPECT',
107 metricSources = pexConfig.ConfigurableField(
108 target=SkyObjectsTask,
109 doc=
"Generate QA metric sources",
111 image_type = pexConfig.ChoiceField[str](
112 doc=
"Type of the input PSF matching kernel. The input images are read"
113 " as lsst.afw.image.ExposureF either way.",
115 "legacy":
"Read lsst.afw.math.Kernel.",
116 "future":
"Read lsst.images.convolution_kernels.ConvolutionKernel.",
123 self.metricSources.avoidMask = [
"NO_DATA",
"EDGE"]
126class SpatiallySampledMetricsTask(lsst.pipe.base.PipelineTask):
127 """Detect and measure sources on a difference image.
129 ConfigClass = SpatiallySampledMetricsConfig
130 _DefaultName =
"spatiallySampledMetrics"
132 def __init__(self, **kwargs):
133 super().__init__(**kwargs)
135 self.makeSubtask(
"metricSources")
136 self.schema = afwTable.SourceTable.makeMinimalSchema()
137 self.schema.addField(
139 "X location of the metric evaluation.",
141 self.schema.addField(
143 "Y location of the metric evaluation.",
145 self.metricSources.skySourceKey = self.schema.addField(
"sky_source", type=
"Flag",
146 doc=
"Metric evaluation objects.")
147 self.schema.addField(
148 "source_density",
"F",
149 "Density of diaSources at location.",
150 units=
"count/degree^2")
151 self.schema.addField(
152 "dipole_density",
"F",
153 "Density of dipoles at location.",
154 units=
"count/degree^2")
155 self.schema.addField(
156 "dipole_direction",
"F",
157 "Mean dipole orientation.",
159 self.schema.addField(
160 "dipole_separation",
"F",
161 "Mean dipole separation.",
163 self.schema.addField(
164 "template_value",
"F",
165 "Median of template at location.",
167 self.schema.addField(
168 "template_variance",
"F",
169 "Median of template variance at location.",
171 self.schema.addField(
172 "science_value",
"F",
173 "Median of science at location.",
175 self.schema.addField(
176 "science_variance",
"F",
177 "Median of science variance at location.",
179 self.schema.addField(
181 "Median of diffim at location.",
183 self.schema.addField(
184 "diffim_variance",
"F",
185 "Median of diffim variance at location.",
187 self.schema.addField(
188 "diffim_chi2PerPix",
"F",
189 "Robust normalized noise of diffim at location:"
190 " (1.4826*MAD(image))^2 / median(variance), evaluated on background"
191 " pixels (DETECTED, DETECTED_NEGATIVE, BAD, SAT, EDGE, NO_DATA excluded)."
192 " Expected ~1.0 for a well-decorrelated diffim; values >>1 indicate"
193 " residual structure, <<1 indicates over-estimated variance.")
194 self.schema.addField(
195 "science_psfSize",
"F",
196 "Width of the science image PSF at location.",
198 self.schema.addField(
199 "template_psfSize",
"F",
200 "Width of the template image PSF at location.",
202 for maskPlane
in self.config.metricsMaskPlanes:
203 self.schema.addField(
204 "%s_mask_fraction"%maskPlane.lower(),
"F",
205 "Fraction of pixels with %s mask"%maskPlane
207 self.schema.addField(
208 "psfMatchingKernel_sum",
"F",
209 "PSF matching kernel sum at location.")
210 self.schema.addField(
211 "psfMatchingKernel_dx",
"F",
212 "PSF matching kernel centroid offset in x at location.",
214 self.schema.addField(
215 "psfMatchingKernel_dy",
"F",
216 "PSF matching kernel centroid offset in y at location.",
218 self.schema.addField(
219 "psfMatchingKernel_length",
"F",
220 "PSF matching kernel centroid offset module.",
222 self.schema.addField(
223 "psfMatchingKernel_position_angle",
"F",
224 "PSF matching kernel centroid offset position angle.",
226 self.schema.addField(
227 "psfMatchingKernel_direction",
"F",
228 "PSF matching kernel centroid offset direction in detector plane.",
230 self.schema.addField(
231 "psfMatchingKernel_residualNorm",
"F",
232 "Shape-only PSF match residual at location:"
233 "L2 norm of (K-convolved template PSF - science PSF),"
234 " relative to the science PSF L2 norm. Larger values indicate worse"
235 " PSF matching. Assumes the kernel was solved to convolve the template.")
237 def runQuantum(self, butlerQC, inputRefs, outputRefs):
238 inputs = butlerQC.get(inputRefs)
239 if self.config.image_type ==
"future":
240 inputs[
"psfMatchingKernel"] = inputs[
"psfMatchingKernel"].to_legacy()
241 outputs = self.run(**inputs)
242 butlerQC.put(outputs, outputRefs)
245 def run(self, science, template, difference, diaSources, psfMatchingKernel):
246 """Calculate difference image metrics on specific locations across the images
250 science : `lsst.afw.image.ExposureF`
251 Science exposure that the template was subtracted from.
252 template : `lsst.afw.image.ExposureF`
253 Warped and non PSF-matched template that was used produce
254 the difference image.
255 difference : `lsst.afw.image.ExposureF`
256 Result of subtracting template from the science image.
257 diaSources : `lsst.afw.table.SourceCatalog`
258 The catalog of detected sources.
259 psfMatchingKernel : `~lsst.afw.math.LinearCombinationKernel`
260 The PSF matching kernel of the subtraction to evaluate.
264 results : `lsst.pipe.base.Struct`
265 ``spatiallySampledMetrics`` : `astropy.table.Table`
266 Image quality metrics spatially sampled locations.
269 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
271 spatiallySampledMetrics = afwTable.SourceCatalog(self.schema)
272 spatiallySampledMetrics.getTable().setIdFactory(idFactory)
274 self.metricSources.run(mask=science.mask, seed=difference.info.id, catalog=spatiallySampledMetrics)
279 metricsMaskPlanes = {}
280 for maskPlane
in self.config.metricsMaskPlanes:
282 metricsMaskPlanes[maskPlane] = afwImage.Mask.getPlaneBitMask(maskPlane)
283 except InvalidParameterError:
284 self.log.info(
"Mask plane %s is not registered; its metrics will be zero.", maskPlane)
285 metricsMaskPlanes[maskPlane] = 0
287 for src
in spatiallySampledMetrics:
288 self._evaluateLocalMetric(src, science, template, difference, diaSources,
289 metricsMaskPlanes=metricsMaskPlanes,
290 psfMatchingKernel=psfMatchingKernel)
291 spatiallySampledMetrics = spatiallySampledMetrics.copy(deep=
True).asAstropy()
292 return pipeBase.Struct(spatiallySampledMetrics=spatiallySampledMetrics)
294 def _evaluateLocalMetric(self, src, science, template, difference, diaSources,
295 metricsMaskPlanes, psfMatchingKernel):
296 """Calculate image quality metrics at spatially sampled locations.
300 src : `lsst.afw.table.SourceRecord`
301 The source record to be updated with metric calculations.
302 diaSources : `lsst.afw.table.SourceCatalog`
303 The catalog of detected sources.
304 science : `lsst.afw.image.Exposure`
306 difference : `lsst.afw.image.Exposure`
307 Result of subtracting template from the science image.
308 metricsMaskPlanes : `dict` [`str`, `int`]
309 Mask planes to calculate metrics from, mapped to their bitmask.
310 The bitmask is 0 for a plane that is not registered.
311 psfMatchingKernel : `~lsst.afw.math.LinearCombinationKernel`
312 The PSF matching kernel of the subtraction to evaluate.
314 bbox = src.getFootprint().getBBox()
315 pix = bbox.getCenter()
316 src.set(
'science_psfSize', getPsfFwhm(science.psf, position=pix))
318 src.set(
'template_psfSize', getPsfFwhm(template.psf, position=pix))
319 except (InvalidParameterError, RangeError):
320 src.set(
'template_psfSize', np.nan)
322 metricRegionSize = 100
323 bbox.grow(metricRegionSize)
324 bbox = bbox.clippedTo(science.getBBox())
325 nPix = bbox.getArea()
326 pixScale = science.wcs.getPixelScale(bbox.getCenter())
327 area = nPix*pixScale.asDegrees()**2
328 peak = src.getFootprint().getPeaks()[0]
329 src.set(
'x', peak[
'i_x'])
330 src.set(
'y', peak[
'i_y'])
331 src.setCoord(science.wcs.pixelToSky(peak[
'i_x'], peak[
'i_y']))
332 selectSources = diaSources[bbox.contains(diaSources[
'x'], diaSources[
'y'])]
333 sourceDensity = len(selectSources)/area
334 dipoleSources = selectSources[selectSources[
"isDipole"]]
335 dipoleDensity = len(dipoleSources)/area
338 meanDipoleOrientation = angleMean(dipoleSources[
"dipoleAngle"])
339 src.set(
'dipole_direction', meanDipoleOrientation)
340 meanDipoleSeparation = np.mean(dipoleSources[
"dipoleLength"])
341 src.set(
'dipole_separation', meanDipoleSeparation)
343 templateVal = np.median(template[bbox].image.array)
344 templateVar = np.median(template[bbox].variance.array)
345 scienceVal = np.median(science[bbox].image.array)
346 scienceVar = np.median(science[bbox].variance.array)
347 diffimVal = np.median(difference[bbox].image.array)
348 diffimVar = np.median(difference[bbox].variance.array)
349 src.set(
'source_density', sourceDensity)
350 src.set(
'dipole_density', dipoleDensity)
351 src.set(
'template_value', templateVal)
352 src.set(
'template_variance', templateVar)
353 src.set(
'science_value', scienceVal)
354 src.set(
'science_variance', scienceVar)
355 src.set(
'diffim_value', diffimVal)
356 src.set(
'diffim_variance', diffimVar)
357 src.set(
'diffim_chi2PerPix', self._diffimChi2PerPix(difference[bbox]))
358 differenceMask = difference.mask[bbox].array
359 for maskPlane, bitMask
in metricsMaskPlanes.items():
360 src.set(
"%s_mask_fraction"%maskPlane.lower(),
361 np.count_nonzero(differenceMask & bitMask)/differenceMask.size
364 krnlSum, dx, dy, direction, length = getKernelCenterDisplacement(
365 psfMatchingKernel, src.get(
'x'), src.get(
'y'))
368 src.get(
'coord_ra'), src.get(
'coord_dec'),
370 point2 = science.wcs.pixelToSky(src.get(
'x') + dx, src.get(
'y') + dy)
371 bearing = point1.bearingTo(point2)
373 pa = pa_ref_angle - bearing
376 position_angle = pa.asRadians()
378 src.set(
'psfMatchingKernel_sum', krnlSum)
379 src.set(
'psfMatchingKernel_dx', dx)
380 src.set(
'psfMatchingKernel_dy', dy)
381 src.set(
'psfMatchingKernel_length', length*pixScale.asArcseconds())
382 src.set(
'psfMatchingKernel_position_angle', position_angle)
383 src.set(
'psfMatchingKernel_direction', direction)
385 src.set(
'psfMatchingKernel_residualNorm',
386 self._psfMatchResidualNorm(psfMatchingKernel, science.psf, template.psf,
387 src.get(
'x'), src.get(
'y')))
389 def _diffimChi2PerPix(self, difference):
390 """Robust normalized noise of the difference image.
392 Computes ``(1.4826 * MAD(image))^2 / median(variance)`` on
393 background-only pixels. The 1.4826 factor rescales the
394 Median Absolute Deviation to a Gaussian-sigma estimate, so the
395 ratio has expectation 1.0 on pure noise.
397 Returns NaN if no usable pixels remain or the median variance
400 image = difference.image.array
401 variance = difference.variance.array
402 mask = difference.mask
403 excludePlanes = [p
for p
in (
"DETECTED",
"DETECTED_NEGATIVE",
"BAD",
404 "SAT",
"EDGE",
"NO_DATA")
405 if p
in mask.getMaskPlaneDict()]
407 badBits = mask.getPlaneBitMask(excludePlanes)
408 good = (mask.array & badBits) == 0
410 good = np.ones(image.shape, dtype=bool)
411 good &= np.isfinite(image) & np.isfinite(variance) & (variance > 0)
414 varMed = np.median(variance[good])
415 if not np.isfinite(varMed)
or varMed <= 0:
417 imgGood = image[good]
418 mad = np.median(np.abs(imgGood - np.median(imgGood)))
419 return float((1.4826*mad)**2/varMed)
421 def _psfMatchResidualNorm(self, kernel, sciencePsf, templatePsf, x, y):
422 """Relative L2 norm of the PSF-match residual at (x, y).
424 Convolves the template PSF with the matching kernel evaluated at
425 (x, y) and compares to the science PSF at the same position, both
426 renormalized to unit sum so the result captures shape mismatch only.
427 The kernel is assumed to convolve the template.
429 Returns NaN if either PSF cannot be evaluated at the position or
430 the resulting images cannot be normalized.
434 psfSci = sciencePsf.computeKernelImage(point).array
435 psfTmp = templatePsf.computeKernelImage(point).array
436 except (InvalidParameterError, RangeError):
439 kImage = afwImage.ImageD(kernel.getDimensions())
440 kernel.computeImage(kImage, doNormalize=
True, x=x, y=y)
441 matched = scipy.signal.fftconvolve(psfTmp, kImage.array, mode=
'same')
443 matchedSum = matched.sum()
444 psfSciSum = psfSci.sum()
445 if matchedSum <= 0
or psfSciSum <= 0:
447 matched = matched/matchedSum
448 psfSci = psfSci/psfSciSum
452 h = min(matched.shape[0], psfSci.shape[0])
453 w = min(matched.shape[1], psfSci.shape[1])
456 sy = (a.shape[0] - h)//2
457 sx = (a.shape[1] - w)//2
458 return a[sy:sy + h, sx:sx + w]
459 matched = _crop(matched)
460 psfSci = _crop(psfSci)
462 sciNorm = np.sqrt(np.sum(psfSci**2))
465 return float(np.sqrt(np.sum((matched - psfSci)**2))/sciNorm)