Coverage for python / lsst / ip / diffim / detectAndMeasure.py: 18%
525 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 09:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 09:17 +0000
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/>.
22import numpy as np
23import requests
24import os
26import lsst.afw.detection as afwDetection
27import lsst.afw.image as afwImage
28import lsst.afw.math as afwMath
29import lsst.afw.table as afwTable
30import lsst.daf.base as dafBase
31import lsst.geom
32from lsst.ip.diffim.utils import (evaluateMaskFraction, computeDifferenceImageMetrics,
33 populate_sattle_visit_cache)
34from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask
35from lsst.meas.algorithms import FindGlintTrailsTask, FindCosmicRaysConfig, findCosmicRays
36from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig
37import lsst.meas.deblender
38import lsst.meas.extensions.trailedSources # noqa: F401
39import lsst.meas.extensions.shapeHSM
40import lsst.pex.config as pexConfig
41from lsst.pex.exceptions import InvalidParameterError, LengthError
42import lsst.pipe.base as pipeBase
43import lsst.utils
44from lsst.utils.timer import timeMethod
46from . import DipoleFitTask
48__all__ = ["DetectAndMeasureConfig", "DetectAndMeasureTask",
49 "DetectAndMeasureScoreConfig", "DetectAndMeasureScoreTask"]
52class BadSubtractionError(pipeBase.AlgorithmError):
53 """Raised when the residuals in footprints of stars used to compute the
54 psf-matching kernel exceeds the configured maximum.
55 """
56 def __init__(self, *, ratio, threshold):
57 msg = ("The ratio of residual power in source footprints on the"
58 " difference image to the power in the footprints on the"
59 f" science image was {ratio}, which exceeds the maximum"
60 f" threshold of {threshold}")
61 super().__init__(msg)
62 self.ratio = ratio
63 self.threshold = threshold
65 @property
66 def metadata(self):
67 return {"ratio": self.ratio,
68 "threshold": self.threshold
69 }
72class NoDiaSourcesError(pipeBase.AlgorithmError):
73 """Raised when there are no diaSources detected on an image difference.
74 """
75 def __init__(self):
76 msg = ("No diaSources detected!")
77 super().__init__(msg)
79 @property
80 def metadata(self):
81 return {}
84class TooManyCosmicRays(pipeBase.AlgorithmError):
85 """Raised if the cosmic ray task fails with too many cosmics.
87 Parameters
88 ----------
89 maxCosmicRays : `int`
90 Maximum number of cosmic rays allowed.
91 """
92 def __init__(self, maxCosmicRays, **kwargs):
93 msg = f"Cosmic ray task found more than {maxCosmicRays} cosmics."
94 self.msg = msg
95 self._metadata = kwargs
96 super().__init__(msg, **kwargs)
97 self._metadata["maxCosmicRays"] = maxCosmicRays
99 def __str__(self):
100 # Exception doesn't handle **kwargs, so we need a custom str.
101 return f"{self.msg}: {self.metadata}"
103 @property
104 def metadata(self):
105 for key, value in self._metadata.items():
106 if not (isinstance(value, int) or isinstance(value, float) or isinstance(value, str)):
107 raise TypeError(f"{key} is of type {type(value)}, but only (int, float, str) are allowed.")
108 return self._metadata
111class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections,
112 dimensions=("instrument", "visit", "detector"),
113 defaultTemplates={"coaddName": "deep",
114 "warpTypeSuffix": "",
115 "fakesType": ""}):
116 science = pipeBase.connectionTypes.Input(
117 doc="Input science exposure.",
118 dimensions=("instrument", "visit", "detector"),
119 storageClass="ExposureF",
120 name="{fakesType}calexp"
121 )
122 matchedTemplate = pipeBase.connectionTypes.Input(
123 doc="Warped and PSF-matched template used to create the difference image.",
124 dimensions=("instrument", "visit", "detector"),
125 storageClass="ExposureF",
126 name="{fakesType}{coaddName}Diff_matchedExp",
127 )
128 difference = pipeBase.connectionTypes.Input(
129 doc="Result of subtracting template from science.",
130 dimensions=("instrument", "visit", "detector"),
131 storageClass="ExposureF",
132 name="{fakesType}{coaddName}Diff_differenceTempExp",
133 )
134 kernelSources = pipeBase.connectionTypes.Input(
135 doc="Final selection of sources used for psf matching.",
136 dimensions=("instrument", "visit", "detector"),
137 storageClass="SourceCatalog",
138 name="{fakesType}{coaddName}Diff_psfMatchSources"
139 )
140 outputSchema = pipeBase.connectionTypes.InitOutput(
141 doc="Schema (as an example catalog) for output DIASource catalog.",
142 storageClass="SourceCatalog",
143 name="{fakesType}{coaddName}Diff_diaSrc_schema",
144 )
145 diaSources = pipeBase.connectionTypes.Output(
146 doc="Detected diaSources on the difference image.",
147 dimensions=("instrument", "visit", "detector"),
148 storageClass="SourceCatalog",
149 name="{fakesType}{coaddName}Diff_diaSrc",
150 )
151 subtractedMeasuredExposure = pipeBase.connectionTypes.Output(
152 doc="Difference image with detection mask plane filled in.",
153 dimensions=("instrument", "visit", "detector"),
154 storageClass="ExposureF",
155 name="{fakesType}{coaddName}Diff_differenceExp",
156 )
157 differenceBackground = pipeBase.connectionTypes.Output(
158 doc="Background model that was subtracted from the difference image.",
159 dimensions=("instrument", "visit", "detector"),
160 storageClass="Background",
161 name="difference_background",
162 )
163 maskedStreaks = pipeBase.connectionTypes.Output(
164 doc='Catalog of streak fit parameters for the difference image.',
165 storageClass="ArrowNumpyDict",
166 dimensions=("instrument", "visit", "detector"),
167 name="{fakesType}{coaddName}Diff_streaks",
168 )
169 glintTrailInfo = pipeBase.connectionTypes.Output(
170 doc='Dict of fit parameters for glint trails in the catalog.',
171 storageClass="ArrowNumpyDict",
172 dimensions=("instrument", "visit", "detector"),
173 name="trailed_glints",
174 )
176 def __init__(self, *, config):
177 super().__init__(config=config)
178 if not (self.config.doCalculateResidualMetics):
179 self.inputs.remove("kernelSources")
180 if not (self.config.writeStreakInfo and self.config.doMaskStreaks):
181 self.outputs.remove("maskedStreaks")
182 if not (self.config.doSubtractBackground and self.config.doWriteBackground):
183 self.outputs.remove("differenceBackground")
184 if not (self.config.writeGlintInfo):
185 self.outputs.remove("glintTrailInfo")
188class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig,
189 pipelineConnections=DetectAndMeasureConnections):
190 """Config for DetectAndMeasureTask
191 """
192 doMerge = pexConfig.Field(
193 dtype=bool,
194 default=True,
195 doc="Merge positive and negative diaSources with grow radius "
196 "set by growFootprint"
197 )
198 doForcedMeasurement = pexConfig.Field(
199 dtype=bool,
200 default=True,
201 doc="Force photometer diaSource locations on PVI?"
202 )
203 doAddMetrics = pexConfig.Field(
204 dtype=bool,
205 default=False,
206 doc="Add columns to the source table to hold analysis metrics?"
207 )
208 doSubtractBackground = pexConfig.Field(
209 dtype=bool,
210 doc="Subtract a background model from the image before detection?",
211 default=True,
212 )
213 doWriteBackground = pexConfig.Field(
214 dtype=bool,
215 doc="Persist the fitted background model?",
216 default=False,
217 )
218 doCalculateResidualMetics = pexConfig.Field(
219 dtype=bool,
220 doc="Calculate metrics to assess image subtraction quality for the task"
221 "metadata?",
222 default=True,
223 )
224 subtractInitialBackground = pexConfig.ConfigurableField(
225 target=lsst.meas.algorithms.SubtractBackgroundTask,
226 doc="Task to perform intial background subtraction, before first detection pass.",
227 )
228 subtractFinalBackground = pexConfig.ConfigurableField(
229 target=lsst.meas.algorithms.SubtractBackgroundTask,
230 doc="Task to perform final background subtraction, after first detection pass.",
231 )
232 doFindCosmicRays = pexConfig.Field(
233 dtype=bool,
234 doc="Detect and mask cosmic rays on the difference image?"
235 "CRs can be interpolated over by setting cosmicray.keepCRs=False",
236 default=True,
237 )
238 cosmicray = pexConfig.ConfigField(
239 dtype=FindCosmicRaysConfig,
240 doc="Options for finding and masking cosmic rays",
241 )
242 detection = pexConfig.ConfigurableField(
243 target=SourceDetectionTask,
244 doc="Final source detection for diaSource measurement",
245 )
246 streakDetection = pexConfig.ConfigurableField(
247 target=SourceDetectionTask,
248 doc="Separate source detection used only for streak masking",
249 )
250 doDeblend = pexConfig.Field(
251 dtype=bool,
252 default=False,
253 doc="Deblend DIASources after detection?"
254 )
255 deblend = pexConfig.ConfigurableField(
256 target=lsst.meas.deblender.SourceDeblendTask,
257 doc="Task to split blended sources into their components."
258 )
259 measurement = pexConfig.ConfigurableField(
260 target=DipoleFitTask,
261 doc="Task to measure sources on the difference image.",
262 )
263 doApCorr = lsst.pex.config.Field(
264 dtype=bool,
265 default=True,
266 doc="Run subtask to apply aperture corrections"
267 )
268 applyApCorr = lsst.pex.config.ConfigurableField(
269 target=ApplyApCorrTask,
270 doc="Task to apply aperture corrections"
271 )
272 forcedMeasurement = pexConfig.ConfigurableField(
273 target=ForcedMeasurementTask,
274 doc="Task to force photometer science image at diaSource locations.",
275 )
276 growFootprint = pexConfig.Field(
277 dtype=int,
278 default=2,
279 doc="Grow positive and negative footprints by this many pixels before merging"
280 )
281 diaSourceMatchRadius = pexConfig.Field(
282 dtype=float,
283 default=0.5,
284 doc="Match radius (in arcseconds) for DiaSource to Source association"
285 )
286 doSkySources = pexConfig.Field(
287 dtype=bool,
288 default=False,
289 doc="Generate sky sources?",
290 )
291 skySources = pexConfig.ConfigurableField(
292 target=SkyObjectsTask,
293 doc="Generate sky sources",
294 )
295 doMaskStreaks = pexConfig.Field(
296 dtype=bool,
297 default=True,
298 doc="Turn on streak masking",
299 )
300 maskStreaks = pexConfig.ConfigurableField(
301 target=MaskStreaksTask,
302 doc="Subtask for masking streaks. Only used if doMaskStreaks is True. "
303 "Adds a mask plane to an exposure, with the mask plane name set by streakMaskName.",
304 )
305 streakBinFactor = pexConfig.Field(
306 dtype=int,
307 default=4,
308 doc="Bin scale factor to use when rerunning detection for masking streaks. "
309 "Only used if doMaskStreaks is True.",
310 )
311 writeStreakInfo = pexConfig.Field(
312 dtype=bool,
313 default=False,
314 doc="Record the parameters of any detected streaks. For LSST, this should be turned off except for "
315 "development work."
316 )
317 findGlints = pexConfig.ConfigurableField(
318 target=FindGlintTrailsTask,
319 doc="Subtask for finding glint trails, usually caused by satellites or debris."
320 )
321 writeGlintInfo = pexConfig.Field(
322 dtype=bool,
323 default=True,
324 doc="Record the parameters of any detected glint trails."
325 )
326 setPrimaryFlags = pexConfig.ConfigurableField(
327 target=SetPrimaryFlagsTask,
328 doc="Task to add isPrimary and deblending-related flags to the catalog."
329 )
330 badSourceFlags = lsst.pex.config.ListField(
331 dtype=str,
332 doc="Sources with any of these flags set are removed before writing the output catalog.",
333 default=("base_PixelFlags_flag_offimage",
334 "base_PixelFlags_flag_edge",
335 "base_PixelFlags_flag_interpolatedCenterAll",
336 "base_PixelFlags_flag_badCenter",
337 "base_PixelFlags_flag_edgeCenter",
338 "base_PixelFlags_flag_nodataCenter",
339 "base_PixelFlags_flag_saturatedCenter",
340 "base_PixelFlags_flag_saturated_templateCenter",
341 "base_PixelFlags_flag_spikeCenter",
342 ),
343 )
344 clearMaskPlanes = lsst.pex.config.ListField(
345 dtype=str,
346 doc="Mask planes to clear before running detection.",
347 default=("DETECTED", "DETECTED_NEGATIVE", "NOT_DEBLENDED", "STREAK"),
348 )
349 raiseOnBadSubtractionRatio = pexConfig.Field(
350 dtype=bool,
351 default=True,
352 doc="Raise an error if the ratio of power in detected footprints"
353 " on the difference image to the power in footprints on the science"
354 " image exceeds ``badSubtractionRatioThreshold``",
355 )
356 badSubtractionRatioThreshold = pexConfig.Field(
357 dtype=float,
358 default=0.2,
359 doc="Maximum ratio of power in footprints on the difference image to"
360 " the same footprints on the science image."
361 "Only used if ``raiseOnBadSubtractionRatio`` is set",
362 )
363 badSubtractionVariationThreshold = pexConfig.Field(
364 dtype=float,
365 default=0.4,
366 doc="Maximum standard deviation of the ratio of power in footprints on"
367 " the difference image to the same footprints on the science image."
368 "Only used if ``raiseOnBadSubtractionRatio`` is set",
369 )
370 raiseOnNoDiaSources = pexConfig.Field(
371 dtype=bool,
372 default=True,
373 doc="Raise an algorithm error if no diaSources are detected.",
374 )
375 run_sattle = pexConfig.Field(
376 dtype=bool,
377 default=False,
378 doc="If true, dia source bounding boxes will be sent for verification"
379 "to the sattle service."
380 )
381 sattle_historical = pexConfig.Field(
382 dtype=bool,
383 default=False,
384 doc="If re-running a pipeline that requires sattle, this should be set "
385 "to True. This will populate sattle's cache with the historic data "
386 "closest in time to the exposure."
387 )
388 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
390 def setDefaults(self):
391 # Background subtraction
392 # Use a small binsize for the first pass to reduce detections on glints
393 # and extended structures. Should not affect the detectability of
394 # faint diaSources
395 self.subtractInitialBackground.binSize = 8
396 self.subtractInitialBackground.useApprox = False
397 self.subtractInitialBackground.statisticsProperty = "MEDIAN"
398 self.subtractInitialBackground.doFilterSuperPixels = True
399 self.subtractInitialBackground.ignoredPixelMask = ["BAD",
400 "EDGE",
401 "DETECTED",
402 "DETECTED_NEGATIVE",
403 "NO_DATA",
404 ]
405 # Use a larger binsize for the final background subtraction, to reduce
406 # over-subtraction of bright objects.
407 self.subtractFinalBackground.binSize = 40
408 self.subtractFinalBackground.useApprox = False
409 self.subtractFinalBackground.statisticsProperty = "MEDIAN"
410 self.subtractFinalBackground.doFilterSuperPixels = True
411 self.subtractFinalBackground.ignoredPixelMask = ["BAD",
412 "EDGE",
413 "DETECTED",
414 "DETECTED_NEGATIVE",
415 "NO_DATA",
416 ]
417 # Cosmic ray detection
418 self.cosmicray.keepCRs = True # do not interpolate over detected CRs
419 # DiaSource Detection
420 self.detection.thresholdPolarity = "both"
421 self.detection.thresholdValue = 5.0
422 self.detection.reEstimateBackground = False
423 self.detection.thresholdType = "pixel_stdev"
424 self.detection.excludeMaskPlanes = []
426 # Copy configs for binned streak detection from the base detection task
427 self.streakDetection.thresholdType = self.detection.thresholdType
428 self.streakDetection.reEstimateBackground = False
429 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
430 self.streakDetection.thresholdValue = self.detection.thresholdValue
431 # Only detect positive streaks
432 self.streakDetection.thresholdPolarity = "positive"
433 # Do not grow detected mask for streaks
434 self.streakDetection.nSigmaToGrow = 0
435 # Set the streak mask along the entire fit line, not only where the
436 # detected mask is set.
437 self.maskStreaks.onlyMaskDetected = False
438 # Restrict streak masking from growing too large
439 self.maskStreaks.maxStreakWidth = 100
440 # Restrict the number of iterations allowed for fitting streaks
441 # When the fit is good it should solve quickly, and exit a bad fit quickly
442 self.maskStreaks.maxFitIter = 10
443 # Only mask to 2 sigma in width
444 self.maskStreaks.nSigmaMask = 2
445 # Threshold for including streaks after the Hough Transform.
446 # A lower value will detect more features that are less linear.
447 self.maskStreaks.absMinimumKernelHeight = 2
449 self.measurement.plugins.names |= ["ext_trailedSources_Naive",
450 "base_LocalPhotoCalib",
451 "base_LocalWcs",
452 "ext_shapeHSM_HsmSourceMoments",
453 "ext_shapeHSM_HsmPsfMoments",
454 "base_ClassificationSizeExtendedness",
455 ]
456 self.measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
457 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
458 self.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 5.0
459 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"]
460 self.forcedMeasurement.copyColumns = {
461 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"}
462 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid"
463 self.forcedMeasurement.slots.shape = None
465 # Keep track of which footprints contain streaks
466 self.measurement.plugins["base_PixelFlags"].masksFpAnywhere = [
467 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE", "SPIKE"]
468 self.measurement.plugins["base_PixelFlags"].masksFpCenter = [
469 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE", "SPIKE"]
470 self.skySources.avoidMask = ["DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA", "EDGE"]
472 def validate(self):
473 super().validate()
475 if self.run_sattle:
476 if not os.getenv("SATTLE_URI_BASE"):
477 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self,
478 "Sattle requested but SATTLE_URI_BASE "
479 "environment variable not set.")
482class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
483 """Detect and measure sources on a difference image.
484 """
485 ConfigClass = DetectAndMeasureConfig
486 _DefaultName = "detectAndMeasure"
488 def __init__(self, **kwargs):
489 super().__init__(**kwargs)
490 self.schema = afwTable.SourceTable.makeMinimalSchema()
492 self.algMetadata = dafBase.PropertyList()
493 if self.config.doSubtractBackground:
494 self.makeSubtask("subtractInitialBackground")
495 self.makeSubtask("subtractFinalBackground")
496 self.makeSubtask("detection", schema=self.schema)
497 if self.config.doDeblend:
498 self.makeSubtask("deblend", schema=self.schema)
499 self.makeSubtask("setPrimaryFlags", schema=self.schema, isSingleFrame=True)
500 self.makeSubtask("measurement", schema=self.schema,
501 algMetadata=self.algMetadata)
502 if self.config.doApCorr:
503 self.makeSubtask("applyApCorr", schema=self.measurement.schema)
504 if self.config.doForcedMeasurement:
505 self.schema.addField(
506 "ip_diffim_forced_PsfFlux_instFlux", "D",
507 "Forced PSF flux measured on the direct image.",
508 units="count")
509 self.schema.addField(
510 "ip_diffim_forced_PsfFlux_instFluxErr", "D",
511 "Forced PSF flux error measured on the direct image.",
512 units="count")
513 self.schema.addField(
514 "ip_diffim_forced_PsfFlux_area", "F",
515 "Forced PSF flux effective area of PSF.",
516 units="pixel")
517 self.schema.addField(
518 "ip_diffim_forced_PsfFlux_flag", "Flag",
519 "Forced PSF flux general failure flag.")
520 self.schema.addField(
521 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag",
522 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
523 self.schema.addField(
524 "ip_diffim_forced_PsfFlux_flag_edge", "Flag",
525 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
526 self.schema.addField(
527 "ip_diffim_forced_template_PsfFlux_instFlux", "D",
528 "Forced PSF flux measured on the template image.",
529 units="count")
530 self.schema.addField(
531 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D",
532 "Forced PSF flux error measured on the template image.",
533 units="count")
534 self.schema.addField(
535 "ip_diffim_forced_template_PsfFlux_area", "F",
536 "Forced template PSF flux effective area of PSF.",
537 units="pixel")
538 self.schema.addField(
539 "ip_diffim_forced_template_PsfFlux_flag", "Flag",
540 "Forced template PSF flux general failure flag.")
541 self.schema.addField(
542 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag",
543 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.")
544 self.schema.addField(
545 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag",
546 """Forced template PSF flux object was too close to the edge of the image """
547 """to use the full PSF model.""")
548 self.makeSubtask("forcedMeasurement", refSchema=self.schema)
550 self.schema.addField("refMatchId", "L", "unique id of reference catalog match")
551 self.schema.addField("srcMatchId", "L", "unique id of source match")
552 # Create the sky source task for use by metrics,
553 # even if sky sources are not added to the diaSource catalog
554 self.makeSubtask("skySources", schema=self.schema)
555 if self.config.doMaskStreaks:
556 self.makeSubtask("maskStreaks")
557 self.makeSubtask("streakDetection")
558 self.makeSubtask("findGlints")
559 self.schema.addField("glint_trail", "Flag", "DiaSource is part of a glint trail.")
560 self.schema.addField("reliability", type="F", doc="Reliability score of the DiaSource")
562 # To get the "merge_*" fields in the schema; have to re-initialize
563 # this later, once we have a peak schema post-detection.
564 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"])
566 # Check that the schema and config are consistent
567 for flag in self.config.badSourceFlags:
568 if flag not in self.schema:
569 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag)
571 # initialize InitOutputs
572 self.outputSchema = afwTable.SourceCatalog(self.schema)
573 self.outputSchema.getTable().setMetadata(self.algMetadata)
575 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
576 inputRefs: pipeBase.InputQuantizedConnection,
577 outputRefs: pipeBase.OutputQuantizedConnection):
578 inputs = butlerQC.get(inputRefs)
579 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
580 idFactory = idGenerator.make_table_id_factory()
581 # Specify the fields that `annotate` needs below, to ensure they
582 # exist, even as None.
583 measurementResults = pipeBase.Struct(
584 subtractedMeasuredExposure=None,
585 diaSources=None,
586 maskedStreaks=None,
587 differenceBackground=None,
588 )
589 try:
590 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
591 except pipeBase.AlgorithmError as e:
592 error = pipeBase.AnnotatedPartialOutputsError.annotate(
593 e,
594 self,
595 measurementResults.subtractedMeasuredExposure,
596 measurementResults.diaSources,
597 measurementResults.maskedStreaks,
598 log=self.log
599 )
600 butlerQC.put(measurementResults, outputRefs)
601 raise error from e
602 butlerQC.put(measurementResults, outputRefs)
604 @timeMethod
605 def run(self, science, matchedTemplate, difference, kernelSources=None,
606 idFactory=None, measurementResults=None):
607 """Detect and measure sources on a difference image.
609 The difference image will be convolved with a gaussian approximation of
610 the PSF to form a maximum likelihood image for detection.
611 Close positive and negative detections will optionally be merged into
612 dipole diaSources.
613 Sky sources, or forced detections in background regions, will optionally
614 be added, and the configured measurement algorithm will be run on all
615 detections.
617 Parameters
618 ----------
619 science : `lsst.afw.image.ExposureF`
620 Science exposure that the template was subtracted from.
621 matchedTemplate : `lsst.afw.image.ExposureF`
622 Warped and PSF-matched template that was used produce the
623 difference image.
624 difference : `lsst.afw.image.ExposureF`
625 Result of subtracting template from the science image.
626 kernelSources : `lsst.afw.table.SourceCatalog`, optional
627 Final selection of sources that was used for psf matching.
628 idFactory : `lsst.afw.table.IdFactory`, optional
629 Generator object used to assign ids to detected sources in the
630 difference image. Ids from this generator are not set until after
631 deblending and merging positive/negative peaks.
632 measurementResults : `lsst.pipe.base.Struct`, optional
633 Result struct that is modified to allow saving of partial outputs
634 for some failure conditions. If the task completes successfully,
635 this is also returned.
637 Returns
638 -------
639 measurementResults : `lsst.pipe.base.Struct`
641 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
642 Subtracted exposure with detection mask applied.
643 ``diaSources`` : `lsst.afw.table.SourceCatalog`
644 The catalog of detected sources.
645 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
646 Background that was subtracted from the difference image.
647 """
648 if measurementResults is None:
649 measurementResults = pipeBase.Struct()
650 if idFactory is None:
651 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
653 if self.config.doSubtractBackground:
654 # Run background subtraction before clearing the mask planes
655 detectionExposure = difference.clone()
656 background = self.subtractInitialBackground.run(detectionExposure).background
657 else:
658 detectionExposure = difference
659 background = afwMath.BackgroundList()
661 self._prepareInputs(detectionExposure)
663 if self.config.doFindCosmicRays and not self.config.doSubtractBackground:
664 # Detect and interpolate over any remaining cosmic rays
665 # This only needs to run once, right before the final detection.
666 self.findAndMaskCosmicRays(detectionExposure)
668 # Don't use the idFactory until after deblend+merge, so that we aren't
669 # generating ids that just get thrown away (footprint merge doesn't
670 # know about past ids).
671 table = afwTable.SourceTable.make(self.schema)
672 results = self.detection.run(
673 table=table,
674 exposure=detectionExposure,
675 doSmooth=True,
676 background=background
677 )
679 if self.config.doSubtractBackground:
680 # Run background subtraction again after detecting peaks
681 # but before measurement
682 # First update the mask using the detection image
683 difference.setMask(detectionExposure.mask)
684 background = self.subtractFinalBackground.run(difference).background
686 if self.config.doFindCosmicRays:
687 # Detect and interpolate over any remaining cosmic rays
688 self.findAndMaskCosmicRays(difference)
690 # Re-run detection to get final footprints
691 table = afwTable.SourceTable.make(self.schema)
692 results = self.detection.run(
693 table=table,
694 exposure=difference,
695 doSmooth=True,
696 background=background
697 )
698 measurementResults.differenceBackground = background
700 if self.config.doDeblend:
701 sources, positives, negatives = self._deblend(difference,
702 results.positive,
703 results.negative)
705 else:
706 positives = afwTable.SourceCatalog(self.schema)
707 results.positive.makeSources(positives)
708 negatives = afwTable.SourceCatalog(self.schema)
709 results.negative.makeSources(negatives)
710 sources = results.sources
712 self.processResults(science, matchedTemplate, difference,
713 sources, idFactory, kernelSources,
714 positives=positives,
715 negatives=negatives,
716 measurementResults=measurementResults)
717 return measurementResults
719 def _prepareInputs(self, difference):
720 """Ensure that we start with an empty detection and deblended mask.
722 Parameters
723 ----------
724 difference : `lsst.afw.image.ExposureF`
725 The difference image that will be used for detecting diaSources.
726 The mask plane will be modified in place.
728 Raises
729 ------
730 lsst.pipe.base.UpstreamFailureNoWorkFound
731 If the PSF is not usable for measurement.
732 """
733 # Check that we have a valid PSF now before we do more work
734 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
735 if np.isnan(sigma):
736 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.")
737 # Ensure that we start with an empty detection and deblended mask.
738 mask = difference.mask
739 for mp in self.config.clearMaskPlanes:
740 if mp not in mask.getMaskPlaneDict():
741 mask.addMaskPlane(mp)
742 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
744 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
745 kernelSources=None, positives=None, negatives=None, measurementResults=None):
746 """Measure and process the results of source detection.
748 Parameters
749 ----------
750 science : `lsst.afw.image.ExposureF`
751 Science exposure that the template was subtracted from.
752 matchedTemplate : `lsst.afw.image.ExposureF`
753 Warped and PSF-matched template that was used produce the
754 difference image.
755 difference : `lsst.afw.image.ExposureF`
756 Result of subtracting template from the science image.
757 sources : `lsst.afw.table.SourceCatalog`
758 Detected sources on the difference exposure.
759 idFactory : `lsst.afw.table.IdFactory`
760 Generator object used to assign ids to detected sources in the
761 difference image.
762 kernelSources : `lsst.afw.table.SourceCatalog`, optional
763 Final selection of sources that was used for psf matching.
764 positives : `lsst.afw.table.SourceCatalog`, optional
765 Positive polarity footprints.
766 negatives : `lsst.afw.table.SourceCatalog`, optional
767 Negative polarity footprints.
768 measurementResults : `lsst.pipe.base.Struct`, optional
769 Result struct that is modified to allow saving of partial outputs
770 for some failure conditions. If the task completes successfully,
771 this is also returned.
773 Returns
774 -------
775 measurementResults : `lsst.pipe.base.Struct`
777 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
778 Subtracted exposure with detection mask applied.
779 ``diaSources`` : `lsst.afw.table.SourceCatalog`
780 The catalog of detected sources.
781 """
782 if measurementResults is None:
783 measurementResults = pipeBase.Struct()
784 self.metadata["nUnmergedDiaSources"] = len(sources)
785 if self.config.doMerge:
786 # preserve peak schema, if there are any footprints
787 if len(positives) > 0:
788 peakSchema = positives[0].getFootprint().peaks.schema
789 elif len(negatives) > 0:
790 peakSchema = negatives[0].getFootprint().peaks.schema
791 else:
792 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
793 mergeList = afwDetection.FootprintMergeList(self.schema,
794 ["positive", "negative"], peakSchema)
795 initialDiaSources = afwTable.SourceCatalog(self.schema)
796 # Start with positive, as FootprintMergeList will self-merge the
797 # subsequent added catalogs, and we want to try to preserve
798 # deblended positive sources.
799 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0)
800 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0)
801 mergeList.getFinalSources(initialDiaSources)
802 # Flag as negative those sources that *only* came from the negative
803 # footprint set.
804 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \
805 ~initialDiaSources["merge_footprint_positive"]
806 self.log.info("Merging detections into %d sources", len(initialDiaSources))
807 else:
808 initialDiaSources = sources
810 # Assign source ids at the end: deblend/merge mean that we don't keep
811 # track of parents and children, we only care about the final ids.
812 for source in initialDiaSources:
813 source.setId(idFactory())
814 # Ensure sources added after this get correct ids.
815 initialDiaSources.getTable().setIdFactory(idFactory)
816 initialDiaSources.setMetadata(self.algMetadata)
818 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
820 if self.config.doMaskStreaks:
821 streakInfo = self._runStreakMasking(difference)
823 if self.config.doSkySources:
824 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
826 if not initialDiaSources.isContiguous():
827 initialDiaSources = initialDiaSources.copy(deep=True)
829 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
831 # Remove unphysical diaSources per config.badSourceFlags
832 diaSources = self._removeBadSources(initialDiaSources)
834 if self.config.run_sattle:
835 diaSources = self.filterSatellites(diaSources, science)
837 # Flag diaSources in glint trails, but do not remove them
838 diaSources, trail_parameters = self._find_glint_trails(diaSources)
839 if self.config.writeGlintInfo:
840 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo')
842 if self.config.doForcedMeasurement:
843 self.measureForcedSources(diaSources, science, difference.getWcs())
844 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
845 template=True)
847 # Clear the image plane for regions with NO_DATA.
848 # These regions are most often caused by insufficient template coverage.
849 # Do this for the final difference image after detection and measurement
850 # since the subtasks should all be configured to handle NO_DATA properly
851 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask('NO_DATA') > 0] = 0
853 measurementResults.subtractedMeasuredExposure = difference
855 if self.config.doMaskStreaks and self.config.writeStreakInfo:
856 measurementResults.maskedStreaks = streakInfo.maskedStreaks
858 if kernelSources is not None:
859 self.calculateMetrics(science, difference, diaSources, kernelSources)
861 if np.count_nonzero(~diaSources["sky_source"]) > 0:
862 measurementResults.diaSources = diaSources
863 elif self.config.raiseOnNoDiaSources:
864 raise NoDiaSourcesError()
865 elif len(diaSources) > 0:
866 # This option allows returning sky sources,
867 # even if there are no diaSources
868 measurementResults.diaSources = diaSources
869 self.log.info("Measured %d diaSources and %d sky sources",
870 np.count_nonzero(~diaSources["sky_source"]),
871 np.count_nonzero(diaSources["sky_source"])
872 )
873 return measurementResults
875 def _deblend(self, difference, positiveFootprints, negativeFootprints):
876 """Deblend the positive and negative footprints and return a catalog
877 containing just the children, and the deblended footprints.
879 Parameters
880 ----------
881 difference : `lsst.afw.image.Exposure`
882 Result of subtracting template from the science image.
883 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
884 Positive and negative polarity footprints measured on
885 ``difference`` to be deblended separately.
887 Returns
888 -------
889 sources : `lsst.afw.table.SourceCatalog`
890 Positive and negative deblended children.
891 positives, negatives : `lsst.afw.table.SourceCatalog`
892 Deblended positive and negative polarity sources with footprints
893 detected on ``difference``.
894 """
896 def deblend(footprints, negative=False):
897 """Deblend a positive or negative footprint set,
898 and return the deblended children.
900 Parameters
901 ----------
902 footprints : `lsst.afw.detection.FootprintSet`
903 negative : `bool`
904 Set True if the footprints contain negative fluxes
906 Returns
907 -------
908 sources : `lsst.afw.table.SourceCatalog`
909 """
910 sources = afwTable.SourceCatalog(self.schema)
911 footprints.makeSources(sources)
912 if negative:
913 # Invert the image so the deblender can run on positive peaks
914 difference_inverted = difference.clone()
915 difference_inverted.image *= -1
916 self.deblend.run(exposure=difference_inverted, sources=sources)
917 children = sources[sources["parent"] != 0]
918 # Set the heavy footprint pixel values back to reality
919 for child in children:
920 footprint = child.getFootprint()
921 array = footprint.getImageArray()
922 array *= -1
923 else:
924 self.deblend.run(exposure=difference, sources=sources)
925 self.setPrimaryFlags.run(sources)
926 children = sources["detect_isDeblendedSource"] == 1
927 sources = sources[children].copy(deep=True)
928 # Clear parents, so that measurement plugins behave correctly.
929 sources['parent'] = 0
930 return sources.copy(deep=True)
932 positives = deblend(positiveFootprints)
933 negatives = deblend(negativeFootprints, negative=True)
935 sources = afwTable.SourceCatalog(self.schema)
936 sources.reserve(len(positives) + len(negatives))
937 sources.extend(positives, deep=True)
938 sources.extend(negatives, deep=True)
939 if len(negatives) > 0:
940 sources[-len(negatives):]["is_negative"] = True
941 return sources, positives, negatives
943 def _removeBadSources(self, diaSources):
944 """Remove unphysical diaSources from the catalog.
946 Parameters
947 ----------
948 diaSources : `lsst.afw.table.SourceCatalog`
949 The catalog of detected sources.
951 Returns
952 -------
953 diaSources : `lsst.afw.table.SourceCatalog`
954 The updated catalog of detected sources, with any source that has a
955 flag in ``config.badSourceFlags`` set removed.
956 """
957 selector = np.ones(len(diaSources), dtype=bool)
958 for flag in self.config.badSourceFlags:
959 flags = diaSources[flag]
960 nBad = np.count_nonzero(flags)
961 if nBad > 0:
962 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag)
963 selector &= ~flags
964 nBadTotal = np.count_nonzero(~selector)
965 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal
966 self.log.info("Removed %d unphysical sources.", nBadTotal)
967 return diaSources[selector].copy(deep=True)
969 def _find_glint_trails(self, diaSources):
970 """Define a new flag column for diaSources that are in a glint trail.
972 Parameters
973 ----------
974 diaSources : `lsst.afw.table.SourceCatalog`
975 The catalog of detected sources.
977 Returns
978 -------
979 diaSources : `lsst.afw.table.SourceCatalog`
980 The updated catalog of detected sources, with a new bool column
981 called 'glint_trail' added.
983 trail_parameters : `dict`
984 Parameters of all the trails that were found.
985 """
986 if self.config.doSkySources:
987 # Do not include sky sources in glint detection
988 candidateDiaSources = diaSources[~diaSources["sky_source"]].copy(deep=True)
989 else:
990 candidateDiaSources = diaSources
991 trailed_glints = self.findGlints.run(candidateDiaSources)
992 glint_mask = [True if id in trailed_glints.trailed_ids else False for id in diaSources['id']]
993 if np.any(glint_mask):
994 diaSources['glint_trail'] = np.array(glint_mask)
996 slopes = np.array([trail.slope for trail in trailed_glints.parameters])
997 intercepts = np.array([trail.intercept for trail in trailed_glints.parameters])
998 stderrs = np.array([trail.stderr for trail in trailed_glints.parameters])
999 lengths = np.array([trail.length for trail in trailed_glints.parameters])
1000 angles = np.array([trail.angle for trail in trailed_glints.parameters])
1001 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths,
1002 'angles': angles}
1004 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
1006 return diaSources, trail_parameters
1008 def addSkySources(self, diaSources, mask, seed,
1009 subtask=None):
1010 """Add sources in empty regions of the difference image
1011 for measuring the background.
1013 Parameters
1014 ----------
1015 diaSources : `lsst.afw.table.SourceCatalog`
1016 The catalog of detected sources.
1017 mask : `lsst.afw.image.Mask`
1018 Mask plane for determining regions where Sky sources can be added.
1019 seed : `int`
1020 Seed value to initialize the random number generator.
1021 """
1022 if subtask is None:
1023 subtask = self.skySources
1024 if subtask.config.nSources <= 0:
1025 self.metadata[f"n_{subtask.getName()}"] = 0
1026 return
1027 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
1028 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints)
1030 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
1031 """Use (matched) template and science image to constrain dipole fitting.
1033 Parameters
1034 ----------
1035 diaSources : `lsst.afw.table.SourceCatalog`
1036 The catalog of detected sources.
1037 science : `lsst.afw.image.ExposureF`
1038 Science exposure that the template was subtracted from.
1039 difference : `lsst.afw.image.ExposureF`
1040 Result of subtracting template from the science image.
1041 matchedTemplate : `lsst.afw.image.ExposureF`
1042 Warped and PSF-matched template that was used produce the
1043 difference image.
1044 """
1045 # Ensure that the required mask planes are present
1046 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere:
1047 difference.mask.addMaskPlane(mp)
1048 # Note that this may not be correct if we convolved the science image.
1049 # In the future we may wish to persist the matchedScience image.
1050 self.measurement.run(diaSources, difference, science, matchedTemplate)
1051 if self.config.doApCorr:
1052 apCorrMap = difference.getInfo().getApCorrMap()
1053 if apCorrMap is None:
1054 self.log.warning("Difference image does not have valid aperture correction; skipping.")
1055 else:
1056 self.applyApCorr.run(
1057 catalog=diaSources,
1058 apCorrMap=apCorrMap,
1059 )
1061 def measureForcedSources(self, diaSources, image, wcs, template=False):
1062 """Perform forced measurement of the diaSources on a direct image.
1064 Parameters
1065 ----------
1066 diaSources : `lsst.afw.table.SourceCatalog`
1067 The catalog of detected sources.
1068 image: `lsst.afw.image.ExposureF`
1069 Exposure that the forced measurement is being performed on
1070 wcs : `lsst.afw.geom.SkyWcs`
1071 Coordinate system definition (wcs) for the exposure.
1072 template : `bool`
1073 Is the forced measurement being performed on the template?
1074 """
1075 # Run forced psf photometry on the image at the diaSource locations.
1076 # Copy the measured flux and error into the diaSource.
1077 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1078 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1080 if template:
1081 base_key = 'ip_diffim_forced_template_PsfFlux'
1082 else:
1083 base_key = 'ip_diffim_forced_PsfFlux'
1084 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
1085 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
1086 f"{base_key}_instFlux", True)
1087 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
1088 f"{base_key}_instFluxErr", True)
1089 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
1090 f"{base_key}_area", True)
1091 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
1092 f"{base_key}_flag", True)
1093 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
1094 f"{base_key}_flag_noGoodPixels", True)
1095 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
1096 f"{base_key}_flag_edge", True)
1097 for diaSource, forcedSource in zip(diaSources, forcedSources):
1098 diaSource.assign(forcedSource, mapper)
1100 def findAndMaskCosmicRays(self, difference):
1101 """Detect and mask cosmic rays on the difference image.
1103 Parameters
1104 ----------
1105 difference : `lsst.afw.image.Exposure`
1106 The background-subtracted difference image.
1107 The mask plane will be modified in place.
1108 """
1109 # This is run on a background-subtracted difference image, so the
1110 # remaining background should be ~0.
1111 medianBg = 0.
1112 try:
1113 crs = findCosmicRays(
1114 difference.maskedImage,
1115 difference.psf,
1116 medianBg,
1117 pexConfig.makePropertySet(self.config.cosmicray),
1118 self.config.cosmicray.keepCRs,
1119 )
1120 except LengthError:
1121 raise TooManyCosmicRays(self.config.cosmicray.nCrPixelMax) from None
1122 num = 0
1123 if crs is not None:
1124 mask = difference.maskedImage.mask
1125 crBit = mask.getPlaneBitMask("CR")
1126 afwDetection.setMaskFromFootprintList(mask, crs, crBit)
1127 num = len(crs)
1129 text = "masked" if self.config.cosmicray.keepCRs else "interpolated over"
1130 self.log.info("Identified and %s %s cosmic rays.", text, num)
1131 self.metadata["cosmic_ray_count"] = num
1133 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1134 """Add difference image QA metrics to the Task metadata.
1136 This may be used to produce corresponding metrics (see
1137 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1139 Parameters
1140 ----------
1141 science : `lsst.afw.image.ExposureF`
1142 Science exposure that was subtracted.
1143 difference : `lsst.afw.image.Exposure`
1144 The target difference image to calculate metrics for.
1145 diaSources : `lsst.afw.table.SourceCatalog`
1146 The catalog of detected sources.
1147 kernelSources : `lsst.afw.table.SourceCatalog`
1148 Final selection of sources that was used for psf matching.
1149 """
1150 mask = difference.mask
1151 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1152 self.metadata["nGoodPixels"] = np.sum(~badPix)
1153 self.metadata["nBadPixels"] = np.sum(badPix)
1154 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0
1155 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0
1156 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix)
1157 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix)
1158 detPosPix &= badPix
1159 detNegPix &= badPix
1160 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1161 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1163 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1164 for maskPlane in metricsMaskPlanes:
1165 try:
1166 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1167 except InvalidParameterError:
1168 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1
1169 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1171 if self.config.doSkySources:
1172 skySources = diaSources[diaSources["sky_source"]]
1173 else:
1174 skySources = None
1175 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1177 self.metadata["residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1178 self.metadata["residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1179 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1180 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1181 self.log.info("Mean, stdev of ratio of difference to science "
1182 "pixels in star footprints: %5.4f, %5.4f",
1183 self.metadata["residualFootprintRatioMean"],
1184 self.metadata["residualFootprintRatioStdev"])
1185 if self.config.raiseOnBadSubtractionRatio:
1186 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold:
1187 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean,
1188 threshold=self.config.badSubtractionRatioThreshold)
1189 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold:
1190 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev,
1191 threshold=self.config.badSubtractionVariationThreshold)
1193 def getSattleDiaSourceAllowlist(self, diaSources, science):
1194 """Query the sattle service and determine which diaSources are allowed.
1196 Parameters
1197 ----------
1198 diaSources : `lsst.afw.table.SourceCatalog`
1199 The catalog of detected sources.
1200 science : `lsst.afw.image.ExposureF`
1201 Science exposure that was subtracted.
1203 Returns
1204 ----------
1205 allow_list : `list` of `int`
1206 diaSourceIds of diaSources that can be made public.
1208 Raises
1209 ------
1210 requests.HTTPError
1211 Raised if sattle call does not return success.
1212 """
1213 wcs = science.getWcs()
1214 visit_info = science.getInfo().getVisitInfo()
1215 visit_id = visit_info.getId()
1216 sattle_uri_base = os.getenv('SATTLE_URI_BASE')
1218 dia_sources_json = []
1219 for source in diaSources:
1220 source_bbox = source.getFootprint().getBBox()
1221 corners = wcs.pixelToSky([lsst.geom.Point2D(c) for c in source_bbox.getCorners()])
1222 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()] for pt in corners]
1223 dia_sources_json.append({"diasource_id": source["id"], "bbox": bbox_radec})
1225 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(),
1226 "diasources": dia_sources_json, "historical": self.config.sattle_historical}
1228 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list',
1229 json=payload)
1231 # retry once if visit cache is not populated
1232 if sattle_output.status_code == 404:
1233 self.log.warning(f'Visit {visit_id} not found in sattle cache, re-sending')
1234 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1235 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', json=payload)
1237 sattle_output.raise_for_status()
1239 return sattle_output.json()['allow_list']
1241 def filterSatellites(self, diaSources, science):
1242 """Remove diaSources overlapping predicted satellite positions.
1244 Parameters
1245 ----------
1246 diaSources : `lsst.afw.table.SourceCatalog`
1247 The catalog of detected sources.
1248 science : `lsst.afw.image.ExposureF`
1249 Science exposure that was subtracted.
1251 Returns
1252 ----------
1253 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1254 Filtered catalog of diaSources
1255 """
1257 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1259 if allow_list:
1260 allow_set = set(allow_list)
1261 allowed_ids = [source['id'] in allow_set for source in diaSources]
1262 diaSources = diaSources[np.array(allowed_ids)].copy(deep=True)
1263 else:
1264 self.log.warning('Sattle allowlist is empty, all diaSources removed')
1265 diaSources = diaSources[0:0].copy(deep=True)
1266 return diaSources
1268 def _runStreakMasking(self, difference):
1269 """Do streak masking and optionally save the resulting streak
1270 fit parameters in a catalog.
1272 Only returns non-empty streakInfo if self.config.writeStreakInfo
1273 is set. The difference image is binned by self.config.streakBinFactor
1274 (and detection is run a second time) so that regions with lower
1275 surface brightness streaks are more likely to fall above the
1276 detection threshold.
1278 Parameters
1279 ----------
1280 difference: `lsst.afw.image.Exposure`
1281 The exposure in which to search for streaks. Must have a detection
1282 mask.
1284 Returns
1285 -------
1286 streakInfo: `lsst.pipe.base.Struct`
1287 ``rho`` : `np.ndarray`
1288 Angle of detected streak.
1289 ``theta`` : `np.ndarray`
1290 Distance from center of detected streak.
1291 ``sigma`` : `np.ndarray`
1292 Width of streak profile.
1293 ``reducedChi2`` : `np.ndarray`
1294 Reduced chi2 of the best-fit streak profile.
1295 ``modelMaximum`` : `np.ndarray`
1296 Peak value of the fit line profile.
1297 """
1298 maskedImage = difference.maskedImage
1299 # Bin the diffim to enhance low surface brightness streaks
1300 binnedMaskedImage = afwMath.binImage(maskedImage,
1301 self.config.streakBinFactor,
1302 self.config.streakBinFactor)
1303 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1304 binnedExposure.setMaskedImage(binnedMaskedImage)
1305 # Clear the DETECTED mask plane before streak detection
1306 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED')
1307 # Rerun detection to set the DETECTED mask plane on binnedExposure
1308 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1309 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1310 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True,
1311 sigma=sigma/self.config.streakBinFactor)
1312 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED')
1313 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1314 axis=0).repeat(self.config.streakBinFactor,
1315 axis=1)
1316 # Create new version of a diffim with DETECTED based on binnedExposure
1317 streakMaskedImage = maskedImage.clone()
1318 ysize, xsize = rescaledDetectedMaskPlane.shape
1319 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1320 # Detect streaks on this new version of the diffim
1321 streaks = self.maskStreaks.run(streakMaskedImage)
1322 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK')
1323 # Apply the new STREAK mask to the original diffim
1324 maskedImage.mask.array |= streakMaskPlane
1326 if self.config.writeStreakInfo:
1327 rhos = np.array([line.rho for line in streaks.lines])
1328 thetas = np.array([line.theta for line in streaks.lines])
1329 sigmas = np.array([line.sigma for line in streaks.lines])
1330 chi2s = np.array([line.reducedChi2 for line in streaks.lines])
1331 modelMaximums = np.array([line.modelMaximum for line in streaks.lines])
1332 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s,
1333 'modelMaximum': modelMaximums}
1334 else:
1335 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]),
1336 'reducedChi2': np.array([]), 'modelMaximum': np.array([])}
1337 return pipeBase.Struct(maskedStreaks=streakInfo)
1340class DetectAndMeasureScoreConnections(DetectAndMeasureConnections):
1341 scoreExposure = pipeBase.connectionTypes.Input(
1342 doc="Maximum likelihood image for detection.",
1343 dimensions=("instrument", "visit", "detector"),
1344 storageClass="ExposureF",
1345 name="{fakesType}{coaddName}Diff_scoreExp",
1346 )
1349class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1350 pipelineConnections=DetectAndMeasureScoreConnections):
1351 pass
1354class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1355 """Detect DIA sources using a score image,
1356 and measure the detections on the difference image.
1358 Source detection is run on the supplied score, or maximum likelihood,
1359 image. Note that no additional convolution will be done in this case.
1360 Close positive and negative detections will optionally be merged into
1361 dipole diaSources.
1362 Sky sources, or forced detections in background regions, will optionally
1363 be added, and the configured measurement algorithm will be run on all
1364 detections.
1365 """
1366 ConfigClass = DetectAndMeasureScoreConfig
1367 _DefaultName = "detectAndMeasureScore"
1369 @timeMethod
1370 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1371 idFactory=None):
1372 """Detect and measure sources on a score image.
1374 Parameters
1375 ----------
1376 science : `lsst.afw.image.ExposureF`
1377 Science exposure that the template was subtracted from.
1378 matchedTemplate : `lsst.afw.image.ExposureF`
1379 Warped and PSF-matched template that was used produce the
1380 difference image.
1381 difference : `lsst.afw.image.ExposureF`
1382 Result of subtracting template from the science image.
1383 scoreExposure : `lsst.afw.image.ExposureF`
1384 Score or maximum likelihood difference image
1385 kernelSources : `lsst.afw.table.SourceCatalog`
1386 Final selection of sources that was used for psf matching.
1387 idFactory : `lsst.afw.table.IdFactory`, optional
1388 Generator object used to assign ids to detected sources in the
1389 difference image. Ids from this generator are not set until after
1390 deblending and merging positive/negative peaks.
1392 Returns
1393 -------
1394 measurementResults : `lsst.pipe.base.Struct`
1396 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1397 Subtracted exposure with detection mask applied.
1398 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1399 The catalog of detected sources.
1400 """
1401 if idFactory is None:
1402 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
1404 self._prepareInputs(scoreExposure)
1406 # Don't use the idFactory until after deblend+merge, so that we aren't
1407 # generating ids that just get thrown away (footprint merge doesn't
1408 # know about past ids).
1409 table = afwTable.SourceTable.make(self.schema)
1410 results = self.detection.run(
1411 table=table,
1412 exposure=scoreExposure,
1413 doSmooth=False,
1414 )
1415 # Copy the detection mask from the Score image to the difference image
1416 difference.mask.assign(scoreExposure.mask, scoreExposure.getBBox())
1418 if self.config.doDeblend:
1419 sources, positives, negatives = self._deblend(difference,
1420 results.positive,
1421 results.negative)
1423 return self.processResults(science, matchedTemplate, difference,
1424 sources, idFactory, kernelSources,
1425 positives=positives,
1426 negatives=negatives)
1428 else:
1429 positives = afwTable.SourceCatalog(self.schema)
1430 results.positive.makeSources(positives)
1431 negatives = afwTable.SourceCatalog(self.schema)
1432 results.negative.makeSources(negatives)
1433 return self.processResults(science, matchedTemplate, difference,
1434 results.sources, idFactory, kernelSources,
1435 positives=positives,
1436 negatives=negatives)