LSST Applications g0b6bd0c080+a72a5dd7e6,g1182afd7b4+2a019aa3bb,g17e5ecfddb+2b8207f7de,g1d67935e3f+06cf436103,g38293774b4+ac198e9f13,g396055baef+6a2097e274,g3b44f30a73+6611e0205b,g480783c3b1+98f8679e14,g48ccf36440+89c08d0516,g4b93dc025c+98f8679e14,g5c4744a4d9+a302e8c7f0,g613e996a0d+e1c447f2e0,g6c8d09e9e7+25247a063c,g7271f0639c+98f8679e14,g7a9cd813b8+124095ede6,g9d27549199+a302e8c7f0,ga1cf026fa3+ac198e9f13,ga32aa97882+7403ac30ac,ga786bb30fb+7a139211af,gaa63f70f4e+9994eb9896,gabf319e997+ade567573c,gba47b54d5d+94dc90c3ea,gbec6a3398f+06cf436103,gc6308e37c7+07dd123edb,gc655b1545f+ade567573c,gcc9029db3c+ab229f5caf,gd01420fc67+06cf436103,gd877ba84e5+06cf436103,gdb4cecd868+6f279b5b48,ge2d134c3d5+cc4dbb2e3f,ge448b5faa6+86d1ceac1d,gecc7e12556+98f8679e14,gf3ee170dca+25247a063c,gf4ac96e456+ade567573c,gf9f5ea5b4d+ac198e9f13,gff490e6085+8c2580be5c,w.2022.27
LSST Data Management Base Package
imageDifference.py
Go to the documentation of this file.
1# This file is part of pipe_tasks.
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 math
23import random
24import numpy
25
26import lsst.utils
27import lsst.pex.config as pexConfig
28import lsst.pipe.base as pipeBase
29import lsst.daf.base as dafBase
30import lsst.geom as geom
31import lsst.afw.math as afwMath
32import lsst.afw.table as afwTable
34from lsst.meas.algorithms import (SourceDetectionTask, SingleGaussianPsf, ObjectSizeStarSelectorTask,
35 LoadIndexedReferenceObjectsTask, SkyObjectsTask,
36 ScaleVarianceTask)
37from lsst.meas.astrom import AstrometryConfig, AstrometryTask
38from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask
39from lsst.pipe.tasks.registerImage import RegisterTask
40from lsst.ip.diffim import (DipoleAnalysis, SourceFlagChecker, KernelCandidateF, makeKernelBasisList,
41 KernelCandidateQa, DiaCatalogSourceSelectorTask, DiaCatalogSourceSelectorConfig,
42 GetCoaddAsTemplateTask, GetCalexpAsTemplateTask, DipoleFitTask,
43 DecorrelateALKernelSpatialTask, subtractAlgorithmRegistry)
44import lsst.ip.diffim.diffimTools as diffimTools
45import lsst.ip.diffim.utils as diUtils
46import lsst.afw.display as afwDisplay
47from lsst.skymap import BaseSkyMap
48from lsst.obs.base import ExposureIdInfo
49from lsst.utils.timer import timeMethod
50
51__all__ = ["ImageDifferenceConfig", "ImageDifferenceTask"]
52FwhmPerSigma = 2*math.sqrt(2*math.log(2))
53IqrToSigma = 0.741
54
55
56class ImageDifferenceTaskConnections(pipeBase.PipelineTaskConnections,
57 dimensions=("instrument", "visit", "detector", "skymap"),
58 defaultTemplates={"coaddName": "deep",
59 "skyMapName": "deep",
60 "warpTypeSuffix": "",
61 "fakesType": ""}):
62
63 exposure = pipeBase.connectionTypes.Input(
64 doc="Input science exposure to subtract from.",
65 dimensions=("instrument", "visit", "detector"),
66 storageClass="ExposureF",
67 name="{fakesType}calexp"
68 )
69
70 # TODO DM-22953
71 # kernelSources = pipeBase.connectionTypes.Input(
72 # doc="Source catalog produced in calibrate task for kernel candidate sources",
73 # name="src",
74 # storageClass="SourceCatalog",
75 # dimensions=("instrument", "visit", "detector"),
76 # )
77
78 skyMap = pipeBase.connectionTypes.Input(
79 doc="Input definition of geometry/bbox and projection/wcs for template exposures",
80 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
81 dimensions=("skymap", ),
82 storageClass="SkyMap",
83 )
84 coaddExposures = pipeBase.connectionTypes.Input(
85 doc="Input template to match and subtract from the exposure",
86 dimensions=("tract", "patch", "skymap", "band"),
87 storageClass="ExposureF",
88 name="{fakesType}{coaddName}Coadd{warpTypeSuffix}",
89 multiple=True,
90 deferLoad=True
91 )
92 dcrCoadds = pipeBase.connectionTypes.Input(
93 doc="Input DCR template to match and subtract from the exposure",
94 name="{fakesType}dcrCoadd{warpTypeSuffix}",
95 storageClass="ExposureF",
96 dimensions=("tract", "patch", "skymap", "band", "subfilter"),
97 multiple=True,
98 deferLoad=True
99 )
100 finalizedPsfApCorrCatalog = pipeBase.connectionTypes.Input(
101 doc=("Per-visit finalized psf models and aperture correction maps. "
102 "These catalogs use the detector id for the catalog id, "
103 "sorted on id for fast lookup."),
104 name="finalized_psf_ap_corr_catalog",
105 storageClass="ExposureCatalog",
106 dimensions=("instrument", "visit"),
107 )
108 outputSchema = pipeBase.connectionTypes.InitOutput(
109 doc="Schema (as an example catalog) for output DIASource catalog.",
110 storageClass="SourceCatalog",
111 name="{fakesType}{coaddName}Diff_diaSrc_schema",
112 )
113 subtractedExposure = pipeBase.connectionTypes.Output(
114 doc="Output AL difference or Zogy proper difference image",
115 dimensions=("instrument", "visit", "detector"),
116 storageClass="ExposureF",
117 name="{fakesType}{coaddName}Diff_differenceExp",
118 )
119 scoreExposure = pipeBase.connectionTypes.Output(
120 doc="Output AL likelihood or Zogy score image",
121 dimensions=("instrument", "visit", "detector"),
122 storageClass="ExposureF",
123 name="{fakesType}{coaddName}Diff_scoreExp",
124 )
125 warpedExposure = pipeBase.connectionTypes.Output(
126 doc="Warped template used to create `subtractedExposure`.",
127 dimensions=("instrument", "visit", "detector"),
128 storageClass="ExposureF",
129 name="{fakesType}{coaddName}Diff_warpedExp",
130 )
131 matchedExposure = pipeBase.connectionTypes.Output(
132 doc="Warped template used to create `subtractedExposure`.",
133 dimensions=("instrument", "visit", "detector"),
134 storageClass="ExposureF",
135 name="{fakesType}{coaddName}Diff_matchedExp",
136 )
137 diaSources = pipeBase.connectionTypes.Output(
138 doc="Output detected diaSources on the difference image",
139 dimensions=("instrument", "visit", "detector"),
140 storageClass="SourceCatalog",
141 name="{fakesType}{coaddName}Diff_diaSrc",
142 )
143
144 def __init__(self, *, config=None):
145 super().__init__(config=config)
146 if config.coaddName == 'dcr':
147 self.inputs.remove("coaddExposures")
148 else:
149 self.inputs.remove("dcrCoadds")
150 if not config.doWriteSubtractedExp:
151 self.outputs.remove("subtractedExposure")
152 if not config.doWriteScoreExp:
153 self.outputs.remove("scoreExposure")
154 if not config.doWriteWarpedExp:
155 self.outputs.remove("warpedExposure")
156 if not config.doWriteMatchedExp:
157 self.outputs.remove("matchedExposure")
158 if not config.doWriteSources:
159 self.outputs.remove("diaSources")
160 if not config.doApplyFinalizedPsf:
161 self.inputs.remove("finalizedPsfApCorrCatalog")
162
163 # TODO DM-22953: Add support for refObjLoader (kernelSourcesFromRef)
164 # Make kernelSources optional
165
166
167class ImageDifferenceConfig(pipeBase.PipelineTaskConfig,
168 pipelineConnections=ImageDifferenceTaskConnections):
169 """Config for ImageDifferenceTask
170 """
171 doAddCalexpBackground = pexConfig.Field(dtype=bool, default=False,
172 doc="Add background to calexp before processing it. "
173 "Useful as ipDiffim does background matching.")
174 doUseRegister = pexConfig.Field(dtype=bool, default=False,
175 doc="Re-compute astrometry on the template. "
176 "Use image-to-image registration to align template with "
177 "science image (AL only).")
178 doDebugRegister = pexConfig.Field(dtype=bool, default=False,
179 doc="Writing debugging data for doUseRegister")
180 doSelectSources = pexConfig.Field(dtype=bool, default=False,
181 doc="Select stars to use for kernel fitting (AL only)")
182 doSelectDcrCatalog = pexConfig.Field(dtype=bool, default=False,
183 doc="Select stars of extreme color as part "
184 "of the control sample (AL only)")
185 doSelectVariableCatalog = pexConfig.Field(dtype=bool, default=False,
186 doc="Select stars that are variable to be part "
187 "of the control sample (AL only)")
188 doSubtract = pexConfig.Field(dtype=bool, default=True, doc="Compute subtracted exposure?")
189 doPreConvolve = pexConfig.Field(dtype=bool, default=False,
190 doc="Not in use. Superseded by useScoreImageDetection.",
191 deprecated="This option superseded by useScoreImageDetection."
192 " Will be removed after v22.")
193 useScoreImageDetection = pexConfig.Field(
194 dtype=bool, default=False, doc="Calculate the pre-convolved AL likelihood or "
195 "the Zogy score image. Use it for source detection (if doDetection=True).")
196 doWriteScoreExp = pexConfig.Field(
197 dtype=bool, default=False, doc="Write AL likelihood or Zogy score exposure?")
198 doScaleTemplateVariance = pexConfig.Field(dtype=bool, default=False,
199 doc="Scale variance of the template before PSF matching")
200 doScaleDiffimVariance = pexConfig.Field(dtype=bool, default=True,
201 doc="Scale variance of the diffim before PSF matching. "
202 "You may do either this or template variance scaling, "
203 "or neither. (Doing both is a waste of CPU.)")
204 useGaussianForPreConvolution = pexConfig.Field(
205 dtype=bool, default=False, doc="Use a simple gaussian PSF model for pre-convolution "
206 "(oherwise use exposure PSF)? (AL and if useScoreImageDetection=True only)")
207 doDetection = pexConfig.Field(dtype=bool, default=True, doc="Detect sources?")
208 doDecorrelation = pexConfig.Field(dtype=bool, default=True,
209 doc="Perform diffim decorrelation to undo pixel correlation due to A&L "
210 "kernel convolution (AL only)? If True, also update the diffim PSF.")
211 doMerge = pexConfig.Field(dtype=bool, default=True,
212 doc="Merge positive and negative diaSources with grow radius "
213 "set by growFootprint")
214 doMatchSources = pexConfig.Field(dtype=bool, default=False,
215 doc="Match diaSources with input calexp sources and ref catalog sources")
216 doMeasurement = pexConfig.Field(dtype=bool, default=True, doc="Measure diaSources?")
217 doDipoleFitting = pexConfig.Field(dtype=bool, default=True, doc="Measure dipoles using new algorithm?")
218 doForcedMeasurement = pexConfig.Field(
219 dtype=bool,
220 default=True,
221 doc="Force photometer diaSource locations on PVI?")
222 doWriteSubtractedExp = pexConfig.Field(
223 dtype=bool, default=True, doc="Write difference exposure (AL and Zogy) ?")
224 doWriteWarpedExp = pexConfig.Field(
225 dtype=bool, default=False, doc="Write WCS, warped template coadd exposure?")
226 doWriteMatchedExp = pexConfig.Field(dtype=bool, default=False,
227 doc="Write warped and PSF-matched template coadd exposure?")
228 doWriteSources = pexConfig.Field(dtype=bool, default=True, doc="Write sources?")
229 doAddMetrics = pexConfig.Field(dtype=bool, default=False,
230 doc="Add columns to the source table to hold analysis metrics?")
231 doApplyFinalizedPsf = pexConfig.Field(
232 doc="Whether to apply finalized psf models and aperture correction map.",
233 dtype=bool,
234 default=False,
235 )
236
237 coaddName = pexConfig.Field(
238 doc="coadd name: typically one of deep, goodSeeing, or dcr",
239 dtype=str,
240 default="deep",
241 )
242 convolveTemplate = pexConfig.Field(
243 doc="Which image gets convolved (default = template)",
244 dtype=bool,
245 default=True
246 )
247 refObjLoader = pexConfig.ConfigurableField(
248 target=LoadIndexedReferenceObjectsTask,
249 doc="reference object loader",
250 )
251 astrometer = pexConfig.ConfigurableField(
252 target=AstrometryTask,
253 doc="astrometry task; used to match sources to reference objects, but not to fit a WCS",
254 )
255 sourceSelector = pexConfig.ConfigurableField(
256 target=ObjectSizeStarSelectorTask,
257 doc="Source selection algorithm",
258 )
259 subtract = subtractAlgorithmRegistry.makeField("Subtraction Algorithm", default="al")
260 decorrelate = pexConfig.ConfigurableField(
261 target=DecorrelateALKernelSpatialTask,
262 doc="Decorrelate effects of A&L kernel convolution on image difference, only if doSubtract is True. "
263 "If this option is enabled, then detection.thresholdValue should be set to 5.0 (rather than the "
264 "default of 5.5).",
265 )
266 # Old style ImageMapper grid. ZogyTask has its own grid option
267 doSpatiallyVarying = pexConfig.Field(
268 dtype=bool,
269 default=False,
270 doc="Perform A&L decorrelation on a grid across the "
271 "image in order to allow for spatial variations. Zogy does not use this option."
272 )
273 detection = pexConfig.ConfigurableField(
274 target=SourceDetectionTask,
275 doc="Low-threshold detection for final measurement",
276 )
277 measurement = pexConfig.ConfigurableField(
278 target=DipoleFitTask,
279 doc="Enable updated dipole fitting method",
280 )
281 doApCorr = lsst.pex.config.Field(
282 dtype=bool,
283 default=True,
284 doc="Run subtask to apply aperture corrections"
285 )
287 target=ApplyApCorrTask,
288 doc="Subtask to apply aperture corrections"
289 )
290 forcedMeasurement = pexConfig.ConfigurableField(
291 target=ForcedMeasurementTask,
292 doc="Subtask to force photometer PVI at diaSource location.",
293 )
294 getTemplate = pexConfig.ConfigurableField(
295 target=GetCoaddAsTemplateTask,
296 doc="Subtask to retrieve template exposure and sources",
297 )
298 scaleVariance = pexConfig.ConfigurableField(
299 target=ScaleVarianceTask,
300 doc="Subtask to rescale the variance of the template "
301 "to the statistically expected level"
302 )
303 controlStepSize = pexConfig.Field(
304 doc="What step size (every Nth one) to select a control sample from the kernelSources",
305 dtype=int,
306 default=5
307 )
308 controlRandomSeed = pexConfig.Field(
309 doc="Random seed for shuffing the control sample",
310 dtype=int,
311 default=10
312 )
313 register = pexConfig.ConfigurableField(
314 target=RegisterTask,
315 doc="Task to enable image-to-image image registration (warping)",
316 )
317 kernelSourcesFromRef = pexConfig.Field(
318 doc="Select sources to measure kernel from reference catalog if True, template if false",
319 dtype=bool,
320 default=False
321 )
322 templateSipOrder = pexConfig.Field(
323 dtype=int, default=2,
324 doc="Sip Order for fitting the Template Wcs (default is too high, overfitting)"
325 )
326 growFootprint = pexConfig.Field(
327 dtype=int, default=2,
328 doc="Grow positive and negative footprints by this amount before merging"
329 )
330 diaSourceMatchRadius = pexConfig.Field(
331 dtype=float, default=0.5,
332 doc="Match radius (in arcseconds) for DiaSource to Source association"
333 )
334 requiredTemplateFraction = pexConfig.Field(
335 dtype=float, default=0.1,
336 doc="Do not attempt to run task if template covers less than this fraction of pixels."
337 "Setting to 0 will always attempt image subtraction"
338 )
339 doSkySources = pexConfig.Field(
340 dtype=bool,
341 default=False,
342 doc="Generate sky sources?",
343 )
344 skySources = pexConfig.ConfigurableField(
345 target=SkyObjectsTask,
346 doc="Generate sky sources",
347 )
348
349 def setDefaults(self):
350 # defaults are OK for catalog and diacatalog
351
352 self.subtract['al'].kernel.name = "AL"
353 self.subtract['al'].kernel.active.fitForBackground = True
354 self.subtract['al'].kernel.active.spatialKernelOrder = 1
355 self.subtract['al'].kernel.active.spatialBgOrder = 2
356
357 # DiaSource Detection
358 self.detection.thresholdPolarity = "both"
359 self.detection.thresholdValue = 5.0
360 self.detection.reEstimateBackground = False
361 self.detection.thresholdType = "pixel_stdev"
362
363 # Add filtered flux measurement, the correct measurement for pre-convolved images.
364 # Enable all measurements, regardless of doPreConvolve, as it makes data harvesting easier.
365 # To change that you must modify algorithms.names in the task's applyOverrides method,
366 # after the user has set doPreConvolve.
367 self.measurement.algorithms.names.add('base_PeakLikelihoodFlux')
368 self.measurement.plugins.names |= ['ext_trailedSources_Naive',
369 'base_LocalPhotoCalib',
370 'base_LocalWcs']
371
372 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"]
373 self.forcedMeasurement.copyColumns = {
374 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"}
375 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid"
376 self.forcedMeasurement.slots.shape = None
377
378 # For shuffling the control sample
379 random.seed(self.controlRandomSeed)
380
381 def validate(self):
382 pexConfig.Config.validate(self)
383 if not self.doSubtract and not self.doDetection:
384 raise ValueError("Either doSubtract or doDetection must be enabled.")
385 if self.doMeasurement and not self.doDetection:
386 raise ValueError("Cannot run source measurement without source detection.")
387 if self.doMerge and not self.doDetection:
388 raise ValueError("Cannot run source merging without source detection.")
389 if self.doSkySources and not self.doDetection:
390 raise ValueError("Cannot run sky source creation without source detection.")
391 if self.doUseRegister and not self.doSelectSources:
392 raise ValueError("doUseRegister=True and doSelectSources=False. "
393 "Cannot run RegisterTask without selecting sources.")
394 if self.doScaleDiffimVariance and self.doScaleTemplateVariance:
395 raise ValueError("Scaling the diffim variance and scaling the template variance "
396 "are both set. Please choose one or the other.")
397 # We cannot allow inconsistencies that would lead to None or not available output products
398 if self.subtract.name == 'zogy':
399 if self.doWriteMatchedExp:
400 raise ValueError("doWriteMatchedExp=True Matched exposure is not "
401 "calculated in zogy subtraction.")
402 if self.doAddMetrics:
403 raise ValueError("doAddMetrics=True Kernel metrics does not exist in zogy subtraction.")
404 if self.doDecorrelation:
405 raise ValueError(
406 "doDecorrelation=True The decorrelation afterburner does not exist in zogy subtraction.")
407 if self.doSelectSources:
408 raise ValueError(
409 "doSelectSources=True Selecting sources for PSF matching is not a zogy option.")
410 if self.useGaussianForPreConvolution:
411 raise ValueError(
412 "useGaussianForPreConvolution=True This is an AL subtraction only option.")
413 else:
414 # AL only consistency checks
415 if self.useScoreImageDetection and not self.convolveTemplate:
416 raise ValueError(
417 "convolveTemplate=False and useScoreImageDetection=True "
418 "Pre-convolution and matching of the science image is not a supported operation.")
419 if self.doWriteSubtractedExp and self.useScoreImageDetection:
420 raise ValueError(
421 "doWriteSubtractedExp=True and useScoreImageDetection=True "
422 "Regular difference image is not calculated. "
423 "AL subtraction calculates either the regular difference image or the score image.")
424 if self.doWriteScoreExp and not self.useScoreImageDetection:
425 raise ValueError(
426 "doWriteScoreExp=True and useScoreImageDetection=False "
427 "Score image is not calculated. "
428 "AL subtraction calculates either the regular difference image or the score image.")
429 if self.doAddMetrics and not self.doSubtract:
430 raise ValueError("Subtraction must be enabled for kernel metrics calculation.")
431 if self.useGaussianForPreConvolution and not self.useScoreImageDetection:
432 raise ValueError(
433 "useGaussianForPreConvolution=True and useScoreImageDetection=False "
434 "Gaussian PSF approximation exists only for AL subtraction w/ pre-convolution.")
435
436
437class ImageDifferenceTaskRunner(pipeBase.ButlerInitializedTaskRunner):
438
439 @staticmethod
440 def getTargetList(parsedCmd, **kwargs):
441 return pipeBase.TaskRunner.getTargetList(parsedCmd, templateIdList=parsedCmd.templateId.idList,
442 **kwargs)
443
444
445class ImageDifferenceTask(pipeBase.CmdLineTask, pipeBase.PipelineTask):
446 """Subtract an image from a template and measure the result
447 """
448 ConfigClass = ImageDifferenceConfig
449 RunnerClass = ImageDifferenceTaskRunner
450 _DefaultName = "imageDifference"
451
452 def __init__(self, butler=None, **kwargs):
453 """!Construct an ImageDifference Task
454
455 @param[in] butler Butler object to use in constructing reference object loaders
456 """
457 super().__init__(**kwargs)
458 self.makeSubtask("getTemplate")
459
460 self.makeSubtask("subtract")
461
462 if self.config.subtract.name == 'al' and self.config.doDecorrelation:
463 self.makeSubtask("decorrelate")
464
465 if self.config.doScaleTemplateVariance or self.config.doScaleDiffimVariance:
466 self.makeSubtask("scaleVariance")
467
468 if self.config.doUseRegister:
469 self.makeSubtask("register")
470 self.schema = afwTable.SourceTable.makeMinimalSchema()
471
472 if self.config.doSelectSources:
473 self.makeSubtask("sourceSelector")
474 if self.config.kernelSourcesFromRef:
475 self.makeSubtask('refObjLoader', butler=butler)
476 self.makeSubtask("astrometer", refObjLoader=self.refObjLoader)
477
478 self.algMetadata = dafBase.PropertyList()
479 if self.config.doDetection:
480 self.makeSubtask("detection", schema=self.schema)
481 if self.config.doMeasurement:
482 self.makeSubtask("measurement", schema=self.schema,
483 algMetadata=self.algMetadata)
484 if self.config.doApCorr:
485 self.makeSubtask("applyApCorr", schema=self.measurement.schema)
486 if self.config.doForcedMeasurement:
487 self.schema.addField(
488 "ip_diffim_forced_PsfFlux_instFlux", "D",
489 "Forced PSF flux measured on the direct image.",
490 units="count")
491 self.schema.addField(
492 "ip_diffim_forced_PsfFlux_instFluxErr", "D",
493 "Forced PSF flux error measured on the direct image.",
494 units="count")
495 self.schema.addField(
496 "ip_diffim_forced_PsfFlux_area", "F",
497 "Forced PSF flux effective area of PSF.",
498 units="pixel")
499 self.schema.addField(
500 "ip_diffim_forced_PsfFlux_flag", "Flag",
501 "Forced PSF flux general failure flag.")
502 self.schema.addField(
503 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag",
504 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
505 self.schema.addField(
506 "ip_diffim_forced_PsfFlux_flag_edge", "Flag",
507 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
508 self.makeSubtask("forcedMeasurement", refSchema=self.schema)
509 if self.config.doMatchSources:
510 self.schema.addField("refMatchId", "L", "unique id of reference catalog match")
511 self.schema.addField("srcMatchId", "L", "unique id of source match")
512 if self.config.doSkySources:
513 self.makeSubtask("skySources")
514 self.skySourceKey = self.schema.addField("sky_source", type="Flag", doc="Sky objects.")
515
516 # initialize InitOutputs
517 self.outputSchema = afwTable.SourceCatalog(self.schema)
518 self.outputSchema.getTable().setMetadata(self.algMetadata)
519
520 @staticmethod
521 def makeIdFactory(expId, expBits):
522 """Create IdFactory instance for unique 64 bit diaSource id-s.
523
524 Parameters
525 ----------
526 expId : `int`
527 Exposure id.
528
529 expBits: `int`
530 Number of used bits in ``expId``.
531
532 Note
533 ----
534 The diasource id-s consists of the ``expId`` stored fixed in the highest value
535 ``expBits`` of the 64-bit integer plus (bitwise or) a generated sequence number in the
536 low value end of the integer.
537
538 Returns
539 -------
540 idFactory: `lsst.afw.table.IdFactory`
541 """
542 return ExposureIdInfo(expId, expBits).makeSourceIdFactory()
543
544 @lsst.utils.inheritDoc(pipeBase.PipelineTask)
545 def runQuantum(self, butlerQC: pipeBase.ButlerQuantumContext,
546 inputRefs: pipeBase.InputQuantizedConnection,
547 outputRefs: pipeBase.OutputQuantizedConnection):
548 inputs = butlerQC.get(inputRefs)
549 self.log.info("Processing %s", butlerQC.quantum.dataId)
550
551 finalizedPsfApCorrCatalog = inputs.get("finalizedPsfApCorrCatalog", None)
552 exposure = self.prepareCalibratedExposure(
553 inputs["exposure"],
554 finalizedPsfApCorrCatalog=finalizedPsfApCorrCatalog
555 )
556
557 expId, expBits = butlerQC.quantum.dataId.pack("visit_detector",
558 returnMaxBits=True)
559 idFactory = self.makeIdFactory(expId=expId, expBits=expBits)
560 if self.config.coaddName == 'dcr':
561 templateExposures = inputRefs.dcrCoadds
562 else:
563 templateExposures = inputRefs.coaddExposures
564 templateStruct = self.getTemplate.runQuantum(
565 exposure, butlerQC, inputRefs.skyMap, templateExposures
566 )
567
568 self.checkTemplateIsSufficient(templateStruct.exposure)
569
570 outputs = self.run(exposure=exposure,
571 templateExposure=templateStruct.exposure,
572 idFactory=idFactory)
573 # Consistency with runDataref gen2 handling
574 if outputs.diaSources is None:
575 del outputs.diaSources
576 butlerQC.put(outputs, outputRefs)
577
578 @timeMethod
579 def runDataRef(self, sensorRef, templateIdList=None):
580 """Subtract an image from a template coadd and measure the result.
581
582 Data I/O wrapper around `run` using the butler in Gen2.
583
584 Parameters
585 ----------
587 Sensor-level butler data reference, used for the following data products:
588
589 Input only:
590 - calexp
591 - psf
592 - ccdExposureId
593 - ccdExposureId_bits
594 - self.config.coaddName + "Coadd_skyMap"
595 - self.config.coaddName + "Coadd"
596 Input or output, depending on config:
597 - self.config.coaddName + "Diff_subtractedExp"
598 Output, depending on config:
599 - self.config.coaddName + "Diff_matchedExp"
600 - self.config.coaddName + "Diff_src"
601
602 Returns
603 -------
604 results : `lsst.pipe.base.Struct`
605 Returns the Struct by `run`.
606 """
607 subtractedExposureName = self.config.coaddName + "Diff_differenceExp"
608 subtractedExposure = None
609 selectSources = None
610 calexpBackgroundExposure = None
611 self.log.info("Processing %s", sensorRef.dataId)
612
613 # We make one IdFactory that will be used by both icSrc and src datasets;
614 # I don't know if this is the way we ultimately want to do things, but at least
615 # this ensures the source IDs are fully unique.
616 idFactory = self.makeIdFactory(expId=int(sensorRef.get("ccdExposureId")),
617 expBits=sensorRef.get("ccdExposureId_bits"))
618 if self.config.doAddCalexpBackground:
619 calexpBackgroundExposure = sensorRef.get("calexpBackground")
620
621 # Retrieve the science image we wish to analyze
622 exposure = sensorRef.get("calexp", immediate=True)
623
624 # Retrieve the template image
625 template = self.getTemplate.runDataRef(exposure, sensorRef, templateIdList=templateIdList)
626
627 if sensorRef.datasetExists("src"):
628 self.log.info("Source selection via src product")
629 # Sources already exist; for data release processing
630 selectSources = sensorRef.get("src")
631
632 if not self.config.doSubtract and self.config.doDetection:
633 # If we don't do subtraction, we need the subtracted exposure from the repo
634 subtractedExposure = sensorRef.get(subtractedExposureName)
635 # Both doSubtract and doDetection cannot be False
636
637 results = self.run(exposure=exposure,
638 selectSources=selectSources,
639 templateExposure=template.exposure,
640 templateSources=template.sources,
641 idFactory=idFactory,
642 calexpBackgroundExposure=calexpBackgroundExposure,
643 subtractedExposure=subtractedExposure)
644
645 if self.config.doWriteSources and results.diaSources is not None:
646 sensorRef.put(results.diaSources, self.config.coaddName + "Diff_diaSrc")
647 if self.config.doWriteWarpedExp:
648 sensorRef.put(results.warpedExposure, self.config.coaddName + "Diff_warpedExp")
649 if self.config.doWriteMatchedExp:
650 sensorRef.put(results.matchedExposure, self.config.coaddName + "Diff_matchedExp")
651 if self.config.doAddMetrics and self.config.doSelectSources:
652 sensorRef.put(results.selectSources, self.config.coaddName + "Diff_kernelSrc")
653 if self.config.doWriteSubtractedExp:
654 sensorRef.put(results.subtractedExposure, subtractedExposureName)
655 if self.config.doWriteScoreExp:
656 sensorRef.put(results.scoreExposure, self.config.coaddName + "Diff_scoreExp")
657 return results
658
659 def prepareCalibratedExposure(self, exposure, finalizedPsfApCorrCatalog=None):
660 """Prepare a calibrated exposure and apply finalized psf if so configured.
661
662 Parameters
663 ----------
665 Input exposure to adjust calibrations.
666 finalizedPsfApCorrCatalog : `lsst.afw.table.ExposureCatalog`, optional
667 Exposure catalog with finalized psf models and aperture correction
668 maps to be applied if config.doApplyFinalizedPsf=True. Catalog uses
669 the detector id for the catalog id, sorted on id for fast lookup.
670
671 Returns
672 -------
674 Exposure with adjusted calibrations.
675 """
676 detectorId = exposure.getInfo().getDetector().getId()
677
678 if finalizedPsfApCorrCatalog is not None:
679 row = finalizedPsfApCorrCatalog.find(detectorId)
680 if row is None:
681 self.log.warning("Detector id %s not found in finalizedPsfApCorrCatalog; "
682 "Using original psf.", detectorId)
683 else:
684 psf = row.getPsf()
685 apCorrMap = row.getApCorrMap()
686 if psf is None or apCorrMap is None:
687 self.log.warning("Detector id %s has None for psf/apCorrMap in "
688 "finalizedPsfApCorrCatalog; Using original psf.", detectorId)
689 else:
690 exposure.setPsf(psf)
691 exposure.info.setApCorrMap(apCorrMap)
692
693 return exposure
694
695 @timeMethod
696 def run(self, exposure=None, selectSources=None, templateExposure=None, templateSources=None,
697 idFactory=None, calexpBackgroundExposure=None, subtractedExposure=None):
698 """PSF matches, subtract two images and perform detection on the difference image.
699
700 Parameters
701 ----------
702 exposure : `lsst.afw.image.ExposureF`, optional
703 The science exposure, the minuend in the image subtraction.
704 Can be None only if ``config.doSubtract==False``.
705 selectSources : `lsst.afw.table.SourceCatalog`, optional
706 Identified sources on the science exposure. This catalog is used to
707 select sources in order to perform the AL PSF matching on stamp images
708 around them. The selection steps depend on config options and whether
709 ``templateSources`` and ``matchingSources`` specified.
710 templateExposure : `lsst.afw.image.ExposureF`, optional
711 The template to be subtracted from ``exposure`` in the image subtraction.
712 ``templateExposure`` is modified in place if ``config.doScaleTemplateVariance==True``.
713 The template exposure should cover the same sky area as the science exposure.
714 It is either a stich of patches of a coadd skymap image or a calexp
715 of the same pointing as the science exposure. Can be None only
716 if ``config.doSubtract==False`` and ``subtractedExposure`` is not None.
717 templateSources : `lsst.afw.table.SourceCatalog`, optional
718 Identified sources on the template exposure.
719 idFactory : `lsst.afw.table.IdFactory`
720 Generator object to assign ids to detected sources in the difference image.
721 calexpBackgroundExposure : `lsst.afw.image.ExposureF`, optional
722 Background exposure to be added back to the science exposure
723 if ``config.doAddCalexpBackground==True``
724 subtractedExposure : `lsst.afw.image.ExposureF`, optional
725 If ``config.doSubtract==False`` and ``config.doDetection==True``,
726 performs the post subtraction source detection only on this exposure.
727 Otherwise should be None.
728
729 Returns
730 -------
731 results : `lsst.pipe.base.Struct`
732 ``subtractedExposure`` : `lsst.afw.image.ExposureF`
733 Difference image.
734 ``scoreExposure`` : `lsst.afw.image.ExposureF` or `None`
735 The zogy score exposure, if calculated.
736 ``matchedExposure`` : `lsst.afw.image.ExposureF`
737 The matched PSF exposure.
738 ``subtractRes`` : `lsst.pipe.base.Struct`
739 The returned result structure of the ImagePsfMatchTask subtask.
740 ``diaSources`` : `lsst.afw.table.SourceCatalog`
741 The catalog of detected sources.
742 ``selectSources`` : `lsst.afw.table.SourceCatalog`
743 The input source catalog with optionally added Qa information.
744
745 Notes
746 -----
747 The following major steps are included:
748
749 - warp template coadd to match WCS of image
750 - PSF match image to warped template
751 - subtract image from PSF-matched, warped template
752 - detect sources
753 - measure sources
754
755 For details about the image subtraction configuration modes
756 see `lsst.ip.diffim`.
757 """
758 subtractRes = None
759 controlSources = None
760 subtractedExposure = None
761 scoreExposure = None
762 diaSources = None
763 kernelSources = None
764 # We'll clone exposure if modified but will still need the original
765 exposureOrig = exposure
766
767 if self.config.doAddCalexpBackground:
768 mi = exposure.getMaskedImage()
769 mi += calexpBackgroundExposure.getImage()
770
771 if not exposure.hasPsf():
772 raise pipeBase.TaskError("Exposure has no psf")
773 sciencePsf = exposure.getPsf()
774
775 if self.config.doSubtract:
776 if self.config.doScaleTemplateVariance:
777 self.log.info("Rescaling template variance")
778 templateVarFactor = self.scaleVariance.run(
779 templateExposure.getMaskedImage())
780 self.log.info("Template variance scaling factor: %.2f", templateVarFactor)
781 self.metadata.add("scaleTemplateVarianceFactor", templateVarFactor)
782 self.metadata.add("psfMatchingAlgorithm", self.config.subtract.name)
783
784 if self.config.subtract.name == 'zogy':
785 subtractRes = self.subtract.run(exposure, templateExposure, doWarping=True)
786 scoreExposure = subtractRes.scoreExp
787 subtractedExposure = subtractRes.diffExp
788 subtractRes.subtractedExposure = subtractedExposure
789 subtractRes.matchedExposure = None
790
791 elif self.config.subtract.name == 'al':
792 # compute scienceSigmaOrig: sigma of PSF of science image before pre-convolution
793 # Just need a rough estimate; average positions are fine
794 sciAvgPos = sciencePsf.getAveragePosition()
795 scienceSigmaOrig = sciencePsf.computeShape(sciAvgPos).getDeterminantRadius()
796
797 templatePsf = templateExposure.getPsf()
798 templateAvgPos = templatePsf.getAveragePosition()
799 templateSigma = templatePsf.computeShape(templateAvgPos).getDeterminantRadius()
800
801 # if requested, convolve the science exposure with its PSF
802 # (properly, this should be a cross-correlation, but our code does not yet support that)
803 # compute scienceSigmaPost: sigma of science exposure with pre-convolution, if done,
804 # else sigma of original science exposure
805 # TODO: DM-22762 This functional block should be moved into its own method
806 preConvPsf = None
807 if self.config.useScoreImageDetection:
808 self.log.warning("AL likelihood image: pre-convolution of PSF is not implemented.")
809 convControl = afwMath.ConvolutionControl()
810 # cannot convolve in place, so need a new image anyway
811 srcMI = exposure.maskedImage
812 exposure = exposure.clone() # New deep copy
813 srcPsf = sciencePsf
814 if self.config.useGaussianForPreConvolution:
815 self.log.info(
816 "AL likelihood image: Using Gaussian (sigma=%.2f) PSF estimation "
817 "for science image pre-convolution", scienceSigmaOrig)
818 # convolve with a simplified PSF model: a double Gaussian
819 kWidth, kHeight = sciencePsf.getLocalKernel().getDimensions()
820 preConvPsf = SingleGaussianPsf(kWidth, kHeight, scienceSigmaOrig)
821 else:
822 # convolve with science exposure's PSF model
823 self.log.info(
824 "AL likelihood image: Using the science image PSF for pre-convolution.")
825 preConvPsf = srcPsf
826 afwMath.convolve(exposure.maskedImage, srcMI, preConvPsf.getLocalKernel(), convControl)
827 scienceSigmaPost = scienceSigmaOrig*math.sqrt(2)
828 else:
829 scienceSigmaPost = scienceSigmaOrig
830
831 # If requested, find and select sources from the image
832 # else, AL subtraction will do its own source detection
833 # TODO: DM-22762 This functional block should be moved into its own method
834 if self.config.doSelectSources:
835 if selectSources is None:
836 self.log.warning("Src product does not exist; running detection, measurement,"
837 " selection")
838 # Run own detection and measurement; necessary in nightly processing
839 selectSources = self.subtract.getSelectSources(
840 exposure,
841 sigma=scienceSigmaPost,
842 doSmooth=not self.config.useScoreImageDetection,
843 idFactory=idFactory,
844 )
845
846 if self.config.doAddMetrics:
847 # Number of basis functions
848
849 nparam = len(makeKernelBasisList(self.subtract.config.kernel.active,
850 referenceFwhmPix=scienceSigmaPost*FwhmPerSigma,
851 targetFwhmPix=templateSigma*FwhmPerSigma))
852 # Modify the schema of all Sources
853 # DEPRECATED: This is a data dependent (nparam) output product schema
854 # outside the task constructor.
855 # NOTE: The pre-determination of nparam at this point
856 # may be incorrect as the template psf is warped later in
857 # ImagePsfMatchTask.matchExposures()
858 kcQa = KernelCandidateQa(nparam)
859 selectSources = kcQa.addToSchema(selectSources)
860 if self.config.kernelSourcesFromRef:
861 # match exposure sources to reference catalog
862 astromRet = self.astrometer.loadAndMatch(exposure=exposure, sourceCat=selectSources)
863 matches = astromRet.matches
864 elif templateSources:
865 # match exposure sources to template sources
867 mc.findOnlyClosest = False
868 matches = afwTable.matchRaDec(templateSources, selectSources, 1.0*geom.arcseconds,
869 mc)
870 else:
871 raise RuntimeError("doSelectSources=True and kernelSourcesFromRef=False,"
872 "but template sources not available. Cannot match science "
873 "sources with template sources. Run process* on data from "
874 "which templates are built.")
875
876 kernelSources = self.sourceSelector.run(selectSources, exposure=exposure,
877 matches=matches).sourceCat
878 random.shuffle(kernelSources, random.random)
879 controlSources = kernelSources[::self.config.controlStepSize]
880 kernelSources = [k for i, k in enumerate(kernelSources)
881 if i % self.config.controlStepSize]
882
883 if self.config.doSelectDcrCatalog:
884 redSelector = DiaCatalogSourceSelectorTask(
885 DiaCatalogSourceSelectorConfig(grMin=self.sourceSelector.config.grMax,
886 grMax=99.999))
887 redSources = redSelector.selectStars(exposure, selectSources, matches=matches).starCat
888 controlSources.extend(redSources)
889
890 blueSelector = DiaCatalogSourceSelectorTask(
891 DiaCatalogSourceSelectorConfig(grMin=-99.999,
892 grMax=self.sourceSelector.config.grMin))
893 blueSources = blueSelector.selectStars(exposure, selectSources,
894 matches=matches).starCat
895 controlSources.extend(blueSources)
896
897 if self.config.doSelectVariableCatalog:
898 varSelector = DiaCatalogSourceSelectorTask(
899 DiaCatalogSourceSelectorConfig(includeVariable=True))
900 varSources = varSelector.selectStars(exposure, selectSources, matches=matches).starCat
901 controlSources.extend(varSources)
902
903 self.log.info("Selected %d / %d sources for Psf matching (%d for control sample)",
904 len(kernelSources), len(selectSources), len(controlSources))
905
906 allresids = {}
907 # TODO: DM-22762 This functional block should be moved into its own method
908 if self.config.doUseRegister:
909 self.log.info("Registering images")
910
911 if templateSources is None:
912 # Run detection on the template, which is
913 # temporarily background-subtracted
914 # sigma of PSF of template image before warping
915 templateSources = self.subtract.getSelectSources(
916 templateExposure,
917 sigma=templateSigma,
918 doSmooth=True,
919 idFactory=idFactory
920 )
921
922 # Third step: we need to fit the relative astrometry.
923 #
924 wcsResults = self.fitAstrometry(templateSources, templateExposure, selectSources)
925 warpedExp = self.register.warpExposure(templateExposure, wcsResults.wcs,
926 exposure.getWcs(), exposure.getBBox())
927 templateExposure = warpedExp
928
929 # Create debugging outputs on the astrometric
930 # residuals as a function of position. Persistence
931 # not yet implemented; expected on (I believe) #2636.
932 if self.config.doDebugRegister:
933 # Grab matches to reference catalog
934 srcToMatch = {x.second.getId(): x.first for x in matches}
935
936 refCoordKey = wcsResults.matches[0].first.getTable().getCoordKey()
937 inCentroidKey = wcsResults.matches[0].second.getTable().getCentroidSlot().getMeasKey()
938 sids = [m.first.getId() for m in wcsResults.matches]
939 positions = [m.first.get(refCoordKey) for m in wcsResults.matches]
940 residuals = [m.first.get(refCoordKey).getOffsetFrom(wcsResults.wcs.pixelToSky(
941 m.second.get(inCentroidKey))) for m in wcsResults.matches]
942 allresids = dict(zip(sids, zip(positions, residuals)))
943
944 cresiduals = [m.first.get(refCoordKey).getTangentPlaneOffset(
945 wcsResults.wcs.pixelToSky(
946 m.second.get(inCentroidKey))) for m in wcsResults.matches]
947 colors = numpy.array([-2.5*numpy.log10(srcToMatch[x].get("g"))
948 + 2.5*numpy.log10(srcToMatch[x].get("r"))
949 for x in sids if x in srcToMatch.keys()])
950 dlong = numpy.array([r[0].asArcseconds() for s, r in zip(sids, cresiduals)
951 if s in srcToMatch.keys()])
952 dlat = numpy.array([r[1].asArcseconds() for s, r in zip(sids, cresiduals)
953 if s in srcToMatch.keys()])
954 idx1 = numpy.where(colors < self.sourceSelector.config.grMin)
955 idx2 = numpy.where((colors >= self.sourceSelector.config.grMin)
956 & (colors <= self.sourceSelector.config.grMax))
957 idx3 = numpy.where(colors > self.sourceSelector.config.grMax)
958 rms1Long = IqrToSigma*(
959 (numpy.percentile(dlong[idx1], 75) - numpy.percentile(dlong[idx1], 25)))
960 rms1Lat = IqrToSigma*(numpy.percentile(dlat[idx1], 75)
961 - numpy.percentile(dlat[idx1], 25))
962 rms2Long = IqrToSigma*(
963 (numpy.percentile(dlong[idx2], 75) - numpy.percentile(dlong[idx2], 25)))
964 rms2Lat = IqrToSigma*(numpy.percentile(dlat[idx2], 75)
965 - numpy.percentile(dlat[idx2], 25))
966 rms3Long = IqrToSigma*(
967 (numpy.percentile(dlong[idx3], 75) - numpy.percentile(dlong[idx3], 25)))
968 rms3Lat = IqrToSigma*(numpy.percentile(dlat[idx3], 75)
969 - numpy.percentile(dlat[idx3], 25))
970 self.log.info("Blue star offsets'': %.3f %.3f, %.3f %.3f",
971 numpy.median(dlong[idx1]), rms1Long,
972 numpy.median(dlat[idx1]), rms1Lat)
973 self.log.info("Green star offsets'': %.3f %.3f, %.3f %.3f",
974 numpy.median(dlong[idx2]), rms2Long,
975 numpy.median(dlat[idx2]), rms2Lat)
976 self.log.info("Red star offsets'': %.3f %.3f, %.3f %.3f",
977 numpy.median(dlong[idx3]), rms3Long,
978 numpy.median(dlat[idx3]), rms3Lat)
979
980 self.metadata.add("RegisterBlueLongOffsetMedian", numpy.median(dlong[idx1]))
981 self.metadata.add("RegisterGreenLongOffsetMedian", numpy.median(dlong[idx2]))
982 self.metadata.add("RegisterRedLongOffsetMedian", numpy.median(dlong[idx3]))
983 self.metadata.add("RegisterBlueLongOffsetStd", rms1Long)
984 self.metadata.add("RegisterGreenLongOffsetStd", rms2Long)
985 self.metadata.add("RegisterRedLongOffsetStd", rms3Long)
986
987 self.metadata.add("RegisterBlueLatOffsetMedian", numpy.median(dlat[idx1]))
988 self.metadata.add("RegisterGreenLatOffsetMedian", numpy.median(dlat[idx2]))
989 self.metadata.add("RegisterRedLatOffsetMedian", numpy.median(dlat[idx3]))
990 self.metadata.add("RegisterBlueLatOffsetStd", rms1Lat)
991 self.metadata.add("RegisterGreenLatOffsetStd", rms2Lat)
992 self.metadata.add("RegisterRedLatOffsetStd", rms3Lat)
993
994 # warp template exposure to match exposure,
995 # PSF match template exposure to exposure,
996 # then return the difference
997
998 # Return warped template... Construct sourceKernelCand list after subtract
999 self.log.info("Subtracting images")
1000 subtractRes = self.subtract.subtractExposures(
1001 templateExposure=templateExposure,
1002 scienceExposure=exposure,
1003 candidateList=kernelSources,
1004 convolveTemplate=self.config.convolveTemplate,
1005 doWarping=not self.config.doUseRegister
1006 )
1007 if self.config.useScoreImageDetection:
1008 scoreExposure = subtractRes.subtractedExposure
1009 else:
1010 subtractedExposure = subtractRes.subtractedExposure
1011
1012 if self.config.doDetection:
1013 self.log.info("Computing diffim PSF")
1014
1015 # Get Psf from the appropriate input image if it doesn't exist
1016 if subtractedExposure is not None and not subtractedExposure.hasPsf():
1017 if self.config.convolveTemplate:
1018 subtractedExposure.setPsf(exposure.getPsf())
1019 else:
1020 subtractedExposure.setPsf(templateExposure.getPsf())
1021
1022 # If doSubtract is False, then subtractedExposure was fetched from disk (above),
1023 # thus it may have already been decorrelated. Thus, we do not decorrelate if
1024 # doSubtract is False.
1025
1026 # NOTE: At this point doSubtract == True
1027 if self.config.doDecorrelation and self.config.doSubtract:
1028 preConvKernel = None
1029 if self.config.useGaussianForPreConvolution:
1030 preConvKernel = preConvPsf.getLocalKernel()
1031 if self.config.useScoreImageDetection:
1032 scoreExposure = self.decorrelate.run(exposureOrig, subtractRes.warpedExposure,
1033 scoreExposure,
1034 subtractRes.psfMatchingKernel,
1035 spatiallyVarying=self.config.doSpatiallyVarying,
1036 preConvKernel=preConvKernel,
1037 templateMatched=True,
1038 preConvMode=True).correctedExposure
1039 # Note that the subtracted exposure is always decorrelated,
1040 # even if the score image is used for detection
1041 subtractedExposure = self.decorrelate.run(exposureOrig, subtractRes.warpedExposure,
1042 subtractedExposure,
1043 subtractRes.psfMatchingKernel,
1044 spatiallyVarying=self.config.doSpatiallyVarying,
1045 preConvKernel=None,
1046 templateMatched=self.config.convolveTemplate,
1047 preConvMode=False).correctedExposure
1048 # END (if subtractAlgorithm == 'AL')
1049 # END (if self.config.doSubtract)
1050 if self.config.doDetection:
1051 self.log.info("Running diaSource detection")
1052
1053 # subtractedExposure - reserved for task return value
1054 # in zogy, it is always the proper difference image
1055 # in AL, it may be (yet) pre-convolved and/or decorrelated
1056 #
1057 # detectionExposure - controls which exposure to use for detection
1058 # in-place modifications will appear in task return
1059 if self.config.useScoreImageDetection:
1060 # zogy with score image detection enabled
1061 self.log.info("Detection, diffim rescaling and measurements are "
1062 "on AL likelihood or Zogy score image.")
1063 detectionExposure = scoreExposure
1064 else:
1065 # AL or zogy with no score image detection
1066 detectionExposure = subtractedExposure
1067
1068 # Rescale difference image variance plane
1069 if self.config.doScaleDiffimVariance:
1070 self.log.info("Rescaling diffim variance")
1071 diffimVarFactor = self.scaleVariance.run(detectionExposure.getMaskedImage())
1072 self.log.info("Diffim variance scaling factor: %.2f", diffimVarFactor)
1073 self.metadata.add("scaleDiffimVarianceFactor", diffimVarFactor)
1074
1075 # Erase existing detection mask planes
1076 mask = detectionExposure.getMaskedImage().getMask()
1077 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
1078
1079 table = afwTable.SourceTable.make(self.schema, idFactory)
1080 table.setMetadata(self.algMetadata)
1081 results = self.detection.run(
1082 table=table,
1083 exposure=detectionExposure,
1084 doSmooth=not self.config.useScoreImageDetection
1085 )
1086
1087 if self.config.doMerge:
1088 fpSet = results.fpSets.positive
1089 fpSet.merge(results.fpSets.negative, self.config.growFootprint,
1090 self.config.growFootprint, False)
1091 diaSources = afwTable.SourceCatalog(table)
1092 fpSet.makeSources(diaSources)
1093 self.log.info("Merging detections into %d sources", len(diaSources))
1094 else:
1095 diaSources = results.sources
1096 # Inject skySources before measurement.
1097 if self.config.doSkySources:
1098 skySourceFootprints = self.skySources.run(
1099 mask=detectionExposure.mask,
1100 seed=detectionExposure.info.id)
1101 if skySourceFootprints:
1102 for foot in skySourceFootprints:
1103 s = diaSources.addNew()
1104 s.setFootprint(foot)
1105 s.set(self.skySourceKey, True)
1106
1107 if self.config.doMeasurement:
1108 newDipoleFitting = self.config.doDipoleFitting
1109 self.log.info("Running diaSource measurement: newDipoleFitting=%r", newDipoleFitting)
1110 if not newDipoleFitting:
1111 # Just fit dipole in diffim
1112 self.measurement.run(diaSources, detectionExposure)
1113 else:
1114 # Use (matched) template and science image (if avail.) to constrain dipole fitting
1115 if self.config.doSubtract and 'matchedExposure' in subtractRes.getDict():
1116 self.measurement.run(diaSources, detectionExposure, exposure,
1117 subtractRes.matchedExposure)
1118 else:
1119 self.measurement.run(diaSources, detectionExposure, exposure)
1120 if self.config.doApCorr:
1121 self.applyApCorr.run(
1122 catalog=diaSources,
1123 apCorrMap=detectionExposure.getInfo().getApCorrMap()
1124 )
1125
1126 if self.config.doForcedMeasurement:
1127 # Run forced psf photometry on the PVI at the diaSource locations.
1128 # Copy the measured flux and error into the diaSource.
1129 forcedSources = self.forcedMeasurement.generateMeasCat(
1130 exposure, diaSources, detectionExposure.getWcs())
1131 self.forcedMeasurement.run(forcedSources, exposure, diaSources, detectionExposure.getWcs())
1132 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
1133 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
1134 "ip_diffim_forced_PsfFlux_instFlux", True)
1135 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
1136 "ip_diffim_forced_PsfFlux_instFluxErr", True)
1137 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
1138 "ip_diffim_forced_PsfFlux_area", True)
1139 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
1140 "ip_diffim_forced_PsfFlux_flag", True)
1141 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
1142 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", True)
1143 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
1144 "ip_diffim_forced_PsfFlux_flag_edge", True)
1145 for diaSource, forcedSource in zip(diaSources, forcedSources):
1146 diaSource.assign(forcedSource, mapper)
1147
1148 # Match with the calexp sources if possible
1149 if self.config.doMatchSources:
1150 if selectSources is not None:
1151 # Create key,val pair where key=diaSourceId and val=sourceId
1152 matchRadAsec = self.config.diaSourceMatchRadius
1153 matchRadPixel = matchRadAsec/exposure.getWcs().getPixelScale().asArcseconds()
1154
1155 srcMatches = afwTable.matchXy(selectSources, diaSources, matchRadPixel)
1156 srcMatchDict = dict([(srcMatch.second.getId(), srcMatch.first.getId()) for
1157 srcMatch in srcMatches])
1158 self.log.info("Matched %d / %d diaSources to sources",
1159 len(srcMatchDict), len(diaSources))
1160 else:
1161 self.log.warning("Src product does not exist; cannot match with diaSources")
1162 srcMatchDict = {}
1163
1164 # Create key,val pair where key=diaSourceId and val=refId
1165 refAstromConfig = AstrometryConfig()
1166 refAstromConfig.matcher.maxMatchDistArcSec = matchRadAsec
1167 refAstrometer = AstrometryTask(self.refObjLoader, config=refAstromConfig)
1168 astromRet = refAstrometer.run(exposure=exposure, sourceCat=diaSources)
1169 refMatches = astromRet.matches
1170 if refMatches is None:
1171 self.log.warning("No diaSource matches with reference catalog")
1172 refMatchDict = {}
1173 else:
1174 self.log.info("Matched %d / %d diaSources to reference catalog",
1175 len(refMatches), len(diaSources))
1176 refMatchDict = dict([(refMatch.second.getId(), refMatch.first.getId()) for
1177 refMatch in refMatches])
1178
1179 # Assign source Ids
1180 for diaSource in diaSources:
1181 sid = diaSource.getId()
1182 if sid in srcMatchDict:
1183 diaSource.set("srcMatchId", srcMatchDict[sid])
1184 if sid in refMatchDict:
1185 diaSource.set("refMatchId", refMatchDict[sid])
1186
1187 if self.config.doAddMetrics and self.config.doSelectSources:
1188 self.log.info("Evaluating metrics and control sample")
1189
1190 kernelCandList = []
1191 for cell in subtractRes.kernelCellSet.getCellList():
1192 for cand in cell.begin(False): # include bad candidates
1193 kernelCandList.append(cand)
1194
1195 # Get basis list to build control sample kernels
1196 basisList = kernelCandList[0].getKernel(KernelCandidateF.ORIG).getKernelList()
1197 nparam = len(kernelCandList[0].getKernel(KernelCandidateF.ORIG).getKernelParameters())
1198
1199 controlCandList = (
1200 diffimTools.sourceTableToCandidateList(controlSources,
1201 subtractRes.warpedExposure, exposure,
1202 self.config.subtract.kernel.active,
1203 self.config.subtract.kernel.active.detectionConfig,
1204 self.log, doBuild=True, basisList=basisList))
1205
1206 KernelCandidateQa.apply(kernelCandList, subtractRes.psfMatchingKernel,
1207 subtractRes.backgroundModel, dof=nparam)
1208 KernelCandidateQa.apply(controlCandList, subtractRes.psfMatchingKernel,
1209 subtractRes.backgroundModel)
1210
1211 if self.config.doDetection:
1212 KernelCandidateQa.aggregate(selectSources, self.metadata, allresids, diaSources)
1213 else:
1214 KernelCandidateQa.aggregate(selectSources, self.metadata, allresids)
1215
1216 self.runDebug(exposure, subtractRes, selectSources, kernelSources, diaSources)
1217 return pipeBase.Struct(
1218 subtractedExposure=subtractedExposure,
1219 scoreExposure=scoreExposure,
1220 warpedExposure=subtractRes.warpedExposure,
1221 matchedExposure=subtractRes.matchedExposure,
1222 subtractRes=subtractRes,
1223 diaSources=diaSources,
1224 selectSources=selectSources
1225 )
1226
1227 def fitAstrometry(self, templateSources, templateExposure, selectSources):
1228 """Fit the relative astrometry between templateSources and selectSources
1229
1230 Todo
1231 ----
1232
1233 Remove this method. It originally fit a new WCS to the template before calling register.run
1234 because our TAN-SIP fitter behaved badly for points far from CRPIX, but that's been fixed.
1235 It remains because a subtask overrides it.
1236 """
1237 results = self.register.run(templateSources, templateExposure.getWcs(),
1238 templateExposure.getBBox(), selectSources)
1239 return results
1240
1241 def runDebug(self, exposure, subtractRes, selectSources, kernelSources, diaSources):
1242 """Make debug plots and displays.
1243
1244 Todo
1245 ----
1246 Test and update for current debug display and slot names
1247 """
1248 import lsstDebug
1249 display = lsstDebug.Info(__name__).display
1250 showSubtracted = lsstDebug.Info(__name__).showSubtracted
1251 showPixelResiduals = lsstDebug.Info(__name__).showPixelResiduals
1252 showDiaSources = lsstDebug.Info(__name__).showDiaSources
1253 showDipoles = lsstDebug.Info(__name__).showDipoles
1254 maskTransparency = lsstDebug.Info(__name__).maskTransparency
1255 if display:
1256 disp = afwDisplay.getDisplay(frame=lsstDebug.frame)
1257 if not maskTransparency:
1258 maskTransparency = 0
1259 disp.setMaskTransparency(maskTransparency)
1260
1261 if display and showSubtracted:
1262 disp.mtv(subtractRes.subtractedExposure, title="Subtracted image")
1263 mi = subtractRes.subtractedExposure.getMaskedImage()
1264 x0, y0 = mi.getX0(), mi.getY0()
1265 with disp.Buffering():
1266 for s in diaSources:
1267 x, y = s.getX() - x0, s.getY() - y0
1268 ctype = "red" if s.get("flags_negative") else "yellow"
1269 if (s.get("base_PixelFlags_flag_interpolatedCenter")
1270 or s.get("base_PixelFlags_flag_saturatedCenter")
1271 or s.get("base_PixelFlags_flag_crCenter")):
1272 ptype = "x"
1273 elif (s.get("base_PixelFlags_flag_interpolated")
1274 or s.get("base_PixelFlags_flag_saturated")
1275 or s.get("base_PixelFlags_flag_cr")):
1276 ptype = "+"
1277 else:
1278 ptype = "o"
1279 disp.dot(ptype, x, y, size=4, ctype=ctype)
1280 lsstDebug.frame += 1
1281
1282 if display and showPixelResiduals and selectSources:
1283 nonKernelSources = []
1284 for source in selectSources:
1285 if source not in kernelSources:
1286 nonKernelSources.append(source)
1287
1288 diUtils.plotPixelResiduals(exposure,
1289 subtractRes.warpedExposure,
1290 subtractRes.subtractedExposure,
1291 subtractRes.kernelCellSet,
1292 subtractRes.psfMatchingKernel,
1293 subtractRes.backgroundModel,
1294 nonKernelSources,
1295 self.subtract.config.kernel.active.detectionConfig,
1296 origVariance=False)
1297 diUtils.plotPixelResiduals(exposure,
1298 subtractRes.warpedExposure,
1299 subtractRes.subtractedExposure,
1300 subtractRes.kernelCellSet,
1301 subtractRes.psfMatchingKernel,
1302 subtractRes.backgroundModel,
1303 nonKernelSources,
1304 self.subtract.config.kernel.active.detectionConfig,
1305 origVariance=True)
1306 if display and showDiaSources:
1307 flagChecker = SourceFlagChecker(diaSources)
1308 isFlagged = [flagChecker(x) for x in diaSources]
1309 isDipole = [x.get("ip_diffim_ClassificationDipole_value") for x in diaSources]
1310 diUtils.showDiaSources(diaSources, subtractRes.subtractedExposure, isFlagged, isDipole,
1311 frame=lsstDebug.frame)
1312 lsstDebug.frame += 1
1313
1314 if display and showDipoles:
1315 DipoleAnalysis().displayDipoles(subtractRes.subtractedExposure, diaSources,
1316 frame=lsstDebug.frame)
1317 lsstDebug.frame += 1
1318
1319 def checkTemplateIsSufficient(self, templateExposure):
1320 """Raise NoWorkFound if template coverage < requiredTemplateFraction
1321
1322 Parameters
1323 ----------
1324 templateExposure : `lsst.afw.image.ExposureF`
1325 The template exposure to check
1326
1327 Raises
1328 ------
1329 NoWorkFound
1330 Raised if fraction of good pixels, defined as not having NO_DATA
1331 set, is less then the configured requiredTemplateFraction
1332 """
1333 # Count the number of pixels with the NO_DATA mask bit set
1334 # counting NaN pixels is insufficient because pixels without data are often intepolated over)
1335 pixNoData = numpy.count_nonzero(templateExposure.mask.array
1336 & templateExposure.mask.getPlaneBitMask('NO_DATA'))
1337 pixGood = templateExposure.getBBox().getArea() - pixNoData
1338 self.log.info("template has %d good pixels (%.1f%%)", pixGood,
1339 100*pixGood/templateExposure.getBBox().getArea())
1340
1341 if pixGood/templateExposure.getBBox().getArea() < self.config.requiredTemplateFraction:
1342 message = ("Insufficient Template Coverage. (%.1f%% < %.1f%%) Not attempting subtraction. "
1343 "To force subtraction, set config requiredTemplateFraction=0." % (
1344 100*pixGood/templateExposure.getBBox().getArea(),
1345 100*self.config.requiredTemplateFraction))
1346 raise pipeBase.NoWorkFound(message)
1347
1348 def _getConfigName(self):
1349 """Return the name of the config dataset
1350 """
1351 return "%sDiff_config" % (self.config.coaddName,)
1352
1353 def _getMetadataName(self):
1354 """Return the name of the metadata dataset
1355 """
1356 return "%sDiff_metadata" % (self.config.coaddName,)
1357
1358 def getSchemaCatalogs(self):
1359 """Return a dict of empty catalogs for each catalog dataset produced by this task."""
1360 return {self.config.coaddName + "Diff_diaSrc": self.outputSchema}
1361
1362 @classmethod
1363 def _makeArgumentParser(cls):
1364 """Create an argument parser
1365 """
1366 parser = pipeBase.ArgumentParser(name=cls._DefaultName)
1367 parser.add_id_argument("--id", "calexp", help="data ID, e.g. --id visit=12345 ccd=1,2")
1368 parser.add_id_argument("--templateId", "calexp", doMakeDataRefList=True,
1369 help="Template data ID in case of calexp template,"
1370 " e.g. --templateId visit=6789")
1371 return parser
1372
1373
1374class ImageDifferenceFromTemplateConnections(ImageDifferenceTaskConnections,
1375 defaultTemplates={"coaddName": "goodSeeing"}
1376 ):
1377 inputTemplate = pipeBase.connectionTypes.Input(
1378 doc=("Warped template produced by GetMultiTractCoaddTemplate"),
1379 dimensions=("instrument", "visit", "detector"),
1380 storageClass="ExposureF",
1381 name="{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}",
1382 )
1383
1384 def __init__(self, *, config=None):
1385 super().__init__(config=config)
1386 # ImageDifferenceConnections will have removed one of these.
1387 # Make sure they're both gone, because no coadds are needed.
1388 if "coaddExposures" in self.inputs:
1389 self.inputs.remove("coaddExposures")
1390 if "dcrCoadds" in self.inputs:
1391 self.inputs.remove("dcrCoadds")
1392
1393
1394class ImageDifferenceFromTemplateConfig(ImageDifferenceConfig,
1395 pipelineConnections=ImageDifferenceFromTemplateConnections):
1396 pass
1397
1398
1399class ImageDifferenceFromTemplateTask(ImageDifferenceTask):
1400 ConfigClass = ImageDifferenceFromTemplateConfig
1401 _DefaultName = "imageDifference"
1402
1403 @lsst.utils.inheritDoc(pipeBase.PipelineTask)
1404 def runQuantum(self, butlerQC, inputRefs, outputRefs):
1405 inputs = butlerQC.get(inputRefs)
1406 self.log.info("Processing %s", butlerQC.quantum.dataId)
1407 self.checkTemplateIsSufficient(inputs['inputTemplate'])
1408 expId, expBits = butlerQC.quantum.dataId.pack("visit_detector",
1409 returnMaxBits=True)
1410 idFactory = self.makeIdFactory(expId=expId, expBits=expBits)
1411
1412 finalizedPsfApCorrCatalog = inputs.get("finalizedPsfApCorrCatalog", None)
1413 exposure = self.prepareCalibratedExposure(
1414 inputs["exposure"],
1415 finalizedPsfApCorrCatalog=finalizedPsfApCorrCatalog
1416 )
1417
1418 outputs = self.run(exposure=exposure,
1419 templateExposure=inputs['inputTemplate'],
1420 idFactory=idFactory)
1421
1422 # Consistency with runDataref gen2 handling
1423 if outputs.diaSources is None:
1424 del outputs.diaSources
1425 butlerQC.put(outputs, outputRefs)
1426
1427
1428class Winter2013ImageDifferenceConfig(ImageDifferenceConfig):
1429 winter2013WcsShift = pexConfig.Field(dtype=float, default=0.0,
1430 doc="Shift stars going into RegisterTask by this amount")
1431 winter2013WcsRms = pexConfig.Field(dtype=float, default=0.0,
1432 doc="Perturb stars going into RegisterTask by this amount")
1433
1434 def setDefaults(self):
1435 ImageDifferenceConfig.setDefaults(self)
1436 self.getTemplate.retarget(GetCalexpAsTemplateTask)
1437
1438
1439class Winter2013ImageDifferenceTask(ImageDifferenceTask):
1440 """!Image difference Task used in the Winter 2013 data challege.
1441 Enables testing the effects of registration shifts and scatter.
1442
1443 For use with winter 2013 simulated images:
1444 Use --templateId visit=88868666 for sparse data
1445 --templateId visit=22222200 for dense data (g)
1446 --templateId visit=11111100 for dense data (i)
1447 """
1448 ConfigClass = Winter2013ImageDifferenceConfig
1449 _DefaultName = "winter2013ImageDifference"
1450
1451 def __init__(self, **kwargs):
1452 ImageDifferenceTask.__init__(self, **kwargs)
1453
1454 def fitAstrometry(self, templateSources, templateExposure, selectSources):
1455 """Fit the relative astrometry between templateSources and selectSources"""
1456 if self.config.winter2013WcsShift > 0.0:
1457 offset = geom.Extent2D(self.config.winter2013WcsShift,
1458 self.config.winter2013WcsShift)
1459 cKey = templateSources[0].getTable().getCentroidSlot().getMeasKey()
1460 for source in templateSources:
1461 centroid = source.get(cKey)
1462 source.set(cKey, centroid + offset)
1463 elif self.config.winter2013WcsRms > 0.0:
1464 cKey = templateSources[0].getTable().getCentroidSlot().getMeasKey()
1465 for source in templateSources:
1466 offset = geom.Extent2D(self.config.winter2013WcsRms*numpy.random.normal(),
1467 self.config.winter2013WcsRms*numpy.random.normal())
1468 centroid = source.get(cKey)
1469 source.set(cKey, centroid + offset)
1470
1471 results = self.register.run(templateSources, templateExposure.getWcs(),
1472 templateExposure.getBBox(), selectSources)
1473 return results
Parameters to control convolution.
Definition: ConvolveImage.h:50
Custom catalog class for ExposureRecord/Table.
Definition: Exposure.h:311
A polymorphic functor base class for generating record IDs for a table.
Definition: IdFactory.h:21
Pass parameters to algorithms that match list of sources.
Definition: Match.h:45
A mapping between the keys of two Schemas, used to copy data between them.
Definition: SchemaMapper.h:21
Class for storing ordered metadata with comments.
Definition: PropertyList.h:68
Represent a PSF as a circularly symmetrical Gaussian.
def runQuantum(self, butlerQC, inputRefs, outputRefs)
Image difference Task used in the Winter 2013 data challege.
def fitAstrometry(self, templateSources, templateExposure, selectSources)
void convolve(OutImageT &convolvedImage, InImageT const &inImage, KernelT const &kernel, ConvolutionControl const &convolutionControl=ConvolutionControl())
Convolve an Image or MaskedImage with a Kernel, setting pixels of an existing output image.
int warpExposure(DestExposureT &destExposure, SrcExposureT const &srcExposure, WarpingControl const &control, typename DestExposureT::MaskedImageT::SinglePixel padValue=lsst::afw::math::edgePixel< typename DestExposureT::MaskedImageT >(typename lsst::afw::image::detail::image_traits< typename DestExposureT::MaskedImageT >::image_category()))
Warp (remap) one exposure to another.
SourceMatchVector matchXy(SourceCatalog const &cat1, SourceCatalog const &cat2, double radius, MatchControl const &mc=MatchControl())
Compute all tuples (s1,s2,d) where s1 belings to cat1, s2 belongs to cat2 and d, the distance between...
Definition: Match.cc:305
std::vector< Match< typename Cat1::Record, typename Cat2::Record > > matchRaDec(Cat1 const &cat1, Cat2 const &cat2, lsst::geom::Angle radius, MatchControl const &mc=MatchControl())
Compute all tuples (s1,s2,d) where s1 belings to cat1, s2 belongs to cat2 and d, the distance between...
Definition: Match.cc:158
def run(self, coaddExposures, bbox, wcs, dataIds, **kwargs)
Definition: getTemplate.py:596
def makeKernelBasisList(config, targetFwhmPix=None, referenceFwhmPix=None, basisDegGauss=None, basisSigmaGauss=None, metadata=None)
def checkTemplateIsSufficient(templateExposure, logger, requiredTemplateFraction=0.)
Fit spatial kernel using approximate fluxes for candidates, and solving a linear system of equations.