lsst.ip.diffim gfb11b3eabb+b49c19237c
Loading...
Searching...
No Matches
computeSpatiallySampledMetrics.py
Go to the documentation of this file.
1# This file is part of ip_diffim.
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
22import dataclasses
23
24import numpy as np
25import scipy.signal
26
27import lsst.geom
28
29import lsst.afw.image as afwImage
30import lsst.afw.table as afwTable
31import lsst.pipe.base as pipeBase
32import lsst.pex.config as pexConfig
33
34from lsst.ip.diffim.utils import getPsfFwhm, angleMean, getKernelCenterDisplacement
35from lsst.meas.algorithms import SkyObjectsTask
36from lsst.pex.exceptions import InvalidParameterError, RangeError
37from lsst.utils.timer import timeMethod
38
39import lsst.utils
40
41__all__ = ["SpatiallySampledMetricsConfig", "SpatiallySampledMetricsTask"]
42
43
44class SpatiallySampledMetricsConnections(pipeBase.PipelineTaskConnections,
45 dimensions=("instrument", "visit", "detector"),
46 defaultTemplates={"coaddName": "deep",
47 "warpTypeSuffix": "",
48 "fakesType": ""}):
49 science = pipeBase.connectionTypes.Input(
50 doc="Input science exposure.",
51 dimensions=("instrument", "visit", "detector"),
52 storageClass="ExposureF",
53 name="{fakesType}calexp"
54 )
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",
60 )
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",
66 )
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",
72 )
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",
78 )
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",
84 )
85
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")
91
92
93class SpatiallySampledMetricsConfig(pipeBase.PipelineTaskConfig,
94 pipelineConnections=SpatiallySampledMetricsConnections):
95 """Config for SpatiallySampledMetricsTask
96 """
97 metricsMaskPlanes = lsst.pex.config.ListField(
98 dtype=str,
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',
104 'UNMASKEDNAN',
105 ),
106 )
107 metricSources = pexConfig.ConfigurableField(
108 target=SkyObjectsTask,
109 doc="Generate QA metric sources",
110 )
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.",
114 allowed={
115 "legacy": "Read lsst.afw.math.Kernel.",
116 "future": "Read lsst.images.convolution_kernels.ConvolutionKernel.",
117 },
118 optional=False,
119 default="legacy",
120 )
121
122 def setDefaults(self):
123 self.metricSources.avoidMask = ["NO_DATA", "EDGE"]
124
125
126class SpatiallySampledMetricsTask(lsst.pipe.base.PipelineTask):
127 """Detect and measure sources on a difference image.
128 """
129 ConfigClass = SpatiallySampledMetricsConfig
130 _DefaultName = "spatiallySampledMetrics"
131
132 def __init__(self, **kwargs):
133 super().__init__(**kwargs)
134
135 self.makeSubtask("metricSources")
136 self.schema = afwTable.SourceTable.makeMinimalSchema()
137 self.schema.addField(
138 "x", "F",
139 "X location of the metric evaluation.",
140 units="pixel")
141 self.schema.addField(
142 "y", "F",
143 "Y location of the metric evaluation.",
144 units="pixel")
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.",
158 units="radian")
159 self.schema.addField(
160 "dipole_separation", "F",
161 "Mean dipole separation.",
162 units="pixel")
163 self.schema.addField(
164 "template_value", "F",
165 "Median of template at location.",
166 units="nJy")
167 self.schema.addField(
168 "template_variance", "F",
169 "Median of template variance at location.",
170 units="nJy^2")
171 self.schema.addField(
172 "science_value", "F",
173 "Median of science at location.",
174 units="nJy")
175 self.schema.addField(
176 "science_variance", "F",
177 "Median of science variance at location.",
178 units="nJy^2")
179 self.schema.addField(
180 "diffim_value", "F",
181 "Median of diffim at location.",
182 units="nJy")
183 self.schema.addField(
184 "diffim_variance", "F",
185 "Median of diffim variance at location.",
186 units="nJy^2")
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.",
197 units="pixel")
198 self.schema.addField(
199 "template_psfSize", "F",
200 "Width of the template image PSF at location.",
201 units="pixel")
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
206 )
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.",
213 units="pixel")
214 self.schema.addField(
215 "psfMatchingKernel_dy", "F",
216 "PSF matching kernel centroid offset in y at location.",
217 units="pixel")
218 self.schema.addField(
219 "psfMatchingKernel_length", "F",
220 "PSF matching kernel centroid offset module.",
221 units="arcsecond")
222 self.schema.addField(
223 "psfMatchingKernel_position_angle", "F",
224 "PSF matching kernel centroid offset position angle.",
225 units="radian")
226 self.schema.addField(
227 "psfMatchingKernel_direction", "F",
228 "PSF matching kernel centroid offset direction in detector plane.",
229 units="radian")
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.")
236
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)
243
244 @timeMethod
245 def run(self, science, template, difference, diaSources, psfMatchingKernel):
246 """Calculate difference image metrics on specific locations across the images
247
248 Parameters
249 ----------
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.
261
262 Returns
263 -------
264 results : `lsst.pipe.base.Struct`
265 ``spatiallySampledMetrics`` : `astropy.table.Table`
266 Image quality metrics spatially sampled locations.
267 """
268
269 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
270
271 spatiallySampledMetrics = afwTable.SourceCatalog(self.schema)
272 spatiallySampledMetrics.getTable().setIdFactory(idFactory)
273
274 self.metricSources.run(mask=science.mask, seed=difference.info.id, catalog=spatiallySampledMetrics)
275
276 # Mask plane bits are looked up in the global afw dict, not the
277 # image's own. A plane that is not registered in this process has no
278 # pixels set in any image.
279 metricsMaskPlanes = {}
280 for maskPlane in self.config.metricsMaskPlanes:
281 try:
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
286
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)
293
294 def _evaluateLocalMetric(self, src, science, template, difference, diaSources,
295 metricsMaskPlanes, psfMatchingKernel):
296 """Calculate image quality metrics at spatially sampled locations.
297
298 Parameters
299 ----------
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`
305 The science image.
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.
313 """
314 bbox = src.getFootprint().getBBox()
315 pix = bbox.getCenter()
316 src.set('science_psfSize', getPsfFwhm(science.psf, position=pix))
317 try:
318 src.set('template_psfSize', getPsfFwhm(template.psf, position=pix))
319 except (InvalidParameterError, RangeError):
320 src.set('template_psfSize', np.nan)
321
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
336
337 if dipoleSources:
338 meanDipoleOrientation = angleMean(dipoleSources["dipoleAngle"])
339 src.set('dipole_direction', meanDipoleOrientation)
340 meanDipoleSeparation = np.mean(dipoleSources["dipoleLength"])
341 src.set('dipole_separation', meanDipoleSeparation)
342
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
362 )
363
364 krnlSum, dx, dy, direction, length = getKernelCenterDisplacement(
365 psfMatchingKernel, src.get('x'), src.get('y'))
366
367 point1 = lsst.geom.SpherePoint(
368 src.get('coord_ra'), src.get('coord_dec'),
369 lsst.geom.radians)
370 point2 = science.wcs.pixelToSky(src.get('x') + dx, src.get('y') + dy)
371 bearing = point1.bearingTo(point2)
372 pa_ref_angle = lsst.geom.Angle(np.pi/2, lsst.geom.radians)
373 pa = pa_ref_angle - bearing
374 # Wrap around to get Delta_RA from -pi to +pi
375 pa = pa.wrapCtr()
376 position_angle = pa.asRadians()
377
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) # in E of N position angle
383 src.set('psfMatchingKernel_direction', direction) # direction offset in detector
384
385 src.set('psfMatchingKernel_residualNorm',
386 self._psfMatchResidualNorm(psfMatchingKernel, science.psf, template.psf,
387 src.get('x'), src.get('y')))
388
389 def _diffimChi2PerPix(self, difference):
390 """Robust normalized noise of the difference image.
391
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.
396
397 Returns NaN if no usable pixels remain or the median variance
398 is non-positive.
399 """
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()]
406 if excludePlanes:
407 badBits = mask.getPlaneBitMask(excludePlanes)
408 good = (mask.array & badBits) == 0
409 else:
410 good = np.ones(image.shape, dtype=bool)
411 good &= np.isfinite(image) & np.isfinite(variance) & (variance > 0)
412 if not np.any(good):
413 return np.nan
414 varMed = np.median(variance[good])
415 if not np.isfinite(varMed) or varMed <= 0:
416 return np.nan
417 imgGood = image[good]
418 mad = np.median(np.abs(imgGood - np.median(imgGood)))
419 return float((1.4826*mad)**2/varMed)
420
421 def _psfMatchResidualNorm(self, kernel, sciencePsf, templatePsf, x, y):
422 """Relative L2 norm of the PSF-match residual at (x, y).
423
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.
428
429 Returns NaN if either PSF cannot be evaluated at the position or
430 the resulting images cannot be normalized.
431 """
432 point = lsst.geom.Point2D(x, y)
433 try:
434 psfSci = sciencePsf.computeKernelImage(point).array
435 psfTmp = templatePsf.computeKernelImage(point).array
436 except (InvalidParameterError, RangeError):
437 return np.nan
438
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')
442
443 matchedSum = matched.sum()
444 psfSciSum = psfSci.sum()
445 if matchedSum <= 0 or psfSciSum <= 0:
446 return np.nan
447 matched = matched/matchedSum
448 psfSci = psfSci/psfSciSum
449
450 # PSF stamps may differ in size between the two exposures; crop
451 # both to a common centered region before differencing.
452 h = min(matched.shape[0], psfSci.shape[0])
453 w = min(matched.shape[1], psfSci.shape[1])
454
455 def _crop(a):
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)
461
462 sciNorm = np.sqrt(np.sum(psfSci**2))
463 if sciNorm == 0:
464 return np.nan
465 return float(np.sqrt(np.sum((matched - psfSci)**2))/sciNorm)