Coverage for python/lsst/ip/diffim/detectAndMeasure.py: 87%
587 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 02:04 -0700
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 02:04 -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: 515 ↛ 517line 515 didn't jump to line 517 because the condition on line 515 was always true
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: 568 ↛ 571line 568 didn't jump to line 571 because the condition on line 568 was always true
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")
574 self.schema.addField("reliabilityVersion", type=str, size=7, doc="Version of the reliability model")
576 # To get the "merge_*" fields in the schema; have to re-initialize
577 # this later, once we have a peak schema post-detection.
578 lsst.afw.detection.FootprintMergeList(self.schema, ["positive", "negative"])
580 # Check that the schema and config are consistent
581 for flag in self.config.badSourceFlags:
582 if flag not in self.schema:
583 raise pipeBase.InvalidQuantumError("Field %s not in schema" % flag)
585 # initialize InitOutputs
586 self.outputSchema = afwTable.SourceCatalog(self.schema)
587 self.outputSchema.getTable().setMetadata(self.algMetadata)
589 def runQuantum(self, butlerQC: pipeBase.QuantumContext,
590 inputRefs: pipeBase.InputQuantizedConnection,
591 outputRefs: pipeBase.OutputQuantizedConnection):
592 inputs = butlerQC.get(inputRefs)
593 idGenerator = self.config.idGenerator.apply(butlerQC.quantum.dataId)
594 idFactory = idGenerator.make_table_id_factory()
595 # Specify the fields that `annotate` needs below, to ensure they
596 # exist, even as None.
597 measurementResults = pipeBase.Struct(
598 subtractedMeasuredExposure=None,
599 diaSources=None,
600 maskedStreaks=None,
601 differenceBackground=None,
602 )
603 try:
604 self.run(**inputs, idFactory=idFactory, measurementResults=measurementResults)
605 except pipeBase.AlgorithmError as e:
606 error = pipeBase.AnnotatedPartialOutputsError.annotate(
607 e,
608 self,
609 measurementResults.subtractedMeasuredExposure,
610 measurementResults.diaSources,
611 measurementResults.maskedStreaks,
612 log=self.log
613 )
614 butlerQC.put(measurementResults, outputRefs)
615 raise error from e
616 butlerQC.put(measurementResults, outputRefs)
618 @timeMethod
619 def run(self, science, matchedTemplate, difference, kernelSources=None,
620 idFactory=None, measurementResults=None):
621 """Detect and measure sources on a difference image.
623 The difference image will be convolved with a gaussian approximation of
624 the PSF to form a maximum likelihood image for detection.
625 Close positive and negative detections will optionally be merged into
626 dipole diaSources.
627 Sky sources, or forced detections in background regions, will optionally
628 be added, and the configured measurement algorithm will be run on all
629 detections.
631 Parameters
632 ----------
633 science : `lsst.afw.image.ExposureF`
634 Science exposure that the template was subtracted from.
635 matchedTemplate : `lsst.afw.image.ExposureF`
636 Warped and PSF-matched template that was used produce the
637 difference image.
638 difference : `lsst.afw.image.ExposureF`
639 Result of subtracting template from the science image.
640 kernelSources : `lsst.afw.table.SourceCatalog`, optional
641 Final selection of sources that was used for psf matching.
642 idFactory : `lsst.afw.table.IdFactory`, optional
643 Generator object used to assign ids to detected sources in the
644 difference image. Ids from this generator are not set until after
645 deblending and merging positive/negative peaks.
646 measurementResults : `lsst.pipe.base.Struct`, optional
647 Result struct that is modified to allow saving of partial outputs
648 for some failure conditions. If the task completes successfully,
649 this is also returned.
651 Returns
652 -------
653 measurementResults : `lsst.pipe.base.Struct`
655 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
656 Subtracted exposure with detection mask applied.
657 ``diaSources`` : `lsst.afw.table.SourceCatalog`
658 The catalog of detected sources.
659 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
660 Background that was subtracted from the difference image.
661 """
662 if measurementResults is None: 662 ↛ 664line 662 didn't jump to line 664 because the condition on line 662 was always true
663 measurementResults = pipeBase.Struct()
664 if idFactory is None:
665 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
667 # Check image properties and clear detection mask planes.
668 self._prepareInputs(difference)
669 if self.config.doSubtractBackground:
670 background = self._fitAndSubtractBackground(differenceExposure=difference)
671 else:
672 background = afwMath.BackgroundList()
674 if self.config.doFindCosmicRays: 674 ↛ 680line 674 didn't jump to line 680 because the condition on line 674 was always true
675 self.findAndMaskCosmicRays(difference)
677 # Don't use the idFactory until after deblend+merge, so that we aren't
678 # generating ids that just get thrown away (footprint merge doesn't
679 # know about past ids).
680 table = afwTable.SourceTable.make(self.schema)
681 results = self.detection.run(
682 table=table,
683 exposure=difference,
684 doSmooth=True,
685 background=background,
686 clearMask=True,
687 )
688 measurementResults.differenceBackground = background
690 if self.config.doDeblend:
691 sources, positives, negatives = self._deblend(difference,
692 results.positive,
693 results.negative)
695 else:
696 positives = afwTable.SourceCatalog(self.schema)
697 results.positive.makeSources(positives)
698 negatives = afwTable.SourceCatalog(self.schema)
699 results.negative.makeSources(negatives)
700 sources = results.sources
702 self.processResults(science, matchedTemplate, difference,
703 sources, idFactory, kernelSources,
704 positives=positives,
705 negatives=negatives,
706 measurementResults=measurementResults)
707 return measurementResults
709 def _prepareInputs(self, difference):
710 """Ensure that we start with an empty detection and deblended mask.
712 Parameters
713 ----------
714 difference : `lsst.afw.image.ExposureF`
715 The difference image that will be used for detecting diaSources.
716 The mask plane will be modified in place.
718 Raises
719 ------
720 lsst.pipe.base.UpstreamFailureNoWorkFound
721 If the PSF is not usable for measurement.
722 """
723 # Check that we have a valid PSF now before we do more work
724 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
725 if np.isnan(sigma):
726 raise pipeBase.UpstreamFailureNoWorkFound("Invalid PSF detected! PSF width evaluates to NaN.")
727 # Ensure that we start with an empty detection and deblended mask.
728 mask = difference.mask
729 for mp in self.config.clearMaskPlanes:
730 if mp not in mask.getMaskPlaneDict():
731 mask.addMaskPlane(mp)
732 mask &= ~mask.getPlaneBitMask(self.config.clearMaskPlanes)
734 def _fitAndSubtractBackground(self, differenceExposure, scoreExposure=None):
735 """Fit the final background to ``differenceExposure``.
737 A rough background is first fit on the difference and an internal
738 first-pass detection is run on the (background-subtracted) clone; the
739 resulting detection mask is then transferred to ``differenceExposure``
740 so the final background fit can mask out real sources. This two-pass
741 scheme is what suppresses spurious detections from glints and
742 extended structures: the small-binsize initial fit aggressively
743 over-subtracts those features, and the resulting peak mask lets the
744 large-binsize final fit produce a clean background. When
745 ``scoreExposure`` is supplied (score-image variant), the final
746 background is also subtracted from its image so the caller's final
747 detection runs on a properly background-subtracted score.
749 The first-pass detection's `Struct` is discarded; only the mask it
750 leaves on the clone is used downstream.
752 Parameters
753 ----------
754 differenceExposure : `lsst.afw.image.ExposureF`
755 Difference image. The final background is subtracted from its
756 image in place but its mask is not modified.
757 scoreExposure : `lsst.afw.image.ExposureF`, optional
758 Maximum-likelihood score image. When supplied, the final
759 background is also subtracted from its image.
761 Returns
762 -------
763 background : `lsst.afw.math.BackgroundList`
764 The fit final background.
765 """
766 if scoreExposure is None:
767 detectionExposure = differenceExposure.clone()
768 background = self.subtractInitialBackground.run(detectionExposure).background
769 doSmooth = True
770 else:
771 # Use a clone of differenceExposure because the background is
772 # subtracted in place.
773 background = self.subtractInitialBackground.run(differenceExposure.clone()).background
774 detectionExposure = scoreExposure.clone()
775 detectionExposure.image -= background.getImage()
776 doSmooth = False
777 table = afwTable.SourceTable.make(self.schema)
778 self.detection.run(
779 table=table,
780 exposure=detectionExposure,
781 doSmooth=doSmooth,
782 background=background,
783 clearMask=True,
784 )
785 # Use the temporary detection mask for the final background subtraction.
786 # The detection mask planes will be cleared before the final detection
787 # step, so it is OK if they get set for differenceExposure.
788 detectedBit = differenceExposure.mask.getPlaneBitMask(["DETECTED"])
789 detectedPix = detectionExposure.mask.array & detectedBit > 0
790 detectedNegativeBit = differenceExposure.mask.getPlaneBitMask(["DETECTED_NEGATIVE"])
791 detectedNegativePix = detectionExposure.mask.array & detectedNegativeBit > 0
792 differenceExposure.mask.array[detectedPix] |= detectedBit
793 differenceExposure.mask.array[detectedNegativePix] |= detectedNegativeBit
794 background = self.subtractFinalBackground.run(differenceExposure).background
795 if scoreExposure is not None:
796 # The preconvolution kernel is normalized to 1, so the same
797 # background level applies to the difference and score images.
798 scoreExposure.image -= background.getImage()
799 return background
801 def processResults(self, science, matchedTemplate, difference, sources, idFactory,
802 kernelSources=None, positives=None, negatives=None, measurementResults=None):
803 """Measure and process the results of source detection.
805 Parameters
806 ----------
807 science : `lsst.afw.image.ExposureF`
808 Science exposure that the template was subtracted from.
809 matchedTemplate : `lsst.afw.image.ExposureF`
810 Warped and PSF-matched template that was used produce the
811 difference image.
812 difference : `lsst.afw.image.ExposureF`
813 Result of subtracting template from the science image.
814 sources : `lsst.afw.table.SourceCatalog`
815 Detected sources on the difference exposure.
816 idFactory : `lsst.afw.table.IdFactory`
817 Generator object used to assign ids to detected sources in the
818 difference image.
819 kernelSources : `lsst.afw.table.SourceCatalog`, optional
820 Final selection of sources that was used for psf matching.
821 positives : `lsst.afw.table.SourceCatalog`, optional
822 Positive polarity footprints.
823 negatives : `lsst.afw.table.SourceCatalog`, optional
824 Negative polarity footprints.
825 measurementResults : `lsst.pipe.base.Struct`, optional
826 Result struct that is modified to allow saving of partial outputs
827 for some failure conditions. If the task completes successfully,
828 this is also returned.
830 Returns
831 -------
832 measurementResults : `lsst.pipe.base.Struct`
834 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
835 Subtracted exposure with detection mask applied.
836 ``diaSources`` : `lsst.afw.table.SourceCatalog`
837 The catalog of detected sources.
838 """
839 if measurementResults is None: 839 ↛ 840line 839 didn't jump to line 840 because the condition on line 839 was never true
840 measurementResults = pipeBase.Struct()
841 self.metadata["nUnmergedDiaSources"] = len(sources)
842 if self.config.doMerge:
843 # preserve peak schema, if there are any footprints
844 if len(positives) > 0:
845 peakSchema = positives[0].getFootprint().peaks.schema
846 elif len(negatives) > 0: 846 ↛ 849line 846 didn't jump to line 849 because the condition on line 846 was always true
847 peakSchema = negatives[0].getFootprint().peaks.schema
848 else:
849 peakSchema = afwDetection.PeakTable.makeMinimalSchema()
850 mergeList = afwDetection.FootprintMergeList(self.schema,
851 ["positive", "negative"], peakSchema)
852 initialDiaSources = afwTable.SourceCatalog(self.schema)
853 # Start with positive, as FootprintMergeList will self-merge the
854 # subsequent added catalogs, and we want to try to preserve
855 # deblended positive sources.
856 mergeList.addCatalog(initialDiaSources.table, positives, "positive", minNewPeakDist=0)
857 mergeList.addCatalog(initialDiaSources.table, negatives, "negative", minNewPeakDist=0)
858 mergeList.getFinalSources(initialDiaSources)
859 # Flag as negative those sources that *only* came from the negative
860 # footprint set.
861 initialDiaSources["is_negative"] = initialDiaSources["merge_footprint_negative"] & \
862 ~initialDiaSources["merge_footprint_positive"]
863 self.log.info("Merging detections into %d sources", len(initialDiaSources))
864 else:
865 initialDiaSources = sources
867 # Assign source ids at the end: deblend/merge mean that we don't keep
868 # track of parents and children, we only care about the final ids.
869 for source in initialDiaSources:
870 source.setId(idFactory())
871 # Ensure sources added after this get correct ids.
872 initialDiaSources.getTable().setIdFactory(idFactory)
873 initialDiaSources.setMetadata(self.algMetadata)
875 self.metadata["nMergedDiaSources"] = len(initialDiaSources)
877 if self.config.doMaskStreaks: 877 ↛ 880line 877 didn't jump to line 880 because the condition on line 877 was always true
878 streakInfo = self._runStreakMasking(difference)
880 if self.config.doSkySources:
881 self.addSkySources(initialDiaSources, difference.mask, difference.info.id)
883 if self.config.doRejectBadMaskPlaneDetections: 883 ↛ 888line 883 didn't jump to line 888 because the condition on line 883 was always true
884 # Save time by rejecting peaks on bad mask planes prior to
885 # measurement.
886 initialDiaSources = self._rejectBadMaskedDetections(initialDiaSources, difference.mask)
888 if not initialDiaSources.isContiguous(): 888 ↛ 889line 888 didn't jump to line 889 because the condition on line 888 was never true
889 initialDiaSources = initialDiaSources.copy(deep=True)
891 self.measureDiaSources(initialDiaSources, science, difference, matchedTemplate)
893 # Remove unphysical diaSources per config.badSourceFlags
894 diaSources = self._removeBadSources(initialDiaSources)
896 if self.config.run_sattle:
897 diaSources = self.filterSatellites(diaSources, science)
899 # Flag diaSources in glint trails, but do not remove them
900 diaSources, trail_parameters = self._find_glint_trails(diaSources)
901 if self.config.writeGlintInfo: 901 ↛ 904line 901 didn't jump to line 904 because the condition on line 901 was always true
902 measurementResults.mergeItems(trail_parameters, 'glintTrailInfo')
904 if self.config.doForcedMeasurement:
905 self.measureForcedSources(diaSources, science, difference.getWcs())
906 self.measureForcedSources(diaSources, matchedTemplate, difference.getWcs(),
907 template=True)
909 # Clear the image plane for regions with NO_DATA.
910 # These regions are most often caused by insufficient template coverage.
911 # Do this for the final difference image after detection and measurement
912 # since the subtasks should all be configured to handle NO_DATA properly
913 difference.image.array[difference.mask.array & difference.mask.getPlaneBitMask('NO_DATA') > 0] = 0
915 measurementResults.subtractedMeasuredExposure = difference
917 if self.config.doMaskStreaks and self.config.writeStreakInfo: 917 ↛ 918line 917 didn't jump to line 918 because the condition on line 917 was never true
918 measurementResults.maskedStreaks = streakInfo.maskedStreaks
920 if kernelSources is not None:
921 self.calculateMetrics(science, difference, diaSources, kernelSources)
923 if np.count_nonzero(~diaSources["sky_source"]) > 0:
924 measurementResults.diaSources = diaSources
925 elif self.config.raiseOnNoDiaSources:
926 raise NoDiaSourcesError()
927 elif len(diaSources) > 0: 927 ↛ 931line 927 didn't jump to line 931 because the condition on line 927 was always true
928 # This option allows returning sky sources,
929 # even if there are no diaSources
930 measurementResults.diaSources = diaSources
931 self.log.info("Measured %d diaSources and %d sky sources",
932 np.count_nonzero(~diaSources["sky_source"]),
933 np.count_nonzero(diaSources["sky_source"])
934 )
935 return measurementResults
937 def _deblend(self, difference, positiveFootprints, negativeFootprints):
938 """Deblend the positive and negative footprints and return a catalog
939 containing just the children, and the deblended footprints.
941 Parameters
942 ----------
943 difference : `lsst.afw.image.Exposure`
944 Result of subtracting template from the science image.
945 positiveFootprints, negativeFootprints : `lsst.afw.detection.FootprintSet`
946 Positive and negative polarity footprints measured on
947 ``difference`` to be deblended separately.
949 Returns
950 -------
951 sources : `lsst.afw.table.SourceCatalog`
952 Positive and negative deblended children.
953 positives, negatives : `lsst.afw.table.SourceCatalog`
954 Deblended positive and negative polarity sources with footprints
955 detected on ``difference``.
956 """
958 def deblend(footprints, negative=False):
959 """Deblend a positive or negative footprint set,
960 and return the deblended children.
962 Parameters
963 ----------
964 footprints : `lsst.afw.detection.FootprintSet`
965 negative : `bool`
966 Set True if the footprints contain negative fluxes
968 Returns
969 -------
970 sources : `lsst.afw.table.SourceCatalog`
971 """
972 sources = afwTable.SourceCatalog(self.schema)
973 footprints.makeSources(sources)
974 if negative:
975 # Invert the image so the deblender can run on positive peaks
976 difference_inverted = difference.clone()
977 difference_inverted.image *= -1
978 self.deblend.run(exposure=difference_inverted, sources=sources)
979 children = sources[sources["parent"] != 0]
980 # Set the heavy footprint pixel values back to reality
981 for child in children:
982 footprint = child.getFootprint()
983 array = footprint.getImageArray()
984 array *= -1
985 else:
986 self.deblend.run(exposure=difference, sources=sources)
987 self.setPrimaryFlags.run(sources)
988 children = sources["detect_isDeblendedSource"] == 1
989 sources = sources[children].copy(deep=True)
990 # Clear parents, so that measurement plugins behave correctly.
991 sources['parent'] = 0
992 return sources.copy(deep=True)
994 positives = deblend(positiveFootprints)
995 negatives = deblend(negativeFootprints, negative=True)
997 sources = afwTable.SourceCatalog(self.schema)
998 sources.reserve(len(positives) + len(negatives))
999 sources.extend(positives, deep=True)
1000 sources.extend(negatives, deep=True)
1001 if len(negatives) > 0:
1002 sources[-len(negatives):]["is_negative"] = True
1003 return sources, positives, negatives
1005 def _rejectBadMaskedDetections(self, initialDiaSources, mask):
1006 """Remove detections whose footprint peak lies on a bad-masked pixel.
1008 Detections are rejected if any peak of the source footprint falls on a
1009 pixel with any of the ``config.badMaskPlanes`` bits set.
1011 Parameters
1012 ----------
1013 initialDiaSources : `lsst.afw.table.SourceCatalog`
1014 The catalog of detected peaks.
1015 mask : `lsst.afw.image.Mask`
1016 Mask of the image used for detection.
1018 Returns
1019 -------
1020 initialDiaSources : `lsst.afw.table.SourceCatalog`
1021 The catalog with bad-masked detections removed.
1022 """
1023 if not self.config.badMaskPlanes or len(initialDiaSources) == 0: 1023 ↛ 1024line 1023 didn't jump to line 1024 because the condition on line 1023 was never true
1024 self.metadata["nRejectedBadMaskPlanes"] = 0
1025 return initialDiaSources
1027 presentPlanes = [mp for mp in self.config.badMaskPlanes
1028 if mp in mask.getMaskPlaneDict()]
1029 if not presentPlanes: 1029 ↛ 1030line 1029 didn't jump to line 1030 because the condition on line 1029 was never true
1030 self.metadata["nRejectedBadMaskPlanes"] = 0
1031 return initialDiaSources
1033 badBitMask = mask.getPlaneBitMask(presentPlanes)
1034 x0, y0 = mask.getXY0()
1036 selector = np.ones(len(initialDiaSources), dtype=bool)
1037 for i, source in enumerate(initialDiaSources):
1038 peaks = source.getFootprint().getPeaks()
1039 for peak in peaks:
1040 ix = peak.getIx() - x0
1041 iy = peak.getIy() - y0
1042 if mask.array[iy, ix] & badBitMask:
1043 selector[i] = False
1044 break
1045 nRejected = np.count_nonzero(~selector)
1046 self.metadata["nRejectedBadMaskPlanes"] = nRejected
1047 if nRejected > 0:
1048 self.log.info("Rejected %d detections on pixels with bad mask "
1049 "planes %s before measurement.",
1050 nRejected, presentPlanes)
1051 return initialDiaSources[selector].copy(deep=True)
1053 def _removeBadSources(self, diaSources):
1054 """Remove unphysical diaSources from the catalog.
1056 Parameters
1057 ----------
1058 diaSources : `lsst.afw.table.SourceCatalog`
1059 The catalog of detected sources.
1061 Returns
1062 -------
1063 diaSources : `lsst.afw.table.SourceCatalog`
1064 The updated catalog of detected sources, with any source that has a
1065 flag in ``config.badSourceFlags`` set removed.
1066 """
1067 selector = np.ones(len(diaSources), dtype=bool)
1068 for flag in self.config.badSourceFlags:
1069 flags = diaSources[flag]
1070 nBad = np.count_nonzero(flags)
1071 if nBad > 0:
1072 self.log.debug("Found %d unphysical sources with flag %s.", nBad, flag)
1073 selector &= ~flags
1074 nBadTotal = np.count_nonzero(~selector)
1075 self.metadata["nRemovedBadFlaggedSources"] = nBadTotal
1076 self.log.info("Removed %d unphysical sources.", nBadTotal)
1077 return diaSources[selector].copy(deep=True)
1079 def _find_glint_trails(self, diaSources):
1080 """Define a new flag column for diaSources that are in a glint trail.
1082 Parameters
1083 ----------
1084 diaSources : `lsst.afw.table.SourceCatalog`
1085 The catalog of detected sources.
1087 Returns
1088 -------
1089 diaSources : `lsst.afw.table.SourceCatalog`
1090 The updated catalog of detected sources, with a new bool column
1091 called 'glint_trail' added.
1093 trail_parameters : `dict`
1094 Parameters of all the trails that were found.
1095 """
1096 if self.config.doSkySources:
1097 # Do not include sky sources in glint detection
1098 candidateDiaSources = diaSources[~diaSources["sky_source"]].copy(deep=True)
1099 else:
1100 candidateDiaSources = diaSources
1101 trailed_glints = self.findGlints.run(candidateDiaSources)
1102 glint_mask = [True if id in trailed_glints.trailed_ids else False for id in diaSources['id']]
1103 if np.any(glint_mask): 1103 ↛ 1104line 1103 didn't jump to line 1104 because the condition on line 1103 was never true
1104 diaSources['glint_trail'] = np.array(glint_mask)
1106 slopes = np.array([trail.slope for trail in trailed_glints.parameters])
1107 intercepts = np.array([trail.intercept for trail in trailed_glints.parameters])
1108 stderrs = np.array([trail.stderr for trail in trailed_glints.parameters])
1109 lengths = np.array([trail.length for trail in trailed_glints.parameters])
1110 angles = np.array([trail.angle for trail in trailed_glints.parameters])
1111 parameters = {'slopes': slopes, 'intercepts': intercepts, 'stderrs': stderrs, 'lengths': lengths,
1112 'angles': angles}
1114 trail_parameters = pipeBase.Struct(glintTrailInfo=parameters)
1116 return diaSources, trail_parameters
1118 def addSkySources(self, diaSources, mask, seed,
1119 subtask=None):
1120 """Add sources in empty regions of the difference image
1121 for measuring the background.
1123 Parameters
1124 ----------
1125 diaSources : `lsst.afw.table.SourceCatalog`
1126 The catalog of detected sources.
1127 mask : `lsst.afw.image.Mask`
1128 Mask plane for determining regions where Sky sources can be added.
1129 seed : `int`
1130 Seed value to initialize the random number generator.
1131 """
1132 if subtask is None: 1132 ↛ 1134line 1132 didn't jump to line 1134 because the condition on line 1132 was always true
1133 subtask = self.skySources
1134 if subtask.config.nSources <= 0:
1135 self.metadata[f"n_{subtask.getName()}"] = 0
1136 return
1137 skySourceFootprints = subtask.run(mask=mask, seed=seed, catalog=diaSources)
1138 self.metadata[f"n_{subtask.getName()}"] = len(skySourceFootprints)
1140 def measureDiaSources(self, diaSources, science, difference, matchedTemplate):
1141 """Use (matched) template and science image to constrain dipole fitting.
1143 Parameters
1144 ----------
1145 diaSources : `lsst.afw.table.SourceCatalog`
1146 The catalog of detected sources.
1147 science : `lsst.afw.image.ExposureF`
1148 Science exposure that the template was subtracted from.
1149 difference : `lsst.afw.image.ExposureF`
1150 Result of subtracting template from the science image.
1151 matchedTemplate : `lsst.afw.image.ExposureF`
1152 Warped and PSF-matched template that was used produce the
1153 difference image.
1154 """
1155 # Ensure that the required mask planes are present
1156 for mp in self.config.measurement.plugins["base_PixelFlags"].masksFpAnywhere:
1157 difference.mask.addMaskPlane(mp)
1158 # Note that this may not be correct if we convolved the science image.
1159 # In the future we may wish to persist the matchedScience image.
1160 self.measurement.run(diaSources, difference, science, matchedTemplate)
1161 if self.config.doApCorr: 1161 ↛ exitline 1161 didn't return from function 'measureDiaSources' because the condition on line 1161 was always true
1162 apCorrMap = difference.getInfo().getApCorrMap()
1163 if apCorrMap is None: 1163 ↛ 1166line 1163 didn't jump to line 1166 because the condition on line 1163 was always true
1164 self.log.warning("Difference image does not have valid aperture correction; skipping.")
1165 else:
1166 self.applyApCorr.run(
1167 catalog=diaSources,
1168 apCorrMap=apCorrMap,
1169 )
1171 def measureForcedSources(self, diaSources, image, wcs, template=False):
1172 """Perform forced measurement of the diaSources on a direct image.
1174 Parameters
1175 ----------
1176 diaSources : `lsst.afw.table.SourceCatalog`
1177 The catalog of detected sources.
1178 image: `lsst.afw.image.ExposureF`
1179 Exposure that the forced measurement is being performed on
1180 wcs : `lsst.afw.geom.SkyWcs`
1181 Coordinate system definition (wcs) for the exposure.
1182 template : `bool`
1183 Is the forced measurement being performed on the template?
1184 """
1185 # Run forced psf photometry on the image at the diaSource locations.
1186 # Copy the measured flux and error into the diaSource.
1187 forcedSources = self.forcedMeasurement.generateMeasCat(image, diaSources, wcs)
1188 self.forcedMeasurement.run(forcedSources, image, diaSources, wcs)
1190 if template:
1191 base_key = 'ip_diffim_forced_template_PsfFlux'
1192 else:
1193 base_key = 'ip_diffim_forced_PsfFlux'
1194 mapper = afwTable.SchemaMapper(forcedSources.schema, diaSources.schema)
1195 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFlux")[0],
1196 f"{base_key}_instFlux", True)
1197 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_instFluxErr")[0],
1198 f"{base_key}_instFluxErr", True)
1199 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_area")[0],
1200 f"{base_key}_area", True)
1201 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag")[0],
1202 f"{base_key}_flag", True)
1203 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_noGoodPixels")[0],
1204 f"{base_key}_flag_noGoodPixels", True)
1205 mapper.addMapping(forcedSources.schema.find("base_PsfFlux_flag_edge")[0],
1206 f"{base_key}_flag_edge", True)
1207 for diaSource, forcedSource in zip(diaSources, forcedSources):
1208 diaSource.assign(forcedSource, mapper)
1210 def findAndMaskCosmicRays(self, difference):
1211 """Detect and mask cosmic rays on the difference image.
1213 Parameters
1214 ----------
1215 difference : `lsst.afw.image.Exposure`
1216 The background-subtracted difference image.
1217 The mask plane will be modified in place.
1218 """
1219 # This is run on a background-subtracted difference image, so the
1220 # remaining background should be ~0.
1221 medianBg = 0.
1222 try:
1223 crs = findCosmicRays(
1224 difference.maskedImage,
1225 difference.psf,
1226 medianBg,
1227 pexConfig.makePropertySet(self.config.cosmicray),
1228 self.config.cosmicray.keepCRs,
1229 )
1230 except LengthError:
1231 raise TooManyCosmicRays(self.config.cosmicray.nCrPixelMax) from None
1232 num = 0
1233 if crs is not None: 1233 ↛ 1239line 1233 didn't jump to line 1239 because the condition on line 1233 was always true
1234 mask = difference.maskedImage.mask
1235 crBit = mask.getPlaneBitMask("CR")
1236 afwDetection.setMaskFromFootprintList(mask, crs, crBit)
1237 num = len(crs)
1239 text = "masked" if self.config.cosmicray.keepCRs else "interpolated over"
1240 self.log.info("Identified and %s %s cosmic rays.", text, num)
1241 self.metadata["cosmic_ray_count"] = num
1243 def calculateMetrics(self, science, difference, diaSources, kernelSources):
1244 """Add difference image QA metrics to the Task metadata.
1246 This may be used to produce corresponding metrics (see
1247 lsst.analysis.tools.tasks.diffimTaskDetectorVisitMetricAnalysis).
1249 Parameters
1250 ----------
1251 science : `lsst.afw.image.ExposureF`
1252 Science exposure that was subtracted.
1253 difference : `lsst.afw.image.Exposure`
1254 The target difference image to calculate metrics for.
1255 diaSources : `lsst.afw.table.SourceCatalog`
1256 The catalog of detected sources.
1257 kernelSources : `lsst.afw.table.SourceCatalog`
1258 Final selection of sources that was used for psf matching.
1259 """
1260 mask = difference.mask
1261 badPix = (mask.array & mask.getPlaneBitMask(self.config.detection.excludeMaskPlanes)) > 0
1262 self.metadata["nGoodPixels"] = np.sum(~badPix)
1263 self.metadata["nBadPixels"] = np.sum(badPix)
1264 detPosPix = (mask.array & mask.getPlaneBitMask("DETECTED")) > 0
1265 detNegPix = (mask.array & mask.getPlaneBitMask("DETECTED_NEGATIVE")) > 0
1266 self.metadata["nPixelsDetectedPositive"] = np.sum(detPosPix)
1267 self.metadata["nPixelsDetectedNegative"] = np.sum(detNegPix)
1268 detPosPix &= badPix
1269 detNegPix &= badPix
1270 self.metadata["nBadPixelsDetectedPositive"] = np.sum(detPosPix)
1271 self.metadata["nBadPixelsDetectedNegative"] = np.sum(detNegPix)
1273 metricsMaskPlanes = list(mask.getMaskPlaneDict().keys())
1274 for maskPlane in metricsMaskPlanes:
1275 try:
1276 self.metadata["%s_mask_fraction"%maskPlane.lower()] = evaluateMaskFraction(mask, maskPlane)
1277 except InvalidParameterError:
1278 self.metadata["%s_mask_fraction"%maskPlane.lower()] = -1
1279 self.log.info("Unable to calculate metrics for mask plane %s: not in image"%maskPlane)
1281 if self.config.doSkySources:
1282 skySources = diaSources[diaSources["sky_source"]]
1283 else:
1284 skySources = None
1285 metrics = computeDifferenceImageMetrics(science, difference, kernelSources, sky_sources=skySources)
1287 self.metadata["residualFootprintRatioMean"] = metrics.differenceFootprintRatioMean
1288 self.metadata["residualFootprintRatioStdev"] = metrics.differenceFootprintRatioStdev
1289 self.metadata["differenceFootprintSkyRatioMean"] = metrics.differenceFootprintSkyRatioMean
1290 self.metadata["differenceFootprintSkyRatioStdev"] = metrics.differenceFootprintSkyRatioStdev
1291 self.log.info("Mean, stdev of ratio of difference to science "
1292 "pixels in star footprints: %5.4f, %5.4f",
1293 self.metadata["residualFootprintRatioMean"],
1294 self.metadata["residualFootprintRatioStdev"])
1295 if self.config.raiseOnBadSubtractionRatio:
1296 if metrics.differenceFootprintRatioMean > self.config.badSubtractionRatioThreshold: 1296 ↛ 1297line 1296 didn't jump to line 1297 because the condition on line 1296 was never true
1297 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioMean,
1298 threshold=self.config.badSubtractionRatioThreshold)
1299 if metrics.differenceFootprintRatioStdev > self.config.badSubtractionVariationThreshold: 1299 ↛ 1300line 1299 didn't jump to line 1300 because the condition on line 1299 was never true
1300 raise BadSubtractionError(ratio=metrics.differenceFootprintRatioStdev,
1301 threshold=self.config.badSubtractionVariationThreshold)
1303 def getSattleDiaSourceAllowlist(self, diaSources, science):
1304 """Query the sattle service and determine which diaSources are allowed.
1306 Parameters
1307 ----------
1308 diaSources : `lsst.afw.table.SourceCatalog`
1309 The catalog of detected sources.
1310 science : `lsst.afw.image.ExposureF`
1311 Science exposure that was subtracted.
1313 Returns
1314 ----------
1315 allow_list : `list` of `int`
1316 diaSourceIds of diaSources that can be made public.
1318 Raises
1319 ------
1320 requests.HTTPError
1321 Raised if sattle call does not return success.
1322 """
1323 wcs = science.getWcs()
1324 visit_info = science.getInfo().getVisitInfo()
1325 visit_id = visit_info.getId()
1326 sattle_uri_base = os.getenv('SATTLE_URI_BASE')
1328 dia_sources_json = []
1329 for source in diaSources:
1330 source_bbox = source.getFootprint().getBBox()
1331 corners = wcs.pixelToSky([lsst.geom.Point2D(c) for c in source_bbox.getCorners()])
1332 bbox_radec = [[pt.getRa().asDegrees(), pt.getDec().asDegrees()] for pt in corners]
1333 dia_sources_json.append({"diasource_id": source["id"], "bbox": bbox_radec})
1335 payload = {"visit_id": visit_id, "detector_id": science.getDetector().getId(),
1336 "diasources": dia_sources_json, "historical": self.config.sattle_historical}
1338 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list',
1339 json=payload)
1341 # retry once if visit cache is not populated
1342 if sattle_output.status_code == 404:
1343 self.log.warning(f'Visit {visit_id} not found in sattle cache, re-sending')
1344 populate_sattle_visit_cache(visit_info, historical=self.config.sattle_historical)
1345 sattle_output = requests.put(f'{sattle_uri_base}/diasource_allow_list', json=payload)
1347 sattle_output.raise_for_status()
1349 return sattle_output.json()['allow_list']
1351 def filterSatellites(self, diaSources, science):
1352 """Remove diaSources overlapping predicted satellite positions.
1354 Parameters
1355 ----------
1356 diaSources : `lsst.afw.table.SourceCatalog`
1357 The catalog of detected sources.
1358 science : `lsst.afw.image.ExposureF`
1359 Science exposure that was subtracted.
1361 Returns
1362 ----------
1363 filterdDiaSources : `lsst.afw.table.SourceCatalog`
1364 Filtered catalog of diaSources
1365 """
1367 allow_list = self.getSattleDiaSourceAllowlist(diaSources, science)
1369 if allow_list:
1370 allow_set = set(allow_list)
1371 allowed_ids = [source['id'] in allow_set for source in diaSources]
1372 diaSources = diaSources[np.array(allowed_ids)].copy(deep=True)
1373 else:
1374 self.log.warning('Sattle allowlist is empty, all diaSources removed')
1375 diaSources = diaSources[0:0].copy(deep=True)
1376 return diaSources
1378 def _runStreakMasking(self, difference):
1379 """Do streak masking and optionally save the resulting streak
1380 fit parameters in a catalog.
1382 Only returns non-empty streakInfo if self.config.writeStreakInfo
1383 is set. The difference image is binned by self.config.streakBinFactor
1384 (and detection is run a second time) so that regions with lower
1385 surface brightness streaks are more likely to fall above the
1386 detection threshold.
1388 Parameters
1389 ----------
1390 difference: `lsst.afw.image.Exposure`
1391 The exposure in which to search for streaks. Must have a detection
1392 mask.
1394 Returns
1395 -------
1396 streakInfo: `lsst.pipe.base.Struct`
1397 ``rho`` : `np.ndarray`
1398 Angle of detected streak.
1399 ``theta`` : `np.ndarray`
1400 Distance from center of detected streak.
1401 ``sigma`` : `np.ndarray`
1402 Width of streak profile.
1403 ``reducedChi2`` : `np.ndarray`
1404 Reduced chi2 of the best-fit streak profile.
1405 ``modelMaximum`` : `np.ndarray`
1406 Peak value of the fit line profile.
1407 """
1408 maskedImage = difference.maskedImage
1409 # Bin the diffim to enhance low surface brightness streaks
1410 binnedMaskedImage = afwMath.binImage(maskedImage,
1411 self.config.streakBinFactor,
1412 self.config.streakBinFactor)
1413 binnedExposure = afwImage.ExposureF(binnedMaskedImage.getBBox())
1414 binnedExposure.setMaskedImage(binnedMaskedImage)
1415 # Clear the DETECTED mask plane before streak detection
1416 binnedExposure.mask &= ~binnedExposure.mask.getPlaneBitMask('DETECTED')
1417 # Rerun detection to set the DETECTED mask plane on binnedExposure
1418 sigma = difference.psf.computeShape(difference.psf.getAveragePosition()).getDeterminantRadius()
1419 _table = afwTable.SourceTable.make(afwTable.SourceTable.makeMinimalSchema())
1420 self.streakDetection.run(table=_table, exposure=binnedExposure, doSmooth=True,
1421 sigma=sigma/self.config.streakBinFactor)
1422 binnedDetectedMaskPlane = binnedExposure.mask.array & binnedExposure.mask.getPlaneBitMask('DETECTED')
1423 rescaledDetectedMaskPlane = binnedDetectedMaskPlane.repeat(self.config.streakBinFactor,
1424 axis=0).repeat(self.config.streakBinFactor,
1425 axis=1)
1426 # Create new version of a diffim with DETECTED based on binnedExposure
1427 streakMaskedImage = maskedImage.clone()
1428 ysize, xsize = rescaledDetectedMaskPlane.shape
1429 streakMaskedImage.mask.array[:ysize, :xsize] |= rescaledDetectedMaskPlane
1430 # Detect streaks on this new version of the diffim
1431 streaks = self.maskStreaks.run(streakMaskedImage)
1432 streakMaskPlane = streakMaskedImage.mask.array & streakMaskedImage.mask.getPlaneBitMask('STREAK')
1433 # Apply the new STREAK mask to the original diffim
1434 maskedImage.mask.array |= streakMaskPlane
1436 if self.config.writeStreakInfo: 1436 ↛ 1437line 1436 didn't jump to line 1437 because the condition on line 1436 was never true
1437 rhos = np.array([line.rho for line in streaks.lines])
1438 thetas = np.array([line.theta for line in streaks.lines])
1439 sigmas = np.array([line.sigma for line in streaks.lines])
1440 chi2s = np.array([line.reducedChi2 for line in streaks.lines])
1441 modelMaximums = np.array([line.modelMaximum for line in streaks.lines])
1442 streakInfo = {'rho': rhos, 'theta': thetas, 'sigma': sigmas, 'reducedChi2': chi2s,
1443 'modelMaximum': modelMaximums}
1444 else:
1445 streakInfo = {'rho': np.array([]), 'theta': np.array([]), 'sigma': np.array([]),
1446 'reducedChi2': np.array([]), 'modelMaximum': np.array([])}
1447 return pipeBase.Struct(maskedStreaks=streakInfo)
1450class DetectAndMeasureScoreConnections(DetectAndMeasureConnections):
1451 scoreExposure = pipeBase.connectionTypes.Input(
1452 doc="Maximum likelihood image for detection.",
1453 dimensions=("instrument", "visit", "detector"),
1454 storageClass="ExposureF",
1455 name="{fakesType}{coaddName}Diff_scoreTempExp",
1456 )
1457 scoreMeasuredExposure = pipeBase.connectionTypes.Output(
1458 doc="Score image after backgrond subtraction and detection.",
1459 dimensions=("instrument", "visit", "detector"),
1460 storageClass="ExposureF",
1461 name="{fakesType}{coaddName}Diff_scoreExp",
1462 )
1465class DetectAndMeasureScoreConfig(DetectAndMeasureConfig,
1466 pipelineConnections=DetectAndMeasureScoreConnections):
1467 pass
1470class DetectAndMeasureScoreTask(DetectAndMeasureTask):
1471 """Detect DIA sources using a score image,
1472 and measure the detections on the difference image.
1474 Source detection is run on the supplied score, or maximum likelihood,
1475 image. Note that no additional convolution will be done in this case.
1476 Close positive and negative detections will optionally be merged into
1477 dipole diaSources.
1478 Sky sources, or forced detections in background regions, will optionally
1479 be added, and the configured measurement algorithm will be run on all
1480 detections.
1481 """
1482 ConfigClass = DetectAndMeasureScoreConfig
1483 _DefaultName = "detectAndMeasureScore"
1485 @timeMethod
1486 def run(self, science, matchedTemplate, difference, scoreExposure, kernelSources,
1487 idFactory=None, measurementResults=None):
1488 """Detect and measure sources on a score image.
1490 Parameters
1491 ----------
1492 science : `lsst.afw.image.ExposureF`
1493 Science exposure that the template was subtracted from.
1494 matchedTemplate : `lsst.afw.image.ExposureF`
1495 Warped and PSF-matched template that was used produce the
1496 difference image.
1497 difference : `lsst.afw.image.ExposureF`
1498 Result of subtracting template from the science image.
1499 scoreExposure : `lsst.afw.image.ExposureF`
1500 Score or maximum likelihood difference image
1501 kernelSources : `lsst.afw.table.SourceCatalog`
1502 Final selection of sources that was used for psf matching.
1503 idFactory : `lsst.afw.table.IdFactory`, optional
1504 Generator object used to assign ids to detected sources in the
1505 difference image. Ids from this generator are not set until after
1506 deblending and merging positive/negative peaks.
1507 measurementResults : `lsst.pipe.base.Struct`, optional
1508 Result struct that is modified to allow saving of partial outputs
1509 for some failure conditions. If the task completes successfully,
1510 this is also returned.
1512 Returns
1513 -------
1514 measurementResults : `lsst.pipe.base.Struct`
1516 ``subtractedMeasuredExposure`` : `lsst.afw.image.ExposureF`
1517 Subtracted exposure with detection mask applied.
1518 ``scoreMeasuredExposure`` : `lsst.afw.image.ExposureF`
1519 Score exposure with detection mask applied.
1520 ``diaSources`` : `lsst.afw.table.SourceCatalog`
1521 The catalog of detected sources.
1522 ``differenceBackground`` : `lsst.afw.math.BackgroundList`
1523 Background that was subtracted from the difference image.
1524 """
1525 if measurementResults is None: 1525 ↛ 1527line 1525 didn't jump to line 1527 because the condition on line 1525 was always true
1526 measurementResults = pipeBase.Struct()
1527 if idFactory is None:
1528 idFactory = lsst.meas.base.IdGenerator().make_table_id_factory()
1530 # Check image properties and clear detection mask planes.
1531 self._prepareInputs(difference)
1532 self._prepareInputs(scoreExposure)
1533 if self.config.doSubtractBackground:
1534 background = self._fitAndSubtractBackground(
1535 differenceExposure=difference, scoreExposure=scoreExposure,
1536 )
1537 else:
1538 background = afwMath.BackgroundList()
1540 if self.config.doFindCosmicRays: 1540 ↛ 1549line 1540 didn't jump to line 1549 because the condition on line 1540 was always true
1541 # Cosmic rays are detected on the difference and the resulting
1542 # mask is propagated to the score image used for detection.
1543 self.findAndMaskCosmicRays(difference)
1544 scoreExposure.mask.array |= difference.mask.array
1546 # Don't use the idFactory until after deblend+merge, so that we aren't
1547 # generating ids that just get thrown away (footprint merge doesn't
1548 # know about past ids).
1549 table = afwTable.SourceTable.make(self.schema)
1550 detectResults = self.detection.run(
1551 table=table,
1552 exposure=scoreExposure,
1553 doSmooth=False,
1554 background=background,
1555 clearMask=True,
1556 )
1557 measurementResults.differenceBackground = background
1558 measurementResults.scoreMeasuredExposure = scoreExposure
1560 # Copy the final detection masks from the Score image to the difference.
1561 # Do not copy all mask planes, since the convolution will grow them by
1562 # the size of the convolution kernel.
1563 detectedBit = difference.mask.getPlaneBitMask(["DETECTED"])
1564 detectedPix = scoreExposure.mask.array & detectedBit > 0
1565 detectedNegativeBit = difference.mask.getPlaneBitMask(["DETECTED_NEGATIVE"])
1566 detectedNegativePix = scoreExposure.mask.array & detectedNegativeBit > 0
1567 difference.mask.array[detectedPix] |= detectedBit
1568 difference.mask.array[detectedNegativePix] |= detectedNegativeBit
1569 # Also set the EDGE mask, since the science image was convolved
1570 edgeBit = difference.mask.getPlaneBitMask(["EDGE"])
1571 edgePix = scoreExposure.mask.array & edgeBit > 0
1572 difference.mask.array[edgePix] |= edgeBit
1574 if self.config.doDeblend:
1575 sources, positives, negatives = self._deblend(difference,
1576 detectResults.positive,
1577 detectResults.negative)
1579 self.processResults(science, matchedTemplate, difference,
1580 sources, idFactory, kernelSources,
1581 positives=positives,
1582 negatives=negatives,
1583 measurementResults=measurementResults)
1585 else:
1586 positives = afwTable.SourceCatalog(self.schema)
1587 detectResults.positive.makeSources(positives)
1588 negatives = afwTable.SourceCatalog(self.schema)
1589 detectResults.negative.makeSources(negatives)
1590 self.processResults(science, matchedTemplate, difference,
1591 detectResults.sources, idFactory, kernelSources,
1592 positives=positives,
1593 negatives=negatives,
1594 measurementResults=measurementResults)
1595 return measurementResults