27import lsst.afw.image
as afwImage
33 populate_sattle_visit_cache)
34from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask
36from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig
39import lsst.meas.extensions.shapeHSM
44from lsst.utils.timer
import timeMethod
46from .
import DipoleFitTask
48__all__ = [
"DetectAndMeasureConfig",
"DetectAndMeasureTask",
49 "DetectAndMeasureScoreConfig",
"DetectAndMeasureScoreTask"]
53 """Raised when the residuals in footprints of stars used to compute the
54 psf-matching kernel exceeds the configured maximum.
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}")
67 return {
"ratio": self.
ratio,
73 """Raised when there are no diaSources detected on an image difference.
76 msg = (
"No diaSources detected!")
85 dimensions=(
"instrument",
"visit",
"detector"),
86 defaultTemplates={
"coaddName":
"deep",
89 science = pipeBase.connectionTypes.Input(
90 doc=
"Input science exposure.",
91 dimensions=(
"instrument",
"visit",
"detector"),
92 storageClass=
"ExposureF",
93 name=
"{fakesType}calexp"
95 matchedTemplate = pipeBase.connectionTypes.Input(
96 doc=
"Warped and PSF-matched template used to create the difference image.",
97 dimensions=(
"instrument",
"visit",
"detector"),
98 storageClass=
"ExposureF",
99 name=
"{fakesType}{coaddName}Diff_matchedExp",
101 difference = pipeBase.connectionTypes.Input(
102 doc=
"Result of subtracting template from science.",
103 dimensions=(
"instrument",
"visit",
"detector"),
104 storageClass=
"ExposureF",
105 name=
"{fakesType}{coaddName}Diff_differenceTempExp",
107 kernelSources = pipeBase.connectionTypes.Input(
108 doc=
"Final selection of sources used for psf matching.",
109 dimensions=(
"instrument",
"visit",
"detector"),
110 storageClass=
"SourceCatalog",
111 name=
"{fakesType}{coaddName}Diff_psfMatchSources"
113 outputSchema = pipeBase.connectionTypes.InitOutput(
114 doc=
"Schema (as an example catalog) for output DIASource catalog.",
115 storageClass=
"SourceCatalog",
116 name=
"{fakesType}{coaddName}Diff_diaSrc_schema",
118 diaSources = pipeBase.connectionTypes.Output(
119 doc=
"Detected diaSources on the difference image.",
120 dimensions=(
"instrument",
"visit",
"detector"),
121 storageClass=
"SourceCatalog",
122 name=
"{fakesType}{coaddName}Diff_diaSrc",
124 subtractedMeasuredExposure = pipeBase.connectionTypes.Output(
125 doc=
"Difference image with detection mask plane filled in.",
126 dimensions=(
"instrument",
"visit",
"detector"),
127 storageClass=
"ExposureF",
128 name=
"{fakesType}{coaddName}Diff_differenceExp",
130 differenceBackground = pipeBase.connectionTypes.Output(
131 doc=
"Background model that was subtracted from the difference image.",
132 dimensions=(
"instrument",
"visit",
"detector"),
133 storageClass=
"Background",
134 name=
"difference_background",
136 maskedStreaks = pipeBase.connectionTypes.Output(
137 doc=
'Catalog of streak fit parameters for the difference image.',
138 storageClass=
"ArrowNumpyDict",
139 dimensions=(
"instrument",
"visit",
"detector"),
140 name=
"{fakesType}{coaddName}Diff_streaks",
142 glintTrailInfo = pipeBase.connectionTypes.Output(
143 doc=
'Dict of fit parameters for glint trails in the catalog.',
144 storageClass=
"ArrowNumpyDict",
145 dimensions=(
"instrument",
"visit",
"detector"),
146 name=
"trailed_glints",
149 def __init__(self, *, config):
150 super().__init__(config=config)
151 if not (self.config.doCalculateResidualMetics):
152 self.inputs.remove(
"kernelSources")
153 if not (self.config.writeStreakInfo
and self.config.doMaskStreaks):
154 self.outputs.remove(
"maskedStreaks")
155 if not (self.config.doSubtractBackground
and self.config.doWriteBackground):
156 self.outputs.remove(
"differenceBackground")
157 if not (self.config.writeGlintInfo):
158 self.outputs.remove(
"glintTrailInfo")
161class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig,
162 pipelineConnections=DetectAndMeasureConnections):
163 """Config for DetectAndMeasureTask
165 doMerge = pexConfig.Field(
168 doc=
"Merge positive and negative diaSources with grow radius "
169 "set by growFootprint"
171 doForcedMeasurement = pexConfig.Field(
174 doc=
"Force photometer diaSource locations on PVI?"
176 doAddMetrics = pexConfig.Field(
179 doc=
"Add columns to the source table to hold analysis metrics?"
181 doSubtractBackground = pexConfig.Field(
183 doc=
"Subtract a background model from the image before detection?",
186 doWriteBackground = pexConfig.Field(
188 doc=
"Persist the fitted background model?",
191 doCalculateResidualMetics = pexConfig.Field(
193 doc=
"Calculate metrics to assess image subtraction quality for the task"
197 subtractInitialBackground = pexConfig.ConfigurableField(
199 doc=
"Task to perform intial background subtraction, before first detection pass.",
201 subtractFinalBackground = pexConfig.ConfigurableField(
203 doc=
"Task to perform final background subtraction, after first detection pass.",
205 detection = pexConfig.ConfigurableField(
206 target=SourceDetectionTask,
207 doc=
"Final source detection for diaSource measurement",
209 streakDetection = pexConfig.ConfigurableField(
210 target=SourceDetectionTask,
211 doc=
"Separate source detection used only for streak masking",
213 doDeblend = pexConfig.Field(
216 doc=
"Deblend DIASources after detection?"
218 deblend = pexConfig.ConfigurableField(
220 doc=
"Task to split blended sources into their components."
222 measurement = pexConfig.ConfigurableField(
223 target=DipoleFitTask,
224 doc=
"Task to measure sources on the difference image.",
229 doc=
"Run subtask to apply aperture corrections"
232 target=ApplyApCorrTask,
233 doc=
"Task to apply aperture corrections"
235 forcedMeasurement = pexConfig.ConfigurableField(
236 target=ForcedMeasurementTask,
237 doc=
"Task to force photometer science image at diaSource locations.",
239 growFootprint = pexConfig.Field(
242 doc=
"Grow positive and negative footprints by this many pixels before merging"
244 diaSourceMatchRadius = pexConfig.Field(
247 doc=
"Match radius (in arcseconds) for DiaSource to Source association"
249 doSkySources = pexConfig.Field(
252 doc=
"Generate sky sources?",
254 skySources = pexConfig.ConfigurableField(
255 target=SkyObjectsTask,
256 doc=
"Generate sky sources",
258 doMaskStreaks = pexConfig.Field(
261 doc=
"Turn on streak masking",
263 maskStreaks = pexConfig.ConfigurableField(
264 target=MaskStreaksTask,
265 doc=
"Subtask for masking streaks. Only used if doMaskStreaks is True. "
266 "Adds a mask plane to an exposure, with the mask plane name set by streakMaskName.",
268 streakBinFactor = pexConfig.Field(
271 doc=
"Bin scale factor to use when rerunning detection for masking streaks. "
272 "Only used if doMaskStreaks is True.",
274 writeStreakInfo = pexConfig.Field(
277 doc=
"Record the parameters of any detected streaks. For LSST, this should be turned off except for "
280 findGlints = pexConfig.ConfigurableField(
281 target=FindGlintTrailsTask,
282 doc=
"Subtask for finding glint trails, usually caused by satellites or debris."
284 writeGlintInfo = pexConfig.Field(
287 doc=
"Record the parameters of any detected glint trails."
289 setPrimaryFlags = pexConfig.ConfigurableField(
290 target=SetPrimaryFlagsTask,
291 doc=
"Task to add isPrimary and deblending-related flags to the catalog."
295 doc=
"Sources with any of these flags set are removed before writing the output catalog.",
296 default=(
"base_PixelFlags_flag_offimage",
297 "base_PixelFlags_flag_edge",
298 "base_PixelFlags_flag_interpolatedCenterAll",
299 "base_PixelFlags_flag_badCenterAll",
300 "base_PixelFlags_flag_edgeCenterAll",
301 "base_PixelFlags_flag_nodataCenterAll",
302 "base_PixelFlags_flag_saturatedCenterAll",
303 "base_PixelFlags_flag_saturated_templateCenterAll",
308 doc=
"Mask planes to clear before running detection.",
309 default=(
"DETECTED",
"DETECTED_NEGATIVE",
"NOT_DEBLENDED",
"STREAK"),
311 raiseOnBadSubtractionRatio = pexConfig.Field(
314 doc=
"Raise an error if the ratio of power in detected footprints"
315 " on the difference image to the power in footprints on the science"
316 " image exceeds ``badSubtractionRatioThreshold``",
318 badSubtractionRatioThreshold = pexConfig.Field(
321 doc=
"Maximum ratio of power in footprints on the difference image to"
322 " the same footprints on the science image."
323 "Only used if ``raiseOnBadSubtractionRatio`` is set",
325 badSubtractionVariationThreshold = pexConfig.Field(
328 doc=
"Maximum standard deviation of the ratio of power in footprints on"
329 " the difference image to the same footprints on the science image."
330 "Only used if ``raiseOnBadSubtractionRatio`` is set",
332 raiseOnNoDiaSources = pexConfig.Field(
335 doc=
"Raise an algorithm error if no diaSources are detected.",
337 run_sattle = pexConfig.Field(
340 doc=
"If true, dia source bounding boxes will be sent for verification"
341 "to the sattle service."
343 sattle_historical = pexConfig.Field(
346 doc=
"If re-running a pipeline that requires sattle, this should be set "
347 "to True. This will populate sattle's cache with the historic data "
348 "closest in time to the exposure."
350 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
352 def setDefaults(self):
357 self.subtractInitialBackground.binSize = 8
358 self.subtractInitialBackground.useApprox =
False
359 self.subtractInitialBackground.statisticsProperty =
"MEDIAN"
360 self.subtractInitialBackground.doFilterSuperPixels =
True
361 self.subtractInitialBackground.ignoredPixelMask = [
"BAD",
369 self.subtractFinalBackground.binSize = 40
370 self.subtractFinalBackground.useApprox =
False
371 self.subtractFinalBackground.statisticsProperty =
"MEDIAN"
372 self.subtractFinalBackground.doFilterSuperPixels =
True
373 self.subtractFinalBackground.ignoredPixelMask = [
"BAD",
380 self.detection.thresholdPolarity =
"both"
381 self.detection.thresholdValue = 5.0
382 self.detection.reEstimateBackground =
False
383 self.detection.thresholdType =
"pixel_stdev"
384 self.detection.excludeMaskPlanes = []
387 self.streakDetection.thresholdType = self.detection.thresholdType
388 self.streakDetection.reEstimateBackground =
False
389 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
390 self.streakDetection.thresholdValue = self.detection.thresholdValue
392 self.streakDetection.thresholdPolarity =
"positive"
394 self.streakDetection.nSigmaToGrow = 0
397 self.maskStreaks.onlyMaskDetected =
False
399 self.maskStreaks.maxStreakWidth = 100
402 self.maskStreaks.maxFitIter = 10
404 self.maskStreaks.nSigmaMask = 2
407 self.maskStreaks.absMinimumKernelHeight = 2
409 self.measurement.plugins.names |= [
"ext_trailedSources_Naive",
410 "base_LocalPhotoCalib",
412 "ext_shapeHSM_HsmSourceMoments",
413 "ext_shapeHSM_HsmPsfMoments",
414 "base_ClassificationSizeExtendedness",
416 self.measurement.slots.psfShape =
"ext_shapeHSM_HsmPsfMoments"
417 self.measurement.slots.shape =
"ext_shapeHSM_HsmSourceMoments"
418 self.measurement.plugins[
"base_SdssCentroid"].maxDistToPeak = 5.0
419 self.forcedMeasurement.plugins = [
"base_TransformedCentroid",
"base_PsfFlux"]
420 self.forcedMeasurement.copyColumns = {
421 "id":
"objectId",
"parent":
"parentObjectId",
"coord_ra":
"coord_ra",
"coord_dec":
"coord_dec"}
422 self.forcedMeasurement.slots.centroid =
"base_TransformedCentroid"
423 self.forcedMeasurement.slots.shape =
None
426 self.measurement.plugins[
"base_PixelFlags"].masksFpAnywhere = [
427 "STREAK",
"INJECTED",
"INJECTED_TEMPLATE",
"HIGH_VARIANCE",
"SATURATED_TEMPLATE"]
428 self.measurement.plugins[
"base_PixelFlags"].masksFpCenter = [
429 "STREAK",
"INJECTED",
"INJECTED_TEMPLATE",
"HIGH_VARIANCE",
"SATURATED_TEMPLATE"]
430 self.skySources.avoidMask = [
"DETECTED",
"DETECTED_NEGATIVE",
"BAD",
"NO_DATA",
"EDGE"]
436 if not os.getenv(
"SATTLE_URI_BASE"):
437 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self,
438 "Sattle requested but SATTLE_URI_BASE "
439 "environment variable not set.")
442class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
443 """Detect and measure sources on a difference image.
445 ConfigClass = DetectAndMeasureConfig
446 _DefaultName =
"detectAndMeasure"
448 def __init__(self, **kwargs):
449 super().__init__(**kwargs)
450 self.schema = afwTable.SourceTable.makeMinimalSchema()
453 if self.config.doSubtractBackground:
454 self.makeSubtask(
"subtractInitialBackground")
455 self.makeSubtask(
"subtractFinalBackground")
456 self.makeSubtask(
"detection", schema=self.schema)
457 if self.config.doDeblend:
458 self.makeSubtask(
"deblend", schema=self.schema)
459 self.makeSubtask(
"setPrimaryFlags", schema=self.schema, isSingleFrame=
True)
460 self.makeSubtask(
"measurement", schema=self.schema,
461 algMetadata=self.algMetadata)
462 if self.config.doApCorr:
463 self.makeSubtask(
"applyApCorr", schema=self.measurement.schema)
464 if self.config.doForcedMeasurement:
465 self.schema.addField(
466 "ip_diffim_forced_PsfFlux_instFlux",
"D",
467 "Forced PSF flux measured on the direct image.",
469 self.schema.addField(
470 "ip_diffim_forced_PsfFlux_instFluxErr",
"D",
471 "Forced PSF flux error measured on the direct image.",
473 self.schema.addField(
474 "ip_diffim_forced_PsfFlux_area",
"F",
475 "Forced PSF flux effective area of PSF.",
477 self.schema.addField(
478 "ip_diffim_forced_PsfFlux_flag",
"Flag",
479 "Forced PSF flux general failure flag.")
480 self.schema.addField(
481 "ip_diffim_forced_PsfFlux_flag_noGoodPixels",
"Flag",
482 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
483 self.schema.addField(
484 "ip_diffim_forced_PsfFlux_flag_edge",
"Flag",
485 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
486 self.schema.addField(
487 "ip_diffim_forced_template_PsfFlux_instFlux",
"D",
488 "Forced PSF flux measured on the template image.",
490 self.schema.addField(
491 "ip_diffim_forced_template_PsfFlux_instFluxErr",
"D",
492 "Forced PSF flux error measured on the template image.",
494 self.schema.addField(
495 "ip_diffim_forced_template_PsfFlux_area",
"F",
496 "Forced template PSF flux effective area of PSF.",
498 self.schema.addField(
499 "ip_diffim_forced_template_PsfFlux_flag",
"Flag",
500 "Forced template PSF flux general failure flag.")
501 self.schema.addField(
502 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels",
"Flag",
503 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.")
504 self.schema.addField(
505 "ip_diffim_forced_template_PsfFlux_flag_edge",
"Flag",
506 """Forced template PSF flux object was too close to the edge of the image """
507 """to use the full PSF model.""")
508 self.makeSubtask(
"forcedMeasurement", refSchema=self.schema)
510 self.schema.addField(
"refMatchId",
"L",
"unique id of reference catalog match")
511 self.schema.addField(
"srcMatchId",
"L",
"unique id of source match")
514 self.makeSubtask(
"skySources", schema=self.schema)
515 if self.config.doMaskStreaks:
516 self.makeSubtask(
"maskStreaks")
517 self.makeSubtask(
"streakDetection")
518 self.makeSubtask(
"findGlints")
519 self.schema.addField(
"glint_trail",
"Flag",
"DiaSource is part of a glint trail.")
520 self.schema.addField(
"reliability", type=
"F", doc=
"Reliability score of the DiaSource")
527 for flag
in self.config.badSourceFlags:
528 if flag
not in self.schema:
529 raise pipeBase.InvalidQuantumError(
"Field %s not in schema" % flag)
533 self.outputSchema.getTable().setMetadata(self.algMetadata)
535 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
536 inputRefs: pipeBase.InputQuantizedConnection,
537 outputRefs: pipeBase.OutputQuantizedConnection):
538 inputs = butlerQC.get(inputRefs)
539 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
540 idFactory = idGenerator.make_table_id_factory()
543 measurementResults = pipeBase.Struct(
544 subtractedMeasuredExposure=
None,
547 differenceBackground=
None,
550 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
551 except pipeBase.AlgorithmError
as e:
552 error = pipeBase.AnnotatedPartialOutputsError.annotate(
555 measurementResults.subtractedMeasuredExposure,
556 measurementResults.diaSources,
557 measurementResults.maskedStreaks,
560 butlerQC.put(measurementResults, outputRefs)
562 butlerQC.put(measurementResults, outputRefs)
565 def run(self, science, matchedTemplate, difference, kernelSources=None,
566 idFactory=None, measurementResults=None):
567 """Detect and measure sources on a difference image.
569 The difference image will be convolved with a gaussian approximation of
570 the PSF to form a maximum likelihood image for detection.
571 Close positive and negative detections will optionally be merged into
573 Sky sources, or forced detections in background regions, will optionally
574 be added, and the configured measurement algorithm will be run on all
579 science : `lsst.afw.image.ExposureF`
580 Science exposure that the template was subtracted from.
581 matchedTemplate : `lsst.afw.image.ExposureF`
582 Warped and PSF-matched template that was used produce the
584 difference : `lsst.afw.image.ExposureF`
585 Result of subtracting template from the science image.
586 kernelSources : `lsst.afw.table.SourceCatalog`, optional
587 Final selection of sources that was used for psf matching.
588 idFactory : `lsst.afw.table.IdFactory`, optional
589 Generator object used to assign ids to detected sources in the
590 difference image. Ids from this generator are not set until after
591 deblending and merging positive/negative peaks.
592 measurementResults : `lsst.pipe.base.Struct`, optional
593 Result struct that is modified to allow saving of partial outputs
594 for some failure conditions. If the task completes successfully,
595 this is also returned.
599 measurementResults : `lsst.pipe.base.Struct`
601 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
602 Subtracted exposure with detection mask applied.
603 ``diaSources`` : `lsst.afw.table.SourceCatalog`
604 The catalog of detected sources.
605 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
606 Background that was subtracted from the difference image.
608 if measurementResults
is None:
609 measurementResults = pipeBase.Struct()
610 if idFactory
is None:
613 if self.config.doSubtractBackground:
615 detectionExposure = difference.clone()
616 background = self.subtractInitialBackground.run(detectionExposure).background
618 detectionExposure = difference
621 self._prepareInputs(detectionExposure)
626 table = afwTable.SourceTable.make(self.schema)
627 results = self.detection.run(
629 exposure=detectionExposure,
631 background=background
634 if self.config.doSubtractBackground:
638 difference.setMask(detectionExposure.mask)
639 background = self.subtractFinalBackground.run(difference).background
642 table = afwTable.SourceTable.make(self.schema)
643 results = self.detection.run(
647 background=background
649 measurementResults.differenceBackground = background
651 if self.config.doDeblend:
652 sources, positives, negatives = self._deblend(difference,
658 results.positive.makeSources(positives)
660 results.negative.makeSources(negatives)
661 sources = results.sources
663 self.processResults(science, matchedTemplate, difference,
664 sources, idFactory, kernelSources,
667 measurementResults=measurementResults)
668 return measurementResults
670 def _prepareInputs(self, difference):
671 """Ensure that we start with an empty detection and deblended mask.
675 difference : `lsst.afw.image.ExposureF`
676 The difference image that will be used for detecting diaSources.
677 The mask plane will be modified in place.
681 lsst.pipe.base.UpstreamFailureNoWorkFound
682 If the PSF is not usable for measurement.
685 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
687 raise pipeBase.UpstreamFailureNoWorkFound(
"Invalid PSF detected! PSF width evaluates to NaN.")
689 mask = difference.mask
690 for mp
in self.config.clearMaskPlanes:
691 if mp
not in mask.getMaskPlaneDict():
692 mask.addMaskPlane(mp)
693 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
695 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
696 kernelSources=None, positives=None, negatives=None, measurementResults=None):
697 """Measure and process the results of source detection.
701 science : `lsst.afw.image.ExposureF`
702 Science exposure that the template was subtracted from.
703 matchedTemplate : `lsst.afw.image.ExposureF`
704 Warped and PSF-matched template that was used produce the
706 difference : `lsst.afw.image.ExposureF`
707 Result of subtracting template from the science image.
708 sources : `lsst.afw.table.SourceCatalog`
709 Detected sources on the difference exposure.
710 idFactory : `lsst.afw.table.IdFactory`
711 Generator object used to assign ids to detected sources in the
713 kernelSources : `lsst.afw.table.SourceCatalog`, optional
714 Final selection of sources that was used for psf matching.
715 positives : `lsst.afw.table.SourceCatalog`, optional
716 Positive polarity footprints.
717 negatives : `lsst.afw.table.SourceCatalog`, optional
718 Negative polarity footprints.
719 measurementResults : `lsst.pipe.base.Struct`, optional
720 Result struct that is modified to allow saving of partial outputs
721 for some failure conditions. If the task completes successfully,
722 this is also returned.
726 measurementResults : `lsst.pipe.base.Struct`
728 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
729 Subtracted exposure with detection mask applied.
730 ``diaSources`` : `lsst.afw.table.SourceCatalog`
731 The catalog of detected sources.
733 if measurementResults
is None:
734 measurementResults = pipeBase.Struct()
735 self.metadata[
"nUnmergedDiaSources"] = len(sources)
736 if self.config.doMerge:
738 if len(positives) > 0:
739 peakSchema = positives[0].getFootprint().peaks.schema
740 elif len(negatives) > 0:
741 peakSchema = negatives[0].getFootprint().peaks.schema
743 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
744 mergeList = afwDetection.FootprintMergeList(self.schema,
745 [
"positive",
"negative"], peakSchema)
750 mergeList.addCatalog(initialDiaSources.table, positives,
"positive", minNewPeakDist=0)
751 mergeList.addCatalog(initialDiaSources.table, negatives,
"negative", minNewPeakDist=0)
752 mergeList.getFinalSources(initialDiaSources)
755 initialDiaSources[
"is_negative"] = initialDiaSources[
"merge_footprint_negative"] & \
756 ~initialDiaSources[
"merge_footprint_positive"]
757 self.log.info(
"Merging detections into %d sources", len(initialDiaSources))
759 initialDiaSources = sources
763 for source
in initialDiaSources:
766 initialDiaSources.getTable().setIdFactory(idFactory)
767 initialDiaSources.setMetadata(self.algMetadata)
769 self.metadata[
"nMergedDiaSources"] = len(initialDiaSources)
771 if self.config.doMaskStreaks:
772 streakInfo = self._runStreakMasking(difference)
774 if self.config.doSkySources:
775 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
777 if not initialDiaSources.isContiguous():
778 initialDiaSources = initialDiaSources.copy(deep=
True)
780 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
783 diaSources = self._removeBadSources(initialDiaSources)
785 if self.config.run_sattle:
786 diaSources = self.filterSatellites(diaSources, science)
789 diaSources, trail_parameters = self._find_glint_trails(diaSources)
790 if self.config.writeGlintInfo:
791 measurementResults.mergeItems(trail_parameters,
'glintTrailInfo')
793 if self.config.doForcedMeasurement:
794 self.measureForcedSources(diaSources, science, difference.getWcs())
795 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
802 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask(
'NO_DATA') > 0] = 0
804 measurementResults.subtractedMeasuredExposure = difference
806 if self.config.doMaskStreaks
and self.config.writeStreakInfo:
807 measurementResults.maskedStreaks = streakInfo.maskedStreaks
809 if kernelSources
is not None:
810 self.calculateMetrics(science, difference, diaSources, kernelSources)
812 if np.count_nonzero(~diaSources[
"sky_source"]) > 0:
813 measurementResults.diaSources = diaSources
814 elif self.config.raiseOnNoDiaSources:
816 elif len(diaSources) > 0:
819 measurementResults.diaSources = diaSources
820 self.log.info(
"Measured %d diaSources and %d sky sources",
821 np.count_nonzero(~diaSources[
"sky_source"]),
822 np.count_nonzero(diaSources[
"sky_source"])
824 return measurementResults
826 def _deblend(self, difference, positiveFootprints, negativeFootprints):
827 """Deblend the positive and negative footprints and return a catalog
828 containing just the children, and the deblended footprints.
832 difference : `lsst.afw.image.Exposure`
833 Result of subtracting template from the science image.
834 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
835 Positive and negative polarity footprints measured on
836 ``difference`` to be deblended separately.
840 sources : `lsst.afw.table.SourceCatalog`
841 Positive and negative deblended children.
842 positives, negatives : `lsst.afw.table.SourceCatalog`
843 Deblended positive and negative polarity sources with footprints
844 detected on ``difference``.
847 def deblend(footprints, negative=False):
848 """Deblend a positive or negative footprint set,
849 and return the deblended children.
853 footprints : `lsst.afw.detection.FootprintSet`
855 Set True if the footprints contain negative fluxes
859 sources : `lsst.afw.table.SourceCatalog`
862 footprints.makeSources(sources)
865 difference_inverted = difference.clone()
866 difference_inverted.image *= -1
867 self.deblend.run(exposure=difference_inverted, sources=sources)
868 children = sources[sources[
"parent"] != 0]
870 for child
in children:
871 footprint = child.getFootprint()
872 array = footprint.getImageArray()
875 self.deblend.run(exposure=difference, sources=sources)
876 self.setPrimaryFlags.run(sources)
877 children = sources[
"detect_isDeblendedSource"] == 1
878 sources = sources[children].copy(deep=
True)
880 sources[
'parent'] = 0
881 return sources.copy(deep=
True)
883 positives =
deblend(positiveFootprints)
884 negatives =
deblend(negativeFootprints, negative=
True)
887 sources.reserve(len(positives) + len(negatives))
888 sources.extend(positives, deep=
True)
889 sources.extend(negatives, deep=
True)
890 if len(negatives) > 0:
891 sources[-len(negatives):][
"is_negative"] =
True
892 return sources, positives, negatives
894 def _removeBadSources(self, diaSources):
895 """Remove unphysical diaSources from the catalog.
899 diaSources : `lsst.afw.table.SourceCatalog`
900 The catalog of detected sources.
904 diaSources : `lsst.afw.table.SourceCatalog`
905 The updated catalog of detected sources, with any source that has a
906 flag in ``config.badSourceFlags`` set removed.
908 selector = np.ones(len(diaSources), dtype=bool)
909 for flag
in self.config.badSourceFlags:
910 flags = diaSources[flag]
911 nBad = np.count_nonzero(flags)
913 self.log.debug(
"Found %d unphysical sources with flag %s.", nBad, flag)
915 nBadTotal = np.count_nonzero(~selector)
916 self.metadata[
"nRemovedBadFlaggedSources"] = nBadTotal
917 self.log.info(
"Removed %d unphysical sources.", nBadTotal)
918 return diaSources[selector].copy(deep=
True)
920 def _find_glint_trails(self, diaSources):
921 """Define a new flag column for diaSources that are in a glint trail.
925 diaSources : `lsst.afw.table.SourceCatalog`
926 The catalog of detected sources.
930 diaSources : `lsst.afw.table.SourceCatalog`
931 The updated catalog of detected sources, with a new bool column
932 called 'glint_trail' added.
934 trail_parameters : `dict`
935 Parameters of all the trails that were found.
937 if self.config.doSkySources:
939 candidateDiaSources = diaSources[~diaSources[
"sky_source"]].copy(deep=
True)
941 candidateDiaSources = diaSources
942 trailed_glints = self.findGlints.run(candidateDiaSources)
943 glint_mask = [
True if id
in trailed_glints.trailed_ids
else False for id
in diaSources[
'id']]
944 if np.any(glint_mask):
945 diaSources[
'glint_trail'] = np.array(glint_mask)
947 slopes = np.array([trail.slope
for trail
in trailed_glints.parameters])
948 intercepts = np.array([trail.intercept
for trail
in trailed_glints.parameters])
949 stderrs = np.array([trail.stderr
for trail
in trailed_glints.parameters])
950 lengths = np.array([trail.length
for trail
in trailed_glints.parameters])
951 angles = np.array([trail.angle
for trail
in trailed_glints.parameters])
952 parameters = {
'slopes': slopes,
'intercepts': intercepts,
'stderrs': stderrs,
'lengths': lengths,
955 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
957 return diaSources, trail_parameters
959 def addSkySources(self, diaSources, mask, seed,
961 """Add sources in empty regions of the difference image
962 for measuring the background.
966 diaSources : `lsst.afw.table.SourceCatalog`
967 The catalog of detected sources.
968 mask : `lsst.afw.image.Mask`
969 Mask plane for determining regions where Sky sources can be added.
971 Seed value to initialize the random number generator.
974 subtask = self.skySources
975 if subtask.config.nSources <= 0:
976 self.metadata[f
"n_{subtask.getName()}"] = 0
978 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
979 self.metadata[f
"n_{subtask.getName()}"] = len(skySourceFootprints)
981 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
982 """Use (matched) template and science image to constrain dipole fitting.
986 diaSources : `lsst.afw.table.SourceCatalog`
987 The catalog of detected sources.
988 science : `lsst.afw.image.ExposureF`
989 Science exposure that the template was subtracted from.
990 difference : `lsst.afw.image.ExposureF`
991 Result of subtracting template from the science image.
992 matchedTemplate : `lsst.afw.image.ExposureF`
993 Warped and PSF-matched template that was used produce the
997 for mp
in self.config.measurement.plugins[
"base_PixelFlags"].masksFpAnywhere:
998 difference.mask.addMaskPlane(mp)
1001 self.measurement.run(diaSources, difference, science, matchedTemplate)
1002 if self.config.doApCorr:
1003 apCorrMap = difference.getInfo().getApCorrMap()
1004 if apCorrMap
is None:
1005 self.log.warning(
"Difference image does not have valid aperture correction; skipping.")
1007 self.applyApCorr.run(
1009 apCorrMap=apCorrMap,
1012 def measureForcedSources(self, diaSources, image, wcs, template=False):
1013 """Perform forced measurement of the diaSources on a direct image.
1017 diaSources : `lsst.afw.table.SourceCatalog`
1018 The catalog of detected sources.
1019 image: `lsst.afw.image.ExposureF`
1020 Exposure that the forced measurement is being performed on
1021 wcs : `lsst.afw.geom.SkyWcs`
1022 Coordinate system definition (wcs) for the exposure.
1024 Is the forced measurement being performed on the template?
1028 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1029 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1032 base_key =
'ip_diffim_forced_template_PsfFlux'
1034 base_key =
'ip_diffim_forced_PsfFlux'
1036 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_instFlux")[0],
1037 f
"{base_key}_instFlux",
True)
1038 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_instFluxErr")[0],
1039 f
"{base_key}_instFluxErr",
True)
1040 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_area")[0],
1041 f
"{base_key}_area",
True)
1042 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_flag")[0],
1043 f
"{base_key}_flag",
True)
1044 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_flag_noGoodPixels")[0],
1045 f
"{base_key}_flag_noGoodPixels",
True)
1046 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_flag_edge")[0],
1047 f
"{base_key}_flag_edge",
True)
1048 for diaSource, forcedSource
in zip(diaSources, forcedSources):
1049 diaSource.assign(forcedSource, mapper)
1051 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1052 """Add difference image QA metrics to the Task metadata.
1054 This may be used to produce corresponding metrics (see
1055 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1059 science : `lsst.afw.image.ExposureF`
1060 Science exposure that was subtracted.
1061 difference : `lsst.afw.image.Exposure`
1062 The target difference image to calculate metrics for.
1063 diaSources : `lsst.afw.table.SourceCatalog`
1064 The catalog of detected sources.
1065 kernelSources : `lsst.afw.table.SourceCatalog`
1066 Final selection of sources that was used for psf matching.
1068 mask = difference.mask
1069 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1070 self.metadata[
"nGoodPixels"] = np.sum(~badPix)
1071 self.metadata[
"nBadPixels"] = np.sum(badPix)
1072 detPosPix = (mask.array & mask.getPlaneBitMask(
"DETECTED")) > 0
1073 detNegPix = (mask.array & mask.getPlaneBitMask(
"DETECTED_NEGATIVE")) > 0
1074 self.metadata[
"nPixelsDetectedPositive"] = np.sum(detPosPix)
1075 self.metadata[
"nPixelsDetectedNegative"] = np.sum(detNegPix)
1078 self.metadata[
"nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1079 self.metadata[
"nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1081 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1082 for maskPlane
in metricsMaskPlanes:
1084 self.metadata[
"%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1085 except InvalidParameterError:
1086 self.metadata[
"%s_mask_fraction"%maskPlane.lower()] = -1
1087 self.log.info(
"Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1089 if self.config.doSkySources:
1090 skySources = diaSources[diaSources[
"sky_source"]]
1093 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1095 self.metadata[
"residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1096 self.metadata[
"residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1097 self.metadata[
"differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1098 self.metadata[
"differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1099 self.log.info(
"Mean, stdev of ratio of difference to science "
1100 "pixels in star footprints: %5.4f, %5.4f",
1101 self.metadata[
"residualFootprintRatioMean"],
1102 self.metadata[
"residualFootprintRatioStdev"])
1103 if self.config.raiseOnBadSubtractionRatio:
1104 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold:
1106 threshold=self.config.badSubtractionRatioThreshold)
1107 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold:
1109 threshold=self.config.badSubtractionVariationThreshold)
1111 def getSattleDiaSourceAllowlist(self, diaSources, science):
1112 """Query the sattle service and determine which diaSources are allowed.
1116 diaSources : `lsst.afw.table.SourceCatalog`
1117 The catalog of detected sources.
1118 science : `lsst.afw.image.ExposureF`
1119 Science exposure that was subtracted.
1123 allow_list : `list` of `int`
1124 diaSourceIds of diaSources that can be made public.
1129 Raised if sattle call does not return success.
1131 wcs = science.getWcs()
1132 visit_info = science.getInfo().getVisitInfo()
1133 visit_id = visit_info.getId()
1134 sattle_uri_base = os.getenv(
'SATTLE_URI_BASE')
1136 dia_sources_json = []
1137 for source
in diaSources:
1138 source_bbox = source.getFootprint().getBBox()
1139 corners = wcs.pixelToSky([
lsst.geom.Point2D(c)
for c
in source_bbox.getCorners()])
1140 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()]
for pt
in corners]
1141 dia_sources_json.append({
"diasource_id": source[
"id"],
"bbox": bbox_radec})
1143 payload = {
"visit_id": visit_id,
"detector_id": science.getDetector().getId(),
1144 "diasources": dia_sources_json,
"historical": self.config.sattle_historical}
1146 sattle_output = requests.put(f
'{sattle_uri_base}/diasource_allow_list',
1150 if sattle_output.status_code == 404:
1151 self.log.warning(f
'Visit {visit_id} not found in sattle cache, re-sending')
1152 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1153 sattle_output = requests.put(f
'{sattle_uri_base}/diasource_allow_list', json=payload)
1155 sattle_output.raise_for_status()
1157 return sattle_output.json()[
'allow_list']
1159 def filterSatellites(self, diaSources, science):
1160 """Remove diaSources overlapping predicted satellite positions.
1164 diaSources : `lsst.afw.table.SourceCatalog`
1165 The catalog of detected sources.
1166 science : `lsst.afw.image.ExposureF`
1167 Science exposure that was subtracted.
1171 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1172 Filtered catalog of diaSources
1175 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1178 allow_set = set(allow_list)
1179 allowed_ids = [source[
'id']
in allow_set
for source
in diaSources]
1180 diaSources = diaSources[np.array(allowed_ids)].copy(deep=
True)
1182 self.log.warning(
'Sattle allowlist is empty, all diaSources removed')
1183 diaSources = diaSources[0:0].copy(deep=
True)
1186 def _runStreakMasking(self, difference):
1187 """Do streak masking and optionally save the resulting streak
1188 fit parameters in a catalog.
1190 Only returns non-empty streakInfo if self.config.writeStreakInfo
1191 is set. The difference image is binned by self.config.streakBinFactor
1192 (and detection is run a second time) so that regions with lower
1193 surface brightness streaks are more likely to fall above the
1194 detection threshold.
1198 difference: `lsst.afw.image.Exposure`
1199 The exposure in which to search for streaks. Must have a detection
1204 streakInfo: `lsst.pipe.base.Struct`
1205 ``rho`` : `np.ndarray`
1206 Angle of detected streak.
1207 ``theta`` : `np.ndarray`
1208 Distance from center of detected streak.
1209 ``sigma`` : `np.ndarray`
1210 Width of streak profile.
1211 ``reducedChi2`` : `np.ndarray`
1212 Reduced chi2 of the best-fit streak profile.
1213 ``modelMaximum`` : `np.ndarray`
1214 Peak value of the fit line profile.
1216 maskedImage = difference.maskedImage
1219 self.config.streakBinFactor,
1220 self.config.streakBinFactor)
1221 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1222 binnedExposure.setMaskedImage(binnedMaskedImage)
1224 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask(
'DETECTED')
1226 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1227 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1228 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=
True,
1229 sigma=sigma/self.config.streakBinFactor)
1230 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask(
'DETECTED')
1231 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1232 axis=0).repeat(self.config.streakBinFactor,
1235 streakMaskedImage = maskedImage.clone()
1236 ysize, xsize = rescaledDetectedMaskPlane.shape
1237 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1239 streaks = self.maskStreaks.run(streakMaskedImage)
1240 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask(
'STREAK')
1242 maskedImage.mask.array |= streakMaskPlane
1244 if self.config.writeStreakInfo:
1245 rhos = np.array([line.rho
for line
in streaks.lines])
1246 thetas = np.array([line.theta
for line
in streaks.lines])
1247 sigmas = np.array([line.sigma
for line
in streaks.lines])
1248 chi2s = np.array([line.reducedChi2
for line
in streaks.lines])
1249 modelMaximums = np.array([line.modelMaximum
for line
in streaks.lines])
1250 streakInfo = {
'rho': rhos,
'theta': thetas,
'sigma': sigmas,
'reducedChi2': chi2s,
1251 'modelMaximum': modelMaximums}
1253 streakInfo = {
'rho': np.array([]),
'theta': np.array([]),
'sigma': np.array([]),
1254 'reducedChi2': np.array([]),
'modelMaximum': np.array([])}
1255 return pipeBase.Struct(maskedStreaks=streakInfo)
1259 scoreExposure = pipeBase.connectionTypes.Input(
1260 doc=
"Maximum likelihood image for detection.",
1261 dimensions=(
"instrument",
"visit",
"detector"),
1262 storageClass=
"ExposureF",
1263 name=
"{fakesType}{coaddName}Diff_scoreExp",
1267class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1268 pipelineConnections=DetectAndMeasureScoreConnections):
1272class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1273 """Detect DIA sources using a score image,
1274 and measure the detections on the difference image.
1276 Source detection is run on the supplied score, or maximum likelihood,
1277 image. Note that no additional convolution will be done in this case.
1278 Close positive and negative detections will optionally be merged into
1280 Sky sources, or forced detections in background regions, will optionally
1281 be added, and the configured measurement algorithm will be run on all
1284 ConfigClass = DetectAndMeasureScoreConfig
1285 _DefaultName =
"detectAndMeasureScore"
1288 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1290 """Detect and measure sources on a score image.
1294 science : `lsst.afw.image.ExposureF`
1295 Science exposure that the template was subtracted from.
1296 matchedTemplate : `lsst.afw.image.ExposureF`
1297 Warped and PSF-matched template that was used produce the
1299 difference : `lsst.afw.image.ExposureF`
1300 Result of subtracting template from the science image.
1301 scoreExposure : `lsst.afw.image.ExposureF`
1302 Score or maximum likelihood difference image
1303 kernelSources : `lsst.afw.table.SourceCatalog`
1304 Final selection of sources that was used for psf matching.
1305 idFactory : `lsst.afw.table.IdFactory`, optional
1306 Generator object used to assign ids to detected sources in the
1307 difference image. Ids from this generator are not set until after
1308 deblending and merging positive/negative peaks.
1312 measurementResults : `lsst.pipe.base.Struct`
1314 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1315 Subtracted exposure with detection mask applied.
1316 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1317 The catalog of detected sources.
1319 if idFactory
is None:
1322 self._prepareInputs(scoreExposure)
1327 table = afwTable.SourceTable.make(self.schema)
1328 results = self.detection.run(
1330 exposure=scoreExposure,
1334 difference.mask.assign(scoreExposure.mask, scoreExposure.getBBox())
1336 if self.config.doDeblend:
1337 sources, positives, negatives = self._deblend(difference,
1341 return self.processResults(science, matchedTemplate, difference,
1342 sources, idFactory, kernelSources,
1343 positives=positives,
1344 negatives=negatives)
1348 results.positive.makeSources(positives)
1350 results.negative.makeSources(negatives)
1351 return self.processResults(science, matchedTemplate, difference,
1352 results.sources, idFactory, kernelSources,
1353 positives=positives,
1354 negatives=negatives)
A mapping between the keys of two Schemas, used to copy data between them.
Class for storing ordered metadata with comments.
__init__(self, *, ratio, threshold)
std::shared_ptr< ImageT > binImage(ImageT const &inImage, int const binX, int const binY, lsst::afw::math::Property const flags=lsst::afw::math::MEAN)
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter)