Coverage for python / lsst / ip / diffim / detectAndMeasure.py: 17%
586 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 01:15 -0700
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-14 01:15 -0700
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_interpolatedCenterAll",
335 "base_PixelFlags_flag_badCenter",
336 "base_PixelFlags_flag_edgeCenter",
337 "base_PixelFlags_flag_nodataCenter",
338 "base_PixelFlags_flag_saturatedCenter",
339 "base_PixelFlags_flag_saturated_templateCenter",
340 "base_PixelFlags_flag_spikeCenter",
341 ),
342 )
343 clearMaskPlanes = lsst.pex.config.ListField(
344 dtype=str,
345 doc="Mask planes to clear before running detection.",
346 default=("DETECTED", "DETECTED_NEGATIVE", "NOT_DEBLENDED", "STREAK"),
347 )
348 doRejectBadMaskPlaneDetections = pexConfig.Field(
349 dtype=bool,
350 default=True,
351 doc="Reject any peaks detected on ``badMaskPlanes`` before measurement."
352 "These should all be rejected downstream by ``badSourceFlags``, "
353 "but filtering earlier can save time."
354 )
355 badMaskPlanes = lsst.pex.config.ListField(
356 dtype=str,
357 doc="Detections whose footprint peak lies on a pixel with any of these"
358 " mask planes set will be rejected before measurement."
359 " Any missing mask planes will be silently ignored.",
360 default=("NO_DATA", "BAD", "SAT", "EDGE"),
361 )
362 raiseOnBadSubtractionRatio = pexConfig.Field(
363 dtype=bool,
364 default=True,
365 doc="Raise an error if the ratio of power in detected footprints"
366 " on the difference image to the power in footprints on the science"
367 " image exceeds ``badSubtractionRatioThreshold``",
368 )
369 badSubtractionRatioThreshold = pexConfig.Field(
370 dtype=float,
371 default=0.2,
372 doc="Maximum ratio of power in footprints on the difference image to"
373 " the same footprints on the science image."
374 "Only used if ``raiseOnBadSubtractionRatio`` is set",
375 )
376 badSubtractionVariationThreshold = pexConfig.Field(
377 dtype=float,
378 default=0.4,
379 doc="Maximum standard deviation of the ratio of power in footprints on"
380 " the difference image to the same footprints on the science image."
381 "Only used if ``raiseOnBadSubtractionRatio`` is set",
382 )
383 raiseOnNoDiaSources = pexConfig.Field(
384 dtype=bool,
385 default=True,
386 doc="Raise an algorithm error if no diaSources are detected.",
387 )
388 run_sattle = pexConfig.Field(
389 dtype=bool,
390 default=False,
391 doc="If true, dia source bounding boxes will be sent for verification"
392 "to the sattle service."
393 )
394 sattle_historical = pexConfig.Field(
395 dtype=bool,
396 default=False,
397 doc="If re-running a pipeline that requires sattle, this should be set "
398 "to True. This will populate sattle's cache with the historic data "
399 "closest in time to the exposure."
400 )
401 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
403 def setDefaults(self):
404 # Background subtraction
405 # Use a small binsize for the first pass to reduce detections on glints
406 # and extended structures. Should not affect the detectability of
407 # faint diaSources
408 self.subtractInitialBackground.binSize = 8
409 self.subtractInitialBackground.useApprox = False
410 self.subtractInitialBackground.statisticsProperty = "MEDIAN"
411 self.subtractInitialBackground.doFilterSuperPixels = True
412 self.subtractInitialBackground.ignoredPixelMask = ["BAD",
413 "EDGE",
414 "DETECTED",
415 "DETECTED_NEGATIVE",
416 "NO_DATA",
417 ]
418 # Use a larger binsize for the final background subtraction, to reduce
419 # over-subtraction of bright objects.
420 self.subtractFinalBackground.binSize = 40
421 self.subtractFinalBackground.useApprox = False
422 self.subtractFinalBackground.statisticsProperty = "MEDIAN"
423 self.subtractFinalBackground.doFilterSuperPixels = True
424 self.subtractFinalBackground.ignoredPixelMask = ["BAD",
425 "EDGE",
426 "DETECTED",
427 "DETECTED_NEGATIVE",
428 "NO_DATA",
429 ]
430 # Cosmic ray detection
431 self.cosmicray.keepCRs = True # do not interpolate over detected CRs
432 # DiaSource Detection
433 self.detection.thresholdPolarity = "both"
434 self.detection.thresholdValue = 5.0
435 self.detection.reEstimateBackground = False
436 self.detection.thresholdType = "pixel_stdev"
437 self.detection.excludeMaskPlanes = []
439 # Copy configs for binned streak detection from the base detection task
440 self.streakDetection.thresholdType = self.detection.thresholdType
441 self.streakDetection.reEstimateBackground = False
442 self.streakDetection.excludeMaskPlanes = self.detection.excludeMaskPlanes
443 self.streakDetection.thresholdValue = self.detection.thresholdValue
444 # Only detect positive streaks
445 self.streakDetection.thresholdPolarity = "positive"
446 # Do not grow detected mask for streaks
447 self.streakDetection.nSigmaToGrow = 0
448 # Set the streak mask along the entire fit line, not only where the
449 # detected mask is set.
450 self.maskStreaks.onlyMaskDetected = False
451 # Restrict streak masking from growing too large
452 self.maskStreaks.maxStreakWidth = 100
453 # Restrict the number of iterations allowed for fitting streaks
454 # When the fit is good it should solve quickly, and exit a bad fit quickly
455 self.maskStreaks.maxFitIter = 10
456 # Only mask to 2 sigma in width
457 self.maskStreaks.nSigmaMask = 2
458 # Threshold for including streaks after the Hough Transform.
459 # A lower value will detect more features that are less linear.
460 self.maskStreaks.absMinimumKernelHeight = 2
462 self.measurement.plugins.names |= ["ext_trailedSources_Naive",
463 "base_LocalPhotoCalib",
464 "base_LocalWcs",
465 "ext_shapeHSM_HsmSourceMoments",
466 "ext_shapeHSM_HsmPsfMoments",
467 "base_ClassificationSizeExtendedness",
468 ]
469 self.measurement.slots.psfShape = "ext_shapeHSM_HsmPsfMoments"
470 self.measurement.slots.shape = "ext_shapeHSM_HsmSourceMoments"
471 self.measurement.plugins["base_SdssCentroid"].maxDistToPeak = 5.0
472 self.forcedMeasurement.plugins = ["base_TransformedCentroid", "base_PsfFlux"]
473 self.forcedMeasurement.copyColumns = {
474 "id": "objectId", "parent": "parentObjectId", "coord_ra": "coord_ra", "coord_dec": "coord_dec"}
475 self.forcedMeasurement.slots.centroid = "base_TransformedCentroid"
476 self.forcedMeasurement.slots.shape = None
478 # Keep track of which footprints contain streaks
479 self.measurement.plugins["base_PixelFlags"].masksFpAnywhere = [
480 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE", "SPIKE"]
481 self.measurement.plugins["base_PixelFlags"].masksFpCenter = [
482 "STREAK", "INJECTED", "INJECTED_TEMPLATE", "HIGH_VARIANCE", "SATURATED_TEMPLATE", "SPIKE"]
483 self.skySources.avoidMask = ["DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA", "EDGE"]
485 def validate(self):
486 super().validate()
488 if self.run_sattle:
489 if not os.getenv("SATTLE_URI_BASE"):
490 raise pexConfig.FieldValidationError(DetectAndMeasureConfig.run_sattle, self,
491 "Sattle requested but SATTLE_URI_BASE "
492 "environment variable not set.")
495class DetectAndMeasureTask(lsst.pipe.base.PipelineTask):
496 """Detect and measure sources on a difference image.
497 """
498 ConfigClass = DetectAndMeasureConfig
499 _DefaultName = "detectAndMeasure"
501 def __init__(self, **kwargs):
502 super().__init__(**kwargs)
503 self.schema = afwTable.SourceTable.makeMinimalSchema()
505 self.algMetadata = dafBase.PropertyList()
506 if self.config.doSubtractBackground:
507 self.makeSubtask("subtractInitialBackground")
508 self.makeSubtask("subtractFinalBackground")
509 self.makeSubtask("detection", schema=self.schema)
510 if self.config.doDeblend:
511 self.makeSubtask("deblend", schema=self.schema)
512 self.makeSubtask("setPrimaryFlags", schema=self.schema, isSingleFrame=True)
513 self.makeSubtask("measurement", schema=self.schema,
514 algMetadata=self.algMetadata)
515 if self.config.doApCorr:
516 self.makeSubtask("applyApCorr", schema=self.measurement.schema)
517 if self.config.doForcedMeasurement:
518 self.schema.addField(
519 "ip_diffim_forced_PsfFlux_instFlux", "D",
520 "Forced PSF flux measured on the direct image.",
521 units="count")
522 self.schema.addField(
523 "ip_diffim_forced_PsfFlux_instFluxErr", "D",
524 "Forced PSF flux error measured on the direct image.",
525 units="count")
526 self.schema.addField(
527 "ip_diffim_forced_PsfFlux_area", "F",
528 "Forced PSF flux effective area of PSF.",
529 units="pixel")
530 self.schema.addField(
531 "ip_diffim_forced_PsfFlux_flag", "Flag",
532 "Forced PSF flux general failure flag.")
533 self.schema.addField(
534 "ip_diffim_forced_PsfFlux_flag_noGoodPixels", "Flag",
535 "Forced PSF flux not enough non-rejected pixels in data to attempt the fit.")
536 self.schema.addField(
537 "ip_diffim_forced_PsfFlux_flag_edge", "Flag",
538 "Forced PSF flux object was too close to the edge of the image to use the full PSF model.")
539 self.schema.addField(
540 "ip_diffim_forced_template_PsfFlux_instFlux", "D",
541 "Forced PSF flux measured on the template image.",
542 units="count")
543 self.schema.addField(
544 "ip_diffim_forced_template_PsfFlux_instFluxErr", "D",
545 "Forced PSF flux error measured on the template image.",
546 units="count")
547 self.schema.addField(
548 "ip_diffim_forced_template_PsfFlux_area", "F",
549 "Forced template PSF flux effective area of PSF.",
550 units="pixel")
551 self.schema.addField(
552 "ip_diffim_forced_template_PsfFlux_flag", "Flag",
553 "Forced template PSF flux general failure flag.")
554 self.schema.addField(
555 "ip_diffim_forced_template_PsfFlux_flag_noGoodPixels", "Flag",
556 "Forced template PSF flux not enough non-rejected pixels in data to attempt the fit.")
557 self.schema.addField(
558 "ip_diffim_forced_template_PsfFlux_flag_edge", "Flag",
559 """Forced template PSF flux object was too close to the edge of the image """
560 """to use the full PSF model.""")
561 self.makeSubtask("forcedMeasurement", refSchema=self.schema)
563 self.schema.addField("refMatchId", "L", "unique id of reference catalog match")
564 self.schema.addField("srcMatchId", "L", "unique id of source match")
565 # Create the sky source task for use by metrics,
566 # even if sky sources are not added to the diaSource catalog
567 self.makeSubtask("skySources", schema=self.schema)
568 if self.config.doMaskStreaks:
569 self.makeSubtask("maskStreaks")
570 self.makeSubtask("streakDetection")
571 self.makeSubtask("findGlints")
572 self.schema.addField("glint_trail", "Flag", "DiaSource is part of a glint trail.")
573 self.schema.addField("reliability", type="F", doc="Reliability score of the DiaSource")
575 # To get the "merge_*" fields in the schema; have to re-initialize
576 # this later, once we have a peak schema post-detection.
577 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"])
579 # Check that the schema and config are consistent
580 for flag in self.config.badSourceFlags:
581 if flag not in self.schema:
582 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag)
584 # initialize InitOutputs
585 self.outputSchema = afwTable.SourceCatalog(self.schema)
586 self.outputSchema.getTable().setMetadata(self.algMetadata)
588 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
589 inputRefs: pipeBase.InputQuantizedConnection,
590 outputRefs: pipeBase.OutputQuantizedConnection):
591 inputs = butlerQC.get(inputRefs)
592 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
593 idFactory = idGenerator.make_table_id_factory()
594 # Specify the fields that `annotate` needs below, to ensure they
595 # exist, even as None.
596 measurementResults = pipeBase.Struct(
597 subtractedMeasuredExposure=None,
598 diaSources=None,
599 maskedStreaks=None,
600 differenceBackground=None,
601 )
602 try:
603 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
604 except pipeBase.AlgorithmError as e:
605 error = pipeBase.AnnotatedPartialOutputsError.annotate(
606 e,
607 self,
608 measurementResults.subtractedMeasuredExposure,
609 measurementResults.diaSources,
610 measurementResults.maskedStreaks,
611 log=self.log
612 )
613 butlerQC.put(measurementResults, outputRefs)
614 raise error from e
615 butlerQC.put(measurementResults, outputRefs)
617 @timeMethod
618 def run(self, science, matchedTemplate, difference, kernelSources=None,
619 idFactory=None, measurementResults=None):
620 """Detect and measure sources on a difference image.
622 The difference image will be convolved with a gaussian approximation of
623 the PSF to form a maximum likelihood image for detection.
624 Close positive and negative detections will optionally be merged into
625 dipole diaSources.
626 Sky sources, or forced detections in background regions, will optionally
627 be added, and the configured measurement algorithm will be run on all
628 detections.
630 Parameters
631 ----------
632 science : `lsst.afw.image.ExposureF`
633 Science exposure that the template was subtracted from.
634 matchedTemplate : `lsst.afw.image.ExposureF`
635 Warped and PSF-matched template that was used produce the
636 difference image.
637 difference : `lsst.afw.image.ExposureF`
638 Result of subtracting template from the science image.
639 kernelSources : `lsst.afw.table.SourceCatalog`, optional
640 Final selection of sources that was used for psf matching.
641 idFactory : `lsst.afw.table.IdFactory`, optional
642 Generator object used to assign ids to detected sources in the
643 difference image. Ids from this generator are not set until after
644 deblending and merging positive/negative peaks.
645 measurementResults : `lsst.pipe.base.Struct`, optional
646 Result struct that is modified to allow saving of partial outputs
647 for some failure conditions. If the task completes successfully,
648 this is also returned.
650 Returns
651 -------
652 measurementResults : `lsst.pipe.base.Struct`
654 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
655 Subtracted exposure with detection mask applied.
656 ``diaSources`` : `lsst.afw.table.SourceCatalog`
657 The catalog of detected sources.
658 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
659 Background that was subtracted from the difference image.
660 """
661 if measurementResults is None:
662 measurementResults = pipeBase.Struct()
663 if idFactory is None:
664 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
666 # Check image properties and clear detection mask planes.
667 self._prepareInputs(difference)
668 if self.config.doSubtractBackground:
669 background = self._fitAndSubtractBackground(differenceExposure=difference)
670 else:
671 background = afwMath.BackgroundList()
673 if self.config.doFindCosmicRays:
674 self.findAndMaskCosmicRays(difference)
676 # Don't use the idFactory until after deblend+merge, so that we aren't
677 # generating ids that just get thrown away (footprint merge doesn't
678 # know about past ids).
679 table = afwTable.SourceTable.make(self.schema)
680 results = self.detection.run(
681 table=table,
682 exposure=difference,
683 doSmooth=True,
684 background=background,
685 clearMask=True,
686 )
687 measurementResults.differenceBackground = background
689 if self.config.doDeblend:
690 sources, positives, negatives = self._deblend(difference,
691 results.positive,
692 results.negative)
694 else:
695 positives = afwTable.SourceCatalog(self.schema)
696 results.positive.makeSources(positives)
697 negatives = afwTable.SourceCatalog(self.schema)
698 results.negative.makeSources(negatives)
699 sources = results.sources
701 self.processResults(science, matchedTemplate, difference,
702 sources, idFactory, kernelSources,
703 positives=positives,
704 negatives=negatives,
705 measurementResults=measurementResults)
706 return measurementResults
708 def _prepareInputs(self, difference):
709 """Ensure that we start with an empty detection and deblended mask.
711 Parameters
712 ----------
713 difference : `lsst.afw.image.ExposureF`
714 The difference image that will be used for detecting diaSources.
715 The mask plane will be modified in place.
717 Raises
718 ------
719 lsst.pipe.base.UpstreamFailureNoWorkFound
720 If the PSF is not usable for measurement.
721 """
722 # Check that we have a valid PSF now before we do more work
723 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
724 if np.isnan(sigma):
725 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.")
726 # Ensure that we start with an empty detection and deblended mask.
727 mask = difference.mask
728 for mp in self.config.clearMaskPlanes:
729 if mp not in mask.getMaskPlaneDict():
730 mask.addMaskPlane(mp)
731 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
733 def _fitAndSubtractBackground(self, differenceExposure, scoreExposure=None):
734 """Fit the final background to ``differenceExposure``.
736 A rough background is first fit on the difference and an internal
737 first-pass detection is run on the (background-subtracted) clone; the
738 resulting detection mask is then transferred to ``differenceExposure``
739 so the final background fit can mask out real sources. This two-pass
740 scheme is what suppresses spurious detections from glints and
741 extended structures: the small-binsize initial fit aggressively
742 over-subtracts those features, and the resulting peak mask lets the
743 large-binsize final fit produce a clean background. When
744 ``scoreExposure`` is supplied (score-image variant), the final
745 background is also subtracted from its image so the caller's final
746 detection runs on a properly background-subtracted score.
748 The first-pass detection's `Struct` is discarded; only the mask it
749 leaves on the clone is used downstream.
751 Parameters
752 ----------
753 differenceExposure : `lsst.afw.image.ExposureF`
754 Difference image. The final background is subtracted from its
755 image in place but its mask is not modified.
756 scoreExposure : `lsst.afw.image.ExposureF`, optional
757 Maximum-likelihood score image. When supplied, the final
758 background is also subtracted from its image.
760 Returns
761 -------
762 background : `lsst.afw.math.BackgroundList`
763 The fit final background.
764 """
765 if scoreExposure is None:
766 detectionExposure = differenceExposure.clone()
767 background = self.subtractInitialBackground.run(detectionExposure).background
768 doSmooth = True
769 else:
770 # Use a clone of differenceExposure because the background is
771 # subtracted in place.
772 background = self.subtractInitialBackground.run(differenceExposure.clone()).background
773 detectionExposure = scoreExposure.clone()
774 detectionExposure.image -= background.getImage()
775 doSmooth = False
776 table = afwTable.SourceTable.make(self.schema)
777 self.detection.run(
778 table=table,
779 exposure=detectionExposure,
780 doSmooth=doSmooth,
781 background=background,
782 clearMask=True,
783 )
784 # Use the temporary detection mask for the final background subtraction.
785 # The detection mask planes will be cleared before the final detection
786 # step, so it is OK if they get set for differenceExposure.
787 detectedBit = differenceExposure.mask.getPlaneBitMask(["DETECTED"])
788 detectedPix = detectionExposure.mask.array & detectedBit > 0
789 detectedNegativeBit = differenceExposure.mask.getPlaneBitMask(["DETECTED_NEGATIVE"])
790 detectedNegativePix = detectionExposure.mask.array & detectedNegativeBit > 0
791 differenceExposure.mask.array[detectedPix] |= detectedBit
792 differenceExposure.mask.array[detectedNegativePix] |= detectedNegativeBit
793 background = self.subtractFinalBackground.run(differenceExposure).background
794 if scoreExposure is not None:
795 # The preconvolution kernel is normalized to 1, so the same
796 # background level applies to the difference and score images.
797 scoreExposure.image -= background.getImage()
798 return background
800 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
801 kernelSources=None, positives=None, negatives=None, measurementResults=None):
802 """Measure and process the results of source detection.
804 Parameters
805 ----------
806 science : `lsst.afw.image.ExposureF`
807 Science exposure that the template was subtracted from.
808 matchedTemplate : `lsst.afw.image.ExposureF`
809 Warped and PSF-matched template that was used produce the
810 difference image.
811 difference : `lsst.afw.image.ExposureF`
812 Result of subtracting template from the science image.
813 sources : `lsst.afw.table.SourceCatalog`
814 Detected sources on the difference exposure.
815 idFactory : `lsst.afw.table.IdFactory`
816 Generator object used to assign ids to detected sources in the
817 difference image.
818 kernelSources : `lsst.afw.table.SourceCatalog`, optional
819 Final selection of sources that was used for psf matching.
820 positives : `lsst.afw.table.SourceCatalog`, optional
821 Positive polarity footprints.
822 negatives : `lsst.afw.table.SourceCatalog`, optional
823 Negative polarity footprints.
824 measurementResults : `lsst.pipe.base.Struct`, optional
825 Result struct that is modified to allow saving of partial outputs
826 for some failure conditions. If the task completes successfully,
827 this is also returned.
829 Returns
830 -------
831 measurementResults : `lsst.pipe.base.Struct`
833 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
834 Subtracted exposure with detection mask applied.
835 ``diaSources`` : `lsst.afw.table.SourceCatalog`
836 The catalog of detected sources.
837 """
838 if measurementResults is None:
839 measurementResults = pipeBase.Struct()
840 self.metadata["nUnmergedDiaSources"] = len(sources)
841 if self.config.doMerge:
842 # preserve peak schema, if there are any footprints
843 if len(positives) > 0:
844 peakSchema = positives[0].getFootprint().peaks.schema
845 elif len(negatives) > 0:
846 peakSchema = negatives[0].getFootprint().peaks.schema
847 else:
848 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
849 mergeList = afwDetection.FootprintMergeList(self.schema,
850 ["positive", "negative"], peakSchema)
851 initialDiaSources = afwTable.SourceCatalog(self.schema)
852 # Start with positive, as FootprintMergeList will self-merge the
853 # subsequent added catalogs, and we want to try to preserve
854 # deblended positive sources.
855 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0)
856 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0)
857 mergeList.getFinalSources(initialDiaSources)
858 # Flag as negative those sources that *only* came from the negative
859 # footprint set.
860 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \
861 ~initialDiaSources["merge_footprint_positive"]
862 self.log.info("Merging detections into %d sources", len(initialDiaSources))
863 else:
864 initialDiaSources = sources
866 # Assign source ids at the end: deblend/merge mean that we don't keep
867 # track of parents and children, we only care about the final ids.
868 for source in initialDiaSources:
869 source.setId(idFactory())
870 # Ensure sources added after this get correct ids.
871 initialDiaSources.getTable().setIdFactory(idFactory)
872 initialDiaSources.setMetadata(self.algMetadata)
874 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
876 if self.config.doMaskStreaks:
877 streakInfo = self._runStreakMasking(difference)
879 if self.config.doSkySources:
880 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
882 if self.config.doRejectBadMaskPlaneDetections:
883 # Save time by rejecting peaks on bad mask planes prior to
884 # measurement.
885 initialDiaSources = self._rejectBadMaskedDetections(initialDiaSources, difference.mask)
887 if not initialDiaSources.isContiguous():
888 initialDiaSources = initialDiaSources.copy(deep=True)
890 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
892 # Remove unphysical diaSources per config.badSourceFlags
893 diaSources = self._removeBadSources(initialDiaSources)
895 if self.config.run_sattle:
896 diaSources = self.filterSatellites(diaSources, science)
898 # Flag diaSources in glint trails, but do not remove them
899 diaSources, trail_parameters = self._find_glint_trails(diaSources)
900 if self.config.writeGlintInfo:
901 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo')
903 if self.config.doForcedMeasurement:
904 self.measureForcedSources(diaSources, science, difference.getWcs())
905 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
906 template=True)
908 # Clear the image plane for regions with NO_DATA.
909 # These regions are most often caused by insufficient template coverage.
910 # Do this for the final difference image after detection and measurement
911 # since the subtasks should all be configured to handle NO_DATA properly
912 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask('NO_DATA') > 0] = 0
914 measurementResults.subtractedMeasuredExposure = difference
916 if self.config.doMaskStreaks and self.config.writeStreakInfo:
917 measurementResults.maskedStreaks = streakInfo.maskedStreaks
919 if kernelSources is not None:
920 self.calculateMetrics(science, difference, diaSources, kernelSources)
922 if np.count_nonzero(~diaSources["sky_source"]) > 0:
923 measurementResults.diaSources = diaSources
924 elif self.config.raiseOnNoDiaSources:
925 raise NoDiaSourcesError()
926 elif len(diaSources) > 0:
927 # This option allows returning sky sources,
928 # even if there are no diaSources
929 measurementResults.diaSources = diaSources
930 self.log.info("Measured %d diaSources and %d sky sources",
931 np.count_nonzero(~diaSources["sky_source"]),
932 np.count_nonzero(diaSources["sky_source"])
933 )
934 return measurementResults
936 def _deblend(self, difference, positiveFootprints, negativeFootprints):
937 """Deblend the positive and negative footprints and return a catalog
938 containing just the children, and the deblended footprints.
940 Parameters
941 ----------
942 difference : `lsst.afw.image.Exposure`
943 Result of subtracting template from the science image.
944 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
945 Positive and negative polarity footprints measured on
946 ``difference`` to be deblended separately.
948 Returns
949 -------
950 sources : `lsst.afw.table.SourceCatalog`
951 Positive and negative deblended children.
952 positives, negatives : `lsst.afw.table.SourceCatalog`
953 Deblended positive and negative polarity sources with footprints
954 detected on ``difference``.
955 """
957 def deblend(footprints, negative=False):
958 """Deblend a positive or negative footprint set,
959 and return the deblended children.
961 Parameters
962 ----------
963 footprints : `lsst.afw.detection.FootprintSet`
964 negative : `bool`
965 Set True if the footprints contain negative fluxes
967 Returns
968 -------
969 sources : `lsst.afw.table.SourceCatalog`
970 """
971 sources = afwTable.SourceCatalog(self.schema)
972 footprints.makeSources(sources)
973 if negative:
974 # Invert the image so the deblender can run on positive peaks
975 difference_inverted = difference.clone()
976 difference_inverted.image *= -1
977 self.deblend.run(exposure=difference_inverted, sources=sources)
978 children = sources[sources["parent"] != 0]
979 # Set the heavy footprint pixel values back to reality
980 for child in children:
981 footprint = child.getFootprint()
982 array = footprint.getImageArray()
983 array *= -1
984 else:
985 self.deblend.run(exposure=difference, sources=sources)
986 self.setPrimaryFlags.run(sources)
987 children = sources["detect_isDeblendedSource"] == 1
988 sources = sources[children].copy(deep=True)
989 # Clear parents, so that measurement plugins behave correctly.
990 sources['parent'] = 0
991 return sources.copy(deep=True)
993 positives = deblend(positiveFootprints)
994 negatives = deblend(negativeFootprints, negative=True)
996 sources = afwTable.SourceCatalog(self.schema)
997 sources.reserve(len(positives) + len(negatives))
998 sources.extend(positives, deep=True)
999 sources.extend(negatives, deep=True)
1000 if len(negatives) > 0:
1001 sources[-len(negatives):]["is_negative"] = True
1002 return sources, positives, negatives
1004 def _rejectBadMaskedDetections(self, initialDiaSources, mask):
1005 """Remove detections whose footprint peak lies on a bad-masked pixel.
1007 Detections are rejected if any peak of the source footprint falls on a
1008 pixel with any of the ``config.badMaskPlanes`` bits set.
1010 Parameters
1011 ----------
1012 initialDiaSources : `lsst.afw.table.SourceCatalog`
1013 The catalog of detected peaks.
1014 mask : `lsst.afw.image.Mask`
1015 Mask of the image used for detection.
1017 Returns
1018 -------
1019 initialDiaSources : `lsst.afw.table.SourceCatalog`
1020 The catalog with bad-masked detections removed.
1021 """
1022 if not self.config.badMaskPlanes or len(initialDiaSources) == 0:
1023 self.metadata["nRejectedBadMaskPlanes"] = 0
1024 return initialDiaSources
1026 presentPlanes = [mp for mp in self.config.badMaskPlanes
1027 if mp in mask.getMaskPlaneDict()]
1028 if not presentPlanes:
1029 self.metadata["nRejectedBadMaskPlanes"] = 0
1030 return initialDiaSources
1032 badBitMask = mask.getPlaneBitMask(presentPlanes)
1033 x0, y0 = mask.getXY0()
1035 selector = np.ones(len(initialDiaSources), dtype=bool)
1036 for i, source in enumerate(initialDiaSources):
1037 peaks = source.getFootprint().getPeaks()
1038 for peak in peaks:
1039 ix = peak.getIx() - x0
1040 iy = peak.getIy() - y0
1041 if mask.array[iy, ix] & badBitMask:
1042 selector[i] = False
1043 break
1044 nRejected = np.count_nonzero(~selector)
1045 self.metadata["nRejectedBadMaskPlanes"] = nRejected
1046 if nRejected > 0:
1047 self.log.info("Rejected %d detections on pixels with bad mask "
1048 "planes %s before measurement.",
1049 nRejected, presentPlanes)
1050 return initialDiaSources[selector].copy(deep=True)
1052 def _removeBadSources(self, diaSources):
1053 """Remove unphysical diaSources from the catalog.
1055 Parameters
1056 ----------
1057 diaSources : `lsst.afw.table.SourceCatalog`
1058 The catalog of detected sources.
1060 Returns
1061 -------
1062 diaSources : `lsst.afw.table.SourceCatalog`
1063 The updated catalog of detected sources, with any source that has a
1064 flag in ``config.badSourceFlags`` set removed.
1065 """
1066 selector = np.ones(len(diaSources), dtype=bool)
1067 for flag in self.config.badSourceFlags:
1068 flags = diaSources[flag]
1069 nBad = np.count_nonzero(flags)
1070 if nBad > 0:
1071 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag)
1072 selector &= ~flags
1073 nBadTotal = np.count_nonzero(~selector)
1074 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal
1075 self.log.info("Removed %d unphysical sources.", nBadTotal)
1076 return diaSources[selector].copy(deep=True)
1078 def _find_glint_trails(self, diaSources):
1079 """Define a new flag column for diaSources that are in a glint trail.
1081 Parameters
1082 ----------
1083 diaSources : `lsst.afw.table.SourceCatalog`
1084 The catalog of detected sources.
1086 Returns
1087 -------
1088 diaSources : `lsst.afw.table.SourceCatalog`
1089 The updated catalog of detected sources, with a new bool column
1090 called 'glint_trail' added.
1092 trail_parameters : `dict`
1093 Parameters of all the trails that were found.
1094 """
1095 if self.config.doSkySources:
1096 # Do not include sky sources in glint detection
1097 candidateDiaSources = diaSources[~diaSources["sky_source"]].copy(deep=True)
1098 else:
1099 candidateDiaSources = diaSources
1100 trailed_glints = self.findGlints.run(candidateDiaSources)
1101 glint_mask = [True if id in trailed_glints.trailed_ids else False for id in diaSources['id']]
1102 if np.any(glint_mask):
1103 diaSources['glint_trail'] = np.array(glint_mask)
1105 slopes = np.array([trail.slope for trail in trailed_glints.parameters])
1106 intercepts = np.array([trail.intercept for trail in trailed_glints.parameters])
1107 stderrs = np.array([trail.stderr for trail in trailed_glints.parameters])
1108 lengths = np.array([trail.length for trail in trailed_glints.parameters])
1109 angles = np.array([trail.angle for trail in trailed_glints.parameters])
1110 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths,
1111 'angles': angles}
1113 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
1115 return diaSources, trail_parameters
1117 def addSkySources(self, diaSources, mask, seed,
1118 subtask=None):
1119 """Add sources in empty regions of the difference image
1120 for measuring the background.
1122 Parameters
1123 ----------
1124 diaSources : `lsst.afw.table.SourceCatalog`
1125 The catalog of detected sources.
1126 mask : `lsst.afw.image.Mask`
1127 Mask plane for determining regions where Sky sources can be added.
1128 seed : `int`
1129 Seed value to initialize the random number generator.
1130 """
1131 if subtask is None:
1132 subtask = self.skySources
1133 if subtask.config.nSources <= 0:
1134 self.metadata[f"n_{subtask.getName()}"] = 0
1135 return
1136 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
1137 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints)
1139 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
1140 """Use (matched) template and science image to constrain dipole fitting.
1142 Parameters
1143 ----------
1144 diaSources : `lsst.afw.table.SourceCatalog`
1145 The catalog of detected sources.
1146 science : `lsst.afw.image.ExposureF`
1147 Science exposure that the template was subtracted from.
1148 difference : `lsst.afw.image.ExposureF`
1149 Result of subtracting template from the science image.
1150 matchedTemplate : `lsst.afw.image.ExposureF`
1151 Warped and PSF-matched template that was used produce the
1152 difference image.
1153 """
1154 # Ensure that the required mask planes are present
1155 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere:
1156 difference.mask.addMaskPlane(mp)
1157 # Note that this may not be correct if we convolved the science image.
1158 # In the future we may wish to persist the matchedScience image.
1159 self.measurement.run(diaSources, difference, science, matchedTemplate)
1160 if self.config.doApCorr:
1161 apCorrMap = difference.getInfo().getApCorrMap()
1162 if apCorrMap is None:
1163 self.log.warning("Difference image does not have valid aperture correction; skipping.")
1164 else:
1165 self.applyApCorr.run(
1166 catalog=diaSources,
1167 apCorrMap=apCorrMap,
1168 )
1170 def measureForcedSources(self, diaSources, image, wcs, template=False):
1171 """Perform forced measurement of the diaSources on a direct image.
1173 Parameters
1174 ----------
1175 diaSources : `lsst.afw.table.SourceCatalog`
1176 The catalog of detected sources.
1177 image: `lsst.afw.image.ExposureF`
1178 Exposure that the forced measurement is being performed on
1179 wcs : `lsst.afw.geom.SkyWcs`
1180 Coordinate system definition (wcs) for the exposure.
1181 template : `bool`
1182 Is the forced measurement being performed on the template?
1183 """
1184 # Run forced psf photometry on the image at the diaSource locations.
1185 # Copy the measured flux and error into the diaSource.
1186 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1187 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1189 if template:
1190 base_key = 'ip_diffim_forced_template_PsfFlux'
1191 else:
1192 base_key = 'ip_diffim_forced_PsfFlux'
1193 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
1194 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
1195 f"{base_key}_instFlux", True)
1196 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
1197 f"{base_key}_instFluxErr", True)
1198 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
1199 f"{base_key}_area", True)
1200 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
1201 f"{base_key}_flag", True)
1202 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
1203 f"{base_key}_flag_noGoodPixels", True)
1204 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
1205 f"{base_key}_flag_edge", True)
1206 for diaSource, forcedSource in zip(diaSources, forcedSources):
1207 diaSource.assign(forcedSource, mapper)
1209 def findAndMaskCosmicRays(self, difference):
1210 """Detect and mask cosmic rays on the difference image.
1212 Parameters
1213 ----------
1214 difference : `lsst.afw.image.Exposure`
1215 The background-subtracted difference image.
1216 The mask plane will be modified in place.
1217 """
1218 # This is run on a background-subtracted difference image, so the
1219 # remaining background should be ~0.
1220 medianBg = 0.
1221 try:
1222 crs = findCosmicRays(
1223 difference.maskedImage,
1224 difference.psf,
1225 medianBg,
1226 pexConfig.makePropertySet(self.config.cosmicray),
1227 self.config.cosmicray.keepCRs,
1228 )
1229 except LengthError:
1230 raise TooManyCosmicRays(self.config.cosmicray.nCrPixelMax) from None
1231 num = 0
1232 if crs is not None:
1233 mask = difference.maskedImage.mask
1234 crBit = mask.getPlaneBitMask("CR")
1235 afwDetection.setMaskFromFootprintList(mask, crs, crBit)
1236 num = len(crs)
1238 text = "masked" if self.config.cosmicray.keepCRs else "interpolated over"
1239 self.log.info("Identified and %s %s cosmic rays.", text, num)
1240 self.metadata["cosmic_ray_count"] = num
1242 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1243 """Add difference image QA metrics to the Task metadata.
1245 This may be used to produce corresponding metrics (see
1246 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1248 Parameters
1249 ----------
1250 science : `lsst.afw.image.ExposureF`
1251 Science exposure that was subtracted.
1252 difference : `lsst.afw.image.Exposure`
1253 The target difference image to calculate metrics for.
1254 diaSources : `lsst.afw.table.SourceCatalog`
1255 The catalog of detected sources.
1256 kernelSources : `lsst.afw.table.SourceCatalog`
1257 Final selection of sources that was used for psf matching.
1258 """
1259 mask = difference.mask
1260 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1261 self.metadata["nGoodPixels"] = np.sum(~badPix)
1262 self.metadata["nBadPixels"] = np.sum(badPix)
1263 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0
1264 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0
1265 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix)
1266 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix)
1267 detPosPix &= badPix
1268 detNegPix &= badPix
1269 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1270 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1272 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1273 for maskPlane in metricsMaskPlanes:
1274 try:
1275 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1276 except InvalidParameterError:
1277 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1
1278 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1280 if self.config.doSkySources:
1281 skySources = diaSources[diaSources["sky_source"]]
1282 else:
1283 skySources = None
1284 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1286 self.metadata["residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1287 self.metadata["residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1288 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1289 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1290 self.log.info("Mean, stdev of ratio of difference to science "
1291 "pixels in star footprints: %5.4f, %5.4f",
1292 self.metadata["residualFootprintRatioMean"],
1293 self.metadata["residualFootprintRatioStdev"])
1294 if self.config.raiseOnBadSubtractionRatio:
1295 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold:
1296 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean,
1297 threshold=self.config.badSubtractionRatioThreshold)
1298 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold:
1299 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev,
1300 threshold=self.config.badSubtractionVariationThreshold)
1302 def getSattleDiaSourceAllowlist(self, diaSources, science):
1303 """Query the sattle service and determine which diaSources are allowed.
1305 Parameters
1306 ----------
1307 diaSources : `lsst.afw.table.SourceCatalog`
1308 The catalog of detected sources.
1309 science : `lsst.afw.image.ExposureF`
1310 Science exposure that was subtracted.
1312 Returns
1313 ----------
1314 allow_list : `list` of `int`
1315 diaSourceIds of diaSources that can be made public.
1317 Raises
1318 ------
1319 requests.HTTPError
1320 Raised if sattle call does not return success.
1321 """
1322 wcs = science.getWcs()
1323 visit_info = science.getInfo().getVisitInfo()
1324 visit_id = visit_info.getId()
1325 sattle_uri_base = os.getenv('SATTLE_URI_BASE')
1327 dia_sources_json = []
1328 for source in diaSources:
1329 source_bbox = source.getFootprint().getBBox()
1330 corners = wcs.pixelToSky([lsst.geom.Point2D(c) for c in source_bbox.getCorners()])
1331 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()] for pt in corners]
1332 dia_sources_json.append({"diasource_id": source["id"], "bbox": bbox_radec})
1334 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(),
1335 "diasources": dia_sources_json, "historical": self.config.sattle_historical}
1337 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list',
1338 json=payload)
1340 # retry once if visit cache is not populated
1341 if sattle_output.status_code == 404:
1342 self.log.warning(f'Visit {visit_id} not found in sattle cache, re-sending')
1343 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1344 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', json=payload)
1346 sattle_output.raise_for_status()
1348 return sattle_output.json()['allow_list']
1350 def filterSatellites(self, diaSources, science):
1351 """Remove diaSources overlapping predicted satellite positions.
1353 Parameters
1354 ----------
1355 diaSources : `lsst.afw.table.SourceCatalog`
1356 The catalog of detected sources.
1357 science : `lsst.afw.image.ExposureF`
1358 Science exposure that was subtracted.
1360 Returns
1361 ----------
1362 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1363 Filtered catalog of diaSources
1364 """
1366 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1368 if allow_list:
1369 allow_set = set(allow_list)
1370 allowed_ids = [source['id'] in allow_set for source in diaSources]
1371 diaSources = diaSources[np.array(allowed_ids)].copy(deep=True)
1372 else:
1373 self.log.warning('Sattle allowlist is empty, all diaSources removed')
1374 diaSources = diaSources[0:0].copy(deep=True)
1375 return diaSources
1377 def _runStreakMasking(self, difference):
1378 """Do streak masking and optionally save the resulting streak
1379 fit parameters in a catalog.
1381 Only returns non-empty streakInfo if self.config.writeStreakInfo
1382 is set. The difference image is binned by self.config.streakBinFactor
1383 (and detection is run a second time) so that regions with lower
1384 surface brightness streaks are more likely to fall above the
1385 detection threshold.
1387 Parameters
1388 ----------
1389 difference: `lsst.afw.image.Exposure`
1390 The exposure in which to search for streaks. Must have a detection
1391 mask.
1393 Returns
1394 -------
1395 streakInfo: `lsst.pipe.base.Struct`
1396 ``rho`` : `np.ndarray`
1397 Angle of detected streak.
1398 ``theta`` : `np.ndarray`
1399 Distance from center of detected streak.
1400 ``sigma`` : `np.ndarray`
1401 Width of streak profile.
1402 ``reducedChi2`` : `np.ndarray`
1403 Reduced chi2 of the best-fit streak profile.
1404 ``modelMaximum`` : `np.ndarray`
1405 Peak value of the fit line profile.
1406 """
1407 maskedImage = difference.maskedImage
1408 # Bin the diffim to enhance low surface brightness streaks
1409 binnedMaskedImage = afwMath.binImage(maskedImage,
1410 self.config.streakBinFactor,
1411 self.config.streakBinFactor)
1412 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1413 binnedExposure.setMaskedImage(binnedMaskedImage)
1414 # Clear the DETECTED mask plane before streak detection
1415 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED')
1416 # Rerun detection to set the DETECTED mask plane on binnedExposure
1417 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1418 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1419 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True,
1420 sigma=sigma/self.config.streakBinFactor)
1421 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED')
1422 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1423 axis=0).repeat(self.config.streakBinFactor,
1424 axis=1)
1425 # Create new version of a diffim with DETECTED based on binnedExposure
1426 streakMaskedImage = maskedImage.clone()
1427 ysize, xsize = rescaledDetectedMaskPlane.shape
1428 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1429 # Detect streaks on this new version of the diffim
1430 streaks = self.maskStreaks.run(streakMaskedImage)
1431 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK')
1432 # Apply the new STREAK mask to the original diffim
1433 maskedImage.mask.array |= streakMaskPlane
1435 if self.config.writeStreakInfo:
1436 rhos = np.array([line.rho for line in streaks.lines])
1437 thetas = np.array([line.theta for line in streaks.lines])
1438 sigmas = np.array([line.sigma for line in streaks.lines])
1439 chi2s = np.array([line.reducedChi2 for line in streaks.lines])
1440 modelMaximums = np.array([line.modelMaximum for line in streaks.lines])
1441 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s,
1442 'modelMaximum': modelMaximums}
1443 else:
1444 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]),
1445 'reducedChi2': np.array([]), 'modelMaximum': np.array([])}
1446 return pipeBase.Struct(maskedStreaks=streakInfo)
1449class DetectAndMeasureScoreConnections(DetectAndMeasureConnections):
1450 scoreExposure = pipeBase.connectionTypes.Input(
1451 doc="Maximum likelihood image for detection.",
1452 dimensions=("instrument", "visit", "detector"),
1453 storageClass="ExposureF",
1454 name="{fakesType}{coaddName}Diff_scoreTempExp",
1455 )
1456 scoreMeasuredExposure = pipeBase.connectionTypes.Output(
1457 doc="Score image after backgrond subtraction and detection.",
1458 dimensions=("instrument", "visit", "detector"),
1459 storageClass="ExposureF",
1460 name="{fakesType}{coaddName}Diff_scoreExp",
1461 )
1464class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1465 pipelineConnections=DetectAndMeasureScoreConnections):
1466 pass
1469class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1470 """Detect DIA sources using a score image,
1471 and measure the detections on the difference image.
1473 Source detection is run on the supplied score, or maximum likelihood,
1474 image. Note that no additional convolution will be done in this case.
1475 Close positive and negative detections will optionally be merged into
1476 dipole diaSources.
1477 Sky sources, or forced detections in background regions, will optionally
1478 be added, and the configured measurement algorithm will be run on all
1479 detections.
1480 """
1481 ConfigClass = DetectAndMeasureScoreConfig
1482 _DefaultName = "detectAndMeasureScore"
1484 @timeMethod
1485 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1486 idFactory=None, measurementResults=None):
1487 """Detect and measure sources on a score image.
1489 Parameters
1490 ----------
1491 science : `lsst.afw.image.ExposureF`
1492 Science exposure that the template was subtracted from.
1493 matchedTemplate : `lsst.afw.image.ExposureF`
1494 Warped and PSF-matched template that was used produce the
1495 difference image.
1496 difference : `lsst.afw.image.ExposureF`
1497 Result of subtracting template from the science image.
1498 scoreExposure : `lsst.afw.image.ExposureF`
1499 Score or maximum likelihood difference image
1500 kernelSources : `lsst.afw.table.SourceCatalog`
1501 Final selection of sources that was used for psf matching.
1502 idFactory : `lsst.afw.table.IdFactory`, optional
1503 Generator object used to assign ids to detected sources in the
1504 difference image. Ids from this generator are not set until after
1505 deblending and merging positive/negative peaks.
1506 measurementResults : `lsst.pipe.base.Struct`, optional
1507 Result struct that is modified to allow saving of partial outputs
1508 for some failure conditions. If the task completes successfully,
1509 this is also returned.
1511 Returns
1512 -------
1513 measurementResults : `lsst.pipe.base.Struct`
1515 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1516 Subtracted exposure with detection mask applied.
1517 ``scoreMeasuredExposure`` : `lsst.afw.image.ExposureF`
1518 Score exposure with detection mask applied.
1519 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1520 The catalog of detected sources.
1521 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
1522 Background that was subtracted from the difference image.
1523 """
1524 if measurementResults is None:
1525 measurementResults = pipeBase.Struct()
1526 if idFactory is None:
1527 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
1529 # Check image properties and clear detection mask planes.
1530 self._prepareInputs(difference)
1531 self._prepareInputs(scoreExposure)
1532 if self.config.doSubtractBackground:
1533 background = self._fitAndSubtractBackground(
1534 differenceExposure=difference, scoreExposure=scoreExposure,
1535 )
1536 else:
1537 background = afwMath.BackgroundList()
1539 if self.config.doFindCosmicRays:
1540 # Cosmic rays are detected on the difference and the resulting
1541 # mask is propagated to the score image used for detection.
1542 self.findAndMaskCosmicRays(difference)
1543 scoreExposure.mask.array |= difference.mask.array
1545 # Don't use the idFactory until after deblend+merge, so that we aren't
1546 # generating ids that just get thrown away (footprint merge doesn't
1547 # know about past ids).
1548 table = afwTable.SourceTable.make(self.schema)
1549 detectResults = self.detection.run(
1550 table=table,
1551 exposure=scoreExposure,
1552 doSmooth=False,
1553 background=background,
1554 clearMask=True,
1555 )
1556 measurementResults.differenceBackground = background
1557 measurementResults.scoreMeasuredExposure = scoreExposure
1559 # Copy the final detection masks from the Score image to the difference.
1560 # Do not copy all mask planes, since the convolution will grow them by
1561 # the size of the convolution kernel.
1562 detectedBit = difference.mask.getPlaneBitMask(["DETECTED"])
1563 detectedPix = scoreExposure.mask.array & detectedBit > 0
1564 detectedNegativeBit = difference.mask.getPlaneBitMask(["DETECTED_NEGATIVE"])
1565 detectedNegativePix = scoreExposure.mask.array & detectedNegativeBit > 0
1566 difference.mask.array[detectedPix] |= detectedBit
1567 difference.mask.array[detectedNegativePix] |= detectedNegativeBit
1568 # Also set the EDGE mask, since the science image was convolved
1569 edgeBit = difference.mask.getPlaneBitMask(["EDGE"])
1570 edgePix = scoreExposure.mask.array & edgeBit > 0
1571 difference.mask.array[edgePix] |= edgeBit
1573 if self.config.doDeblend:
1574 sources, positives, negatives = self._deblend(difference,
1575 detectResults.positive,
1576 detectResults.negative)
1578 self.processResults(science, matchedTemplate, difference,
1579 sources, idFactory, kernelSources,
1580 positives=positives,
1581 negatives=negatives,
1582 measurementResults=measurementResults)
1584 else:
1585 positives = afwTable.SourceCatalog(self.schema)
1586 detectResults.positive.makeSources(positives)
1587 negatives = afwTable.SourceCatalog(self.schema)
1588 detectResults.negative.makeSources(negatives)
1589 self.processResults(science, matchedTemplate, difference,
1590 detectResults.sources, idFactory, kernelSources,
1591 positives=positives,
1592 negatives=negatives,
1593 measurementResults=measurementResults)
1594 return measurementResults