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_badCenter",
300 "base_PixelFlags_flag_edgeCenter",
301 "base_PixelFlags_flag_nodataCenter",
302 "base_PixelFlags_flag_saturatedCenter",
303 "base_PixelFlags_flag_saturated_templateCenter",
304 "base_PixelFlags_flag_spikeCenter",
309 doc=
"Mask planes to clear before running detection.",
310 default=(
"DETECTED",
"DETECTED_NEGATIVE",
"NOT_DEBLENDED",
"STREAK"),
312 raiseOnBadSubtractionRatio = pexConfig.Field(
315 doc=
"Raise an error if the ratio of power in detected footprints"
316 " on the difference image to the power in footprints on the science"
317 " image exceeds ``badSubtractionRatioThreshold``",
319 badSubtractionRatioThreshold = pexConfig.Field(
322 doc=
"Maximum ratio of power in footprints on the difference image to"
323 " the same footprints on the science image."
324 "Only used if ``raiseOnBadSubtractionRatio`` is set",
326 badSubtractionVariationThreshold = pexConfig.Field(
329 doc=
"Maximum standard deviation of the ratio of power in footprints on"
330 " the difference image to the same footprints on the science image."
331 "Only used if ``raiseOnBadSubtractionRatio`` is set",
333 raiseOnNoDiaSources = pexConfig.Field(
336 doc=
"Raise an algorithm error if no diaSources are detected.",
338 run_sattle = pexConfig.Field(
341 doc=
"If true, dia source bounding boxes will be sent for verification"
342 "to the sattle service."
344 sattle_historical = pexConfig.Field(
347 doc=
"If re-running a pipeline that requires sattle, this should be set "
348 "to True. This will populate sattle's cache with the historic data "
349 "closest in time to the exposure."
351 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
353 def setDefaults(self):
358 self.subtractInitialBackground.binSize = 8
359 self.subtractInitialBackground.useApprox =
False
360 self.subtractInitialBackground.statisticsProperty =
"MEDIAN"
361 self.subtractInitialBackground.doFilterSuperPixels =
True
362 self.subtractInitialBackground.ignoredPixelMask = [
"BAD",
370 self.subtractFinalBackground.binSize = 40
371 self.subtractFinalBackground.useApprox =
False
372 self.subtractFinalBackground.statisticsProperty =
"MEDIAN"
373 self.subtractFinalBackground.doFilterSuperPixels =
True
374 self.subtractFinalBackground.ignoredPixelMask = [
"BAD",
381 self.detection.thresholdPolarity =
"both"
382 self.detection.thresholdValue = 5.0
383 self.detection.reEstimateBackground =
False
384 self.detection.thresholdType =
"pixel_stdev"
385 self.detection.excludeMaskPlanes = []
388 self.streakDetection.thresholdType = self.detection.thresholdType
389 self.streakDetection.reEstimateBackground =
False
390 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
391 self.streakDetection.thresholdValue = self.detection.thresholdValue
393 self.streakDetection.thresholdPolarity =
"positive"
395 self.streakDetection.nSigmaToGrow = 0
398 self.maskStreaks.onlyMaskDetected =
False
400 self.maskStreaks.maxStreakWidth = 100
403 self.maskStreaks.maxFitIter = 10
405 self.maskStreaks.nSigmaMask = 2
408 self.maskStreaks.absMinimumKernelHeight = 2
410 self.measurement.plugins.names |= [
"ext_trailedSources_Naive",
411 "base_LocalPhotoCalib",
413 "ext_shapeHSM_HsmSourceMoments",
414 "ext_shapeHSM_HsmPsfMoments",
415 "base_ClassificationSizeExtendedness",
417 self.measurement.slots.psfShape =
"ext_shapeHSM_HsmPsfMoments"
418 self.measurement.slots.shape =
"ext_shapeHSM_HsmSourceMoments"
419 self.measurement.plugins[
"base_SdssCentroid"].maxDistToPeak = 5.0
420 self.forcedMeasurement.plugins = [
"base_TransformedCentroid",
"base_PsfFlux"]
421 self.forcedMeasurement.copyColumns = {
422 "id":
"objectId",
"parent":
"parentObjectId",
"coord_ra":
"coord_ra",
"coord_dec":
"coord_dec"}
423 self.forcedMeasurement.slots.centroid =
"base_TransformedCentroid"
424 self.forcedMeasurement.slots.shape =
None
427 self.measurement.plugins[
"base_PixelFlags"].masksFpAnywhere = [
428 "STREAK",
"INJECTED",
"INJECTED_TEMPLATE",
"HIGH_VARIANCE",
"SATURATED_TEMPLATE",
"SPIKE"]
429 self.measurement.plugins[
"base_PixelFlags"].masksFpCenter = [
430 "STREAK",
"INJECTED",
"INJECTED_TEMPLATE",
"HIGH_VARIANCE",
"SATURATED_TEMPLATE",
"SPIKE"]
431 self.skySources.avoidMask = [
"DETECTED",
"DETECTED_NEGATIVE",
"BAD",
"NO_DATA",
"EDGE"]
437 if not os.getenv(
"SATTLE_URI_BASE"):
438 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self,
439 "Sattle requested but SATTLE_URI_BASE "
440 "environment variable not set.")
443class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
444 """Detect and measure sources on a difference image.
446 ConfigClass = DetectAndMeasureConfig
447 _DefaultName =
"detectAndMeasure"
449 def __init__(self, **kwargs):
450 super().__init__(**kwargs)
451 self.schema = afwTable.SourceTable.makeMinimalSchema()
454 if self.config.doSubtractBackground:
455 self.makeSubtask(
"subtractInitialBackground")
456 self.makeSubtask(
"subtractFinalBackground")
457 self.makeSubtask(
"detection", schema=self.schema)
458 if self.config.doDeblend:
459 self.makeSubtask(
"deblend", schema=self.schema)
460 self.makeSubtask(
"setPrimaryFlags", schema=self.schema, isSingleFrame=
True)
461 self.makeSubtask(
"measurement", schema=self.schema,
462 algMetadata=self.algMetadata)
463 if self.config.doApCorr:
464 self.makeSubtask(
"applyApCorr", schema=self.measurement.schema)
465 if self.config.doForcedMeasurement:
466 self.schema.addField(
467 "ip_diffim_forced_PsfFlux_instFlux",
"D",
468 "Forced PSF flux measured on the direct image.",
470 self.schema.addField(
471 "ip_diffim_forced_PsfFlux_instFluxErr",
"D",
472 "Forced PSF flux error measured on the direct image.",
474 self.schema.addField(
475 "ip_diffim_forced_PsfFlux_area",
"F",
476 "Forced PSF flux effective area of PSF.",
478 self.schema.addField(
479 "ip_diffim_forced_PsfFlux_flag",
"Flag",
480 "Forced PSF flux general failure flag.")
481 self.schema.addField(
482 "ip_diffim_forced_PsfFlux_flag_noGoodPixels",
"Flag",
483 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
484 self.schema.addField(
485 "ip_diffim_forced_PsfFlux_flag_edge",
"Flag",
486 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
487 self.schema.addField(
488 "ip_diffim_forced_template_PsfFlux_instFlux",
"D",
489 "Forced PSF flux measured on the template image.",
491 self.schema.addField(
492 "ip_diffim_forced_template_PsfFlux_instFluxErr",
"D",
493 "Forced PSF flux error measured on the template image.",
495 self.schema.addField(
496 "ip_diffim_forced_template_PsfFlux_area",
"F",
497 "Forced template PSF flux effective area of PSF.",
499 self.schema.addField(
500 "ip_diffim_forced_template_PsfFlux_flag",
"Flag",
501 "Forced template PSF flux general failure flag.")
502 self.schema.addField(
503 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels",
"Flag",
504 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.")
505 self.schema.addField(
506 "ip_diffim_forced_template_PsfFlux_flag_edge",
"Flag",
507 """Forced template PSF flux object was too close to the edge of the image """
508 """to use the full PSF model.""")
509 self.makeSubtask(
"forcedMeasurement", refSchema=self.schema)
511 self.schema.addField(
"refMatchId",
"L",
"unique id of reference catalog match")
512 self.schema.addField(
"srcMatchId",
"L",
"unique id of source match")
515 self.makeSubtask(
"skySources", schema=self.schema)
516 if self.config.doMaskStreaks:
517 self.makeSubtask(
"maskStreaks")
518 self.makeSubtask(
"streakDetection")
519 self.makeSubtask(
"findGlints")
520 self.schema.addField(
"glint_trail",
"Flag",
"DiaSource is part of a glint trail.")
521 self.schema.addField(
"reliability", type=
"F", doc=
"Reliability score of the DiaSource")
528 for flag
in self.config.badSourceFlags:
529 if flag
not in self.schema:
530 raise pipeBase.InvalidQuantumError(
"Field %s not in schema" % flag)
534 self.outputSchema.getTable().setMetadata(self.algMetadata)
536 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
537 inputRefs: pipeBase.InputQuantizedConnection,
538 outputRefs: pipeBase.OutputQuantizedConnection):
539 inputs = butlerQC.get(inputRefs)
540 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
541 idFactory = idGenerator.make_table_id_factory()
544 measurementResults = pipeBase.Struct(
545 subtractedMeasuredExposure=
None,
548 differenceBackground=
None,
551 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
552 except pipeBase.AlgorithmError
as e:
553 error = pipeBase.AnnotatedPartialOutputsError.annotate(
556 measurementResults.subtractedMeasuredExposure,
557 measurementResults.diaSources,
558 measurementResults.maskedStreaks,
561 butlerQC.put(measurementResults, outputRefs)
563 butlerQC.put(measurementResults, outputRefs)
566 def run(self, science, matchedTemplate, difference, kernelSources=None,
567 idFactory=None, measurementResults=None):
568 """Detect and measure sources on a difference image.
570 The difference image will be convolved with a gaussian approximation of
571 the PSF to form a maximum likelihood image for detection.
572 Close positive and negative detections will optionally be merged into
574 Sky sources, or forced detections in background regions, will optionally
575 be added, and the configured measurement algorithm will be run on all
580 science : `lsst.afw.image.ExposureF`
581 Science exposure that the template was subtracted from.
582 matchedTemplate : `lsst.afw.image.ExposureF`
583 Warped and PSF-matched template that was used produce the
585 difference : `lsst.afw.image.ExposureF`
586 Result of subtracting template from the science image.
587 kernelSources : `lsst.afw.table.SourceCatalog`, optional
588 Final selection of sources that was used for psf matching.
589 idFactory : `lsst.afw.table.IdFactory`, optional
590 Generator object used to assign ids to detected sources in the
591 difference image. Ids from this generator are not set until after
592 deblending and merging positive/negative peaks.
593 measurementResults : `lsst.pipe.base.Struct`, optional
594 Result struct that is modified to allow saving of partial outputs
595 for some failure conditions. If the task completes successfully,
596 this is also returned.
600 measurementResults : `lsst.pipe.base.Struct`
602 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
603 Subtracted exposure with detection mask applied.
604 ``diaSources`` : `lsst.afw.table.SourceCatalog`
605 The catalog of detected sources.
606 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
607 Background that was subtracted from the difference image.
609 if measurementResults
is None:
610 measurementResults = pipeBase.Struct()
611 if idFactory
is None:
614 if self.config.doSubtractBackground:
616 detectionExposure = difference.clone()
617 background = self.subtractInitialBackground.run(detectionExposure).background
619 detectionExposure = difference
622 self._prepareInputs(detectionExposure)
627 table = afwTable.SourceTable.make(self.schema)
628 results = self.detection.run(
630 exposure=detectionExposure,
632 background=background
635 if self.config.doSubtractBackground:
639 difference.setMask(detectionExposure.mask)
640 background = self.subtractFinalBackground.run(difference).background
643 table = afwTable.SourceTable.make(self.schema)
644 results = self.detection.run(
648 background=background
650 measurementResults.differenceBackground = background
652 if self.config.doDeblend:
653 sources, positives, negatives = self._deblend(difference,
659 results.positive.makeSources(positives)
661 results.negative.makeSources(negatives)
662 sources = results.sources
664 self.processResults(science, matchedTemplate, difference,
665 sources, idFactory, kernelSources,
668 measurementResults=measurementResults)
669 return measurementResults
671 def _prepareInputs(self, difference):
672 """Ensure that we start with an empty detection and deblended mask.
676 difference : `lsst.afw.image.ExposureF`
677 The difference image that will be used for detecting diaSources.
678 The mask plane will be modified in place.
682 lsst.pipe.base.UpstreamFailureNoWorkFound
683 If the PSF is not usable for measurement.
686 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
688 raise pipeBase.UpstreamFailureNoWorkFound(
"Invalid PSF detected! PSF width evaluates to NaN.")
690 mask = difference.mask
691 for mp
in self.config.clearMaskPlanes:
692 if mp
not in mask.getMaskPlaneDict():
693 mask.addMaskPlane(mp)
694 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
696 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
697 kernelSources=None, positives=None, negatives=None, measurementResults=None):
698 """Measure and process the results of source detection.
702 science : `lsst.afw.image.ExposureF`
703 Science exposure that the template was subtracted from.
704 matchedTemplate : `lsst.afw.image.ExposureF`
705 Warped and PSF-matched template that was used produce the
707 difference : `lsst.afw.image.ExposureF`
708 Result of subtracting template from the science image.
709 sources : `lsst.afw.table.SourceCatalog`
710 Detected sources on the difference exposure.
711 idFactory : `lsst.afw.table.IdFactory`
712 Generator object used to assign ids to detected sources in the
714 kernelSources : `lsst.afw.table.SourceCatalog`, optional
715 Final selection of sources that was used for psf matching.
716 positives : `lsst.afw.table.SourceCatalog`, optional
717 Positive polarity footprints.
718 negatives : `lsst.afw.table.SourceCatalog`, optional
719 Negative polarity footprints.
720 measurementResults : `lsst.pipe.base.Struct`, optional
721 Result struct that is modified to allow saving of partial outputs
722 for some failure conditions. If the task completes successfully,
723 this is also returned.
727 measurementResults : `lsst.pipe.base.Struct`
729 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
730 Subtracted exposure with detection mask applied.
731 ``diaSources`` : `lsst.afw.table.SourceCatalog`
732 The catalog of detected sources.
734 if measurementResults
is None:
735 measurementResults = pipeBase.Struct()
736 self.metadata[
"nUnmergedDiaSources"] = len(sources)
737 if self.config.doMerge:
739 if len(positives) > 0:
740 peakSchema = positives[0].getFootprint().peaks.schema
741 elif len(negatives) > 0:
742 peakSchema = negatives[0].getFootprint().peaks.schema
744 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
745 mergeList = afwDetection.FootprintMergeList(self.schema,
746 [
"positive",
"negative"], peakSchema)
751 mergeList.addCatalog(initialDiaSources.table, positives,
"positive", minNewPeakDist=0)
752 mergeList.addCatalog(initialDiaSources.table, negatives,
"negative", minNewPeakDist=0)
753 mergeList.getFinalSources(initialDiaSources)
756 initialDiaSources[
"is_negative"] = initialDiaSources[
"merge_footprint_negative"] & \
757 ~initialDiaSources[
"merge_footprint_positive"]
758 self.log.info(
"Merging detections into %d sources", len(initialDiaSources))
760 initialDiaSources = sources
764 for source
in initialDiaSources:
767 initialDiaSources.getTable().setIdFactory(idFactory)
768 initialDiaSources.setMetadata(self.algMetadata)
770 self.metadata[
"nMergedDiaSources"] = len(initialDiaSources)
772 if self.config.doMaskStreaks:
773 streakInfo = self._runStreakMasking(difference)
775 if self.config.doSkySources:
776 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
778 if not initialDiaSources.isContiguous():
779 initialDiaSources = initialDiaSources.copy(deep=
True)
781 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
784 diaSources = self._removeBadSources(initialDiaSources)
786 if self.config.run_sattle:
787 diaSources = self.filterSatellites(diaSources, science)
790 diaSources, trail_parameters = self._find_glint_trails(diaSources)
791 if self.config.writeGlintInfo:
792 measurementResults.mergeItems(trail_parameters,
'glintTrailInfo')
794 if self.config.doForcedMeasurement:
795 self.measureForcedSources(diaSources, science, difference.getWcs())
796 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
803 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask(
'NO_DATA') > 0] = 0
805 measurementResults.subtractedMeasuredExposure = difference
807 if self.config.doMaskStreaks
and self.config.writeStreakInfo:
808 measurementResults.maskedStreaks = streakInfo.maskedStreaks
810 if kernelSources
is not None:
811 self.calculateMetrics(science, difference, diaSources, kernelSources)
813 if np.count_nonzero(~diaSources[
"sky_source"]) > 0:
814 measurementResults.diaSources = diaSources
815 elif self.config.raiseOnNoDiaSources:
817 elif len(diaSources) > 0:
820 measurementResults.diaSources = diaSources
821 self.log.info(
"Measured %d diaSources and %d sky sources",
822 np.count_nonzero(~diaSources[
"sky_source"]),
823 np.count_nonzero(diaSources[
"sky_source"])
825 return measurementResults
827 def _deblend(self, difference, positiveFootprints, negativeFootprints):
828 """Deblend the positive and negative footprints and return a catalog
829 containing just the children, and the deblended footprints.
833 difference : `lsst.afw.image.Exposure`
834 Result of subtracting template from the science image.
835 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
836 Positive and negative polarity footprints measured on
837 ``difference`` to be deblended separately.
841 sources : `lsst.afw.table.SourceCatalog`
842 Positive and negative deblended children.
843 positives, negatives : `lsst.afw.table.SourceCatalog`
844 Deblended positive and negative polarity sources with footprints
845 detected on ``difference``.
848 def deblend(footprints, negative=False):
849 """Deblend a positive or negative footprint set,
850 and return the deblended children.
854 footprints : `lsst.afw.detection.FootprintSet`
856 Set True if the footprints contain negative fluxes
860 sources : `lsst.afw.table.SourceCatalog`
863 footprints.makeSources(sources)
866 difference_inverted = difference.clone()
867 difference_inverted.image *= -1
868 self.deblend.run(exposure=difference_inverted, sources=sources)
869 children = sources[sources[
"parent"] != 0]
871 for child
in children:
872 footprint = child.getFootprint()
873 array = footprint.getImageArray()
876 self.deblend.run(exposure=difference, sources=sources)
877 self.setPrimaryFlags.run(sources)
878 children = sources[
"detect_isDeblendedSource"] == 1
879 sources = sources[children].copy(deep=
True)
881 sources[
'parent'] = 0
882 return sources.copy(deep=
True)
884 positives =
deblend(positiveFootprints)
885 negatives =
deblend(negativeFootprints, negative=
True)
888 sources.reserve(len(positives) + len(negatives))
889 sources.extend(positives, deep=
True)
890 sources.extend(negatives, deep=
True)
891 if len(negatives) > 0:
892 sources[-len(negatives):][
"is_negative"] =
True
893 return sources, positives, negatives
895 def _removeBadSources(self, diaSources):
896 """Remove unphysical diaSources from the catalog.
900 diaSources : `lsst.afw.table.SourceCatalog`
901 The catalog of detected sources.
905 diaSources : `lsst.afw.table.SourceCatalog`
906 The updated catalog of detected sources, with any source that has a
907 flag in ``config.badSourceFlags`` set removed.
909 selector = np.ones(len(diaSources), dtype=bool)
910 for flag
in self.config.badSourceFlags:
911 flags = diaSources[flag]
912 nBad = np.count_nonzero(flags)
914 self.log.debug(
"Found %d unphysical sources with flag %s.", nBad, flag)
916 nBadTotal = np.count_nonzero(~selector)
917 self.metadata[
"nRemovedBadFlaggedSources"] = nBadTotal
918 self.log.info(
"Removed %d unphysical sources.", nBadTotal)
919 return diaSources[selector].copy(deep=
True)
921 def _find_glint_trails(self, diaSources):
922 """Define a new flag column for diaSources that are in a glint trail.
926 diaSources : `lsst.afw.table.SourceCatalog`
927 The catalog of detected sources.
931 diaSources : `lsst.afw.table.SourceCatalog`
932 The updated catalog of detected sources, with a new bool column
933 called 'glint_trail' added.
935 trail_parameters : `dict`
936 Parameters of all the trails that were found.
938 if self.config.doSkySources:
940 candidateDiaSources = diaSources[~diaSources[
"sky_source"]].copy(deep=
True)
942 candidateDiaSources = diaSources
943 trailed_glints = self.findGlints.run(candidateDiaSources)
944 glint_mask = [
True if id
in trailed_glints.trailed_ids
else False for id
in diaSources[
'id']]
945 if np.any(glint_mask):
946 diaSources[
'glint_trail'] = np.array(glint_mask)
948 slopes = np.array([trail.slope
for trail
in trailed_glints.parameters])
949 intercepts = np.array([trail.intercept
for trail
in trailed_glints.parameters])
950 stderrs = np.array([trail.stderr
for trail
in trailed_glints.parameters])
951 lengths = np.array([trail.length
for trail
in trailed_glints.parameters])
952 angles = np.array([trail.angle
for trail
in trailed_glints.parameters])
953 parameters = {
'slopes': slopes,
'intercepts': intercepts,
'stderrs': stderrs,
'lengths': lengths,
956 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
958 return diaSources, trail_parameters
960 def addSkySources(self, diaSources, mask, seed,
962 """Add sources in empty regions of the difference image
963 for measuring the background.
967 diaSources : `lsst.afw.table.SourceCatalog`
968 The catalog of detected sources.
969 mask : `lsst.afw.image.Mask`
970 Mask plane for determining regions where Sky sources can be added.
972 Seed value to initialize the random number generator.
975 subtask = self.skySources
976 if subtask.config.nSources <= 0:
977 self.metadata[f
"n_{subtask.getName()}"] = 0
979 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
980 self.metadata[f
"n_{subtask.getName()}"] = len(skySourceFootprints)
982 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
983 """Use (matched) template and science image to constrain dipole fitting.
987 diaSources : `lsst.afw.table.SourceCatalog`
988 The catalog of detected sources.
989 science : `lsst.afw.image.ExposureF`
990 Science exposure that the template was subtracted from.
991 difference : `lsst.afw.image.ExposureF`
992 Result of subtracting template from the science image.
993 matchedTemplate : `lsst.afw.image.ExposureF`
994 Warped and PSF-matched template that was used produce the
998 for mp
in self.config.measurement.plugins[
"base_PixelFlags"].masksFpAnywhere:
999 difference.mask.addMaskPlane(mp)
1002 self.measurement.run(diaSources, difference, science, matchedTemplate)
1003 if self.config.doApCorr:
1004 apCorrMap = difference.getInfo().getApCorrMap()
1005 if apCorrMap
is None:
1006 self.log.warning(
"Difference image does not have valid aperture correction; skipping.")
1008 self.applyApCorr.run(
1010 apCorrMap=apCorrMap,
1013 def measureForcedSources(self, diaSources, image, wcs, template=False):
1014 """Perform forced measurement of the diaSources on a direct image.
1018 diaSources : `lsst.afw.table.SourceCatalog`
1019 The catalog of detected sources.
1020 image: `lsst.afw.image.ExposureF`
1021 Exposure that the forced measurement is being performed on
1022 wcs : `lsst.afw.geom.SkyWcs`
1023 Coordinate system definition (wcs) for the exposure.
1025 Is the forced measurement being performed on the template?
1029 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1030 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1033 base_key =
'ip_diffim_forced_template_PsfFlux'
1035 base_key =
'ip_diffim_forced_PsfFlux'
1037 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_instFlux")[0],
1038 f
"{base_key}_instFlux",
True)
1039 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_instFluxErr")[0],
1040 f
"{base_key}_instFluxErr",
True)
1041 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_area")[0],
1042 f
"{base_key}_area",
True)
1043 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_flag")[0],
1044 f
"{base_key}_flag",
True)
1045 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_flag_noGoodPixels")[0],
1046 f
"{base_key}_flag_noGoodPixels",
True)
1047 mapper.addMapping(forcedSources.schema.find(
"base_PsfFlux_flag_edge")[0],
1048 f
"{base_key}_flag_edge",
True)
1049 for diaSource, forcedSource
in zip(diaSources, forcedSources):
1050 diaSource.assign(forcedSource, mapper)
1052 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1053 """Add difference image QA metrics to the Task metadata.
1055 This may be used to produce corresponding metrics (see
1056 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1060 science : `lsst.afw.image.ExposureF`
1061 Science exposure that was subtracted.
1062 difference : `lsst.afw.image.Exposure`
1063 The target difference image to calculate metrics for.
1064 diaSources : `lsst.afw.table.SourceCatalog`
1065 The catalog of detected sources.
1066 kernelSources : `lsst.afw.table.SourceCatalog`
1067 Final selection of sources that was used for psf matching.
1069 mask = difference.mask
1070 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1071 self.metadata[
"nGoodPixels"] = np.sum(~badPix)
1072 self.metadata[
"nBadPixels"] = np.sum(badPix)
1073 detPosPix = (mask.array & mask.getPlaneBitMask(
"DETECTED")) > 0
1074 detNegPix = (mask.array & mask.getPlaneBitMask(
"DETECTED_NEGATIVE")) > 0
1075 self.metadata[
"nPixelsDetectedPositive"] = np.sum(detPosPix)
1076 self.metadata[
"nPixelsDetectedNegative"] = np.sum(detNegPix)
1079 self.metadata[
"nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1080 self.metadata[
"nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1082 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1083 for maskPlane
in metricsMaskPlanes:
1085 self.metadata[
"%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1086 except InvalidParameterError:
1087 self.metadata[
"%s_mask_fraction"%maskPlane.lower()] = -1
1088 self.log.info(
"Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1090 if self.config.doSkySources:
1091 skySources = diaSources[diaSources[
"sky_source"]]
1094 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1096 self.metadata[
"residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1097 self.metadata[
"residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1098 self.metadata[
"differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1099 self.metadata[
"differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1100 self.log.info(
"Mean, stdev of ratio of difference to science "
1101 "pixels in star footprints: %5.4f, %5.4f",
1102 self.metadata[
"residualFootprintRatioMean"],
1103 self.metadata[
"residualFootprintRatioStdev"])
1104 if self.config.raiseOnBadSubtractionRatio:
1105 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold:
1107 threshold=self.config.badSubtractionRatioThreshold)
1108 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold:
1110 threshold=self.config.badSubtractionVariationThreshold)
1112 def getSattleDiaSourceAllowlist(self, diaSources, science):
1113 """Query the sattle service and determine which diaSources are allowed.
1117 diaSources : `lsst.afw.table.SourceCatalog`
1118 The catalog of detected sources.
1119 science : `lsst.afw.image.ExposureF`
1120 Science exposure that was subtracted.
1124 allow_list : `list` of `int`
1125 diaSourceIds of diaSources that can be made public.
1130 Raised if sattle call does not return success.
1132 wcs = science.getWcs()
1133 visit_info = science.getInfo().getVisitInfo()
1134 visit_id = visit_info.getId()
1135 sattle_uri_base = os.getenv(
'SATTLE_URI_BASE')
1137 dia_sources_json = []
1138 for source
in diaSources:
1139 source_bbox = source.getFootprint().getBBox()
1140 corners = wcs.pixelToSky([
lsst.geom.Point2D(c)
for c
in source_bbox.getCorners()])
1141 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()]
for pt
in corners]
1142 dia_sources_json.append({
"diasource_id": source[
"id"],
"bbox": bbox_radec})
1144 payload = {
"visit_id": visit_id,
"detector_id": science.getDetector().getId(),
1145 "diasources": dia_sources_json,
"historical": self.config.sattle_historical}
1147 sattle_output = requests.put(f
'{sattle_uri_base}/diasource_allow_list',
1151 if sattle_output.status_code == 404:
1152 self.log.warning(f
'Visit {visit_id} not found in sattle cache, re-sending')
1153 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1154 sattle_output = requests.put(f
'{sattle_uri_base}/diasource_allow_list', json=payload)
1156 sattle_output.raise_for_status()
1158 return sattle_output.json()[
'allow_list']
1160 def filterSatellites(self, diaSources, science):
1161 """Remove diaSources overlapping predicted satellite positions.
1165 diaSources : `lsst.afw.table.SourceCatalog`
1166 The catalog of detected sources.
1167 science : `lsst.afw.image.ExposureF`
1168 Science exposure that was subtracted.
1172 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1173 Filtered catalog of diaSources
1176 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1179 allow_set = set(allow_list)
1180 allowed_ids = [source[
'id']
in allow_set
for source
in diaSources]
1181 diaSources = diaSources[np.array(allowed_ids)].copy(deep=
True)
1183 self.log.warning(
'Sattle allowlist is empty, all diaSources removed')
1184 diaSources = diaSources[0:0].copy(deep=
True)
1187 def _runStreakMasking(self, difference):
1188 """Do streak masking and optionally save the resulting streak
1189 fit parameters in a catalog.
1191 Only returns non-empty streakInfo if self.config.writeStreakInfo
1192 is set. The difference image is binned by self.config.streakBinFactor
1193 (and detection is run a second time) so that regions with lower
1194 surface brightness streaks are more likely to fall above the
1195 detection threshold.
1199 difference: `lsst.afw.image.Exposure`
1200 The exposure in which to search for streaks. Must have a detection
1205 streakInfo: `lsst.pipe.base.Struct`
1206 ``rho`` : `np.ndarray`
1207 Angle of detected streak.
1208 ``theta`` : `np.ndarray`
1209 Distance from center of detected streak.
1210 ``sigma`` : `np.ndarray`
1211 Width of streak profile.
1212 ``reducedChi2`` : `np.ndarray`
1213 Reduced chi2 of the best-fit streak profile.
1214 ``modelMaximum`` : `np.ndarray`
1215 Peak value of the fit line profile.
1217 maskedImage = difference.maskedImage
1220 self.config.streakBinFactor,
1221 self.config.streakBinFactor)
1222 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1223 binnedExposure.setMaskedImage(binnedMaskedImage)
1225 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask(
'DETECTED')
1227 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1228 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1229 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=
True,
1230 sigma=sigma/self.config.streakBinFactor)
1231 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask(
'DETECTED')
1232 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1233 axis=0).repeat(self.config.streakBinFactor,
1236 streakMaskedImage = maskedImage.clone()
1237 ysize, xsize = rescaledDetectedMaskPlane.shape
1238 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1240 streaks = self.maskStreaks.run(streakMaskedImage)
1241 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask(
'STREAK')
1243 maskedImage.mask.array |= streakMaskPlane
1245 if self.config.writeStreakInfo:
1246 rhos = np.array([line.rho
for line
in streaks.lines])
1247 thetas = np.array([line.theta
for line
in streaks.lines])
1248 sigmas = np.array([line.sigma
for line
in streaks.lines])
1249 chi2s = np.array([line.reducedChi2
for line
in streaks.lines])
1250 modelMaximums = np.array([line.modelMaximum
for line
in streaks.lines])
1251 streakInfo = {
'rho': rhos,
'theta': thetas,
'sigma': sigmas,
'reducedChi2': chi2s,
1252 'modelMaximum': modelMaximums}
1254 streakInfo = {
'rho': np.array([]),
'theta': np.array([]),
'sigma': np.array([]),
1255 'reducedChi2': np.array([]),
'modelMaximum': np.array([])}
1256 return pipeBase.Struct(maskedStreaks=streakInfo)
1260 scoreExposure = pipeBase.connectionTypes.Input(
1261 doc=
"Maximum likelihood image for detection.",
1262 dimensions=(
"instrument",
"visit",
"detector"),
1263 storageClass=
"ExposureF",
1264 name=
"{fakesType}{coaddName}Diff_scoreExp",
1268class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1269 pipelineConnections=DetectAndMeasureScoreConnections):
1273class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1274 """Detect DIA sources using a score image,
1275 and measure the detections on the difference image.
1277 Source detection is run on the supplied score, or maximum likelihood,
1278 image. Note that no additional convolution will be done in this case.
1279 Close positive and negative detections will optionally be merged into
1281 Sky sources, or forced detections in background regions, will optionally
1282 be added, and the configured measurement algorithm will be run on all
1285 ConfigClass = DetectAndMeasureScoreConfig
1286 _DefaultName =
"detectAndMeasureScore"
1289 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1291 """Detect and measure sources on a score image.
1295 science : `lsst.afw.image.ExposureF`
1296 Science exposure that the template was subtracted from.
1297 matchedTemplate : `lsst.afw.image.ExposureF`
1298 Warped and PSF-matched template that was used produce the
1300 difference : `lsst.afw.image.ExposureF`
1301 Result of subtracting template from the science image.
1302 scoreExposure : `lsst.afw.image.ExposureF`
1303 Score or maximum likelihood difference image
1304 kernelSources : `lsst.afw.table.SourceCatalog`
1305 Final selection of sources that was used for psf matching.
1306 idFactory : `lsst.afw.table.IdFactory`, optional
1307 Generator object used to assign ids to detected sources in the
1308 difference image. Ids from this generator are not set until after
1309 deblending and merging positive/negative peaks.
1313 measurementResults : `lsst.pipe.base.Struct`
1315 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1316 Subtracted exposure with detection mask applied.
1317 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1318 The catalog of detected sources.
1320 if idFactory
is None:
1323 self._prepareInputs(scoreExposure)
1328 table = afwTable.SourceTable.make(self.schema)
1329 results = self.detection.run(
1331 exposure=scoreExposure,
1335 difference.mask.assign(scoreExposure.mask, scoreExposure.getBBox())
1337 if self.config.doDeblend:
1338 sources, positives, negatives = self._deblend(difference,
1342 return self.processResults(science, matchedTemplate, difference,
1343 sources, idFactory, kernelSources,
1344 positives=positives,
1345 negatives=negatives)
1349 results.positive.makeSources(positives)
1351 results.negative.makeSources(negatives)
1352 return self.processResults(science, matchedTemplate, difference,
1353 results.sources, idFactory, kernelSources,
1354 positives=positives,
1355 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)