Skip to content

Commit 40c9b15

Browse files
authored
Merge pull request #452 from lsst/tickets/DM-54428-v30
DM-54428: Add cosmic ray detection and masking on difference images
2 parents cfd10da + 38c0eb0 commit 40c9b15

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

python/lsst/ip/diffim/detectAndMeasure.py

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@
3232
from lsst.ip.diffim.utils import (evaluateMaskFraction, computeDifferenceImageMetrics,
3333
populate_sattle_visit_cache)
3434
from lsst.meas.algorithms import SkyObjectsTask, SourceDetectionTask, SetPrimaryFlagsTask, MaskStreaksTask
35-
from lsst.meas.algorithms import FindGlintTrailsTask
35+
from lsst.meas.algorithms import FindGlintTrailsTask, FindCosmicRaysConfig, findCosmicRays
3636
from lsst.meas.base import ForcedMeasurementTask, ApplyApCorrTask, DetectorVisitIdGeneratorConfig
3737
import lsst.meas.deblender
3838
import lsst.meas.extensions.trailedSources # noqa: F401
3939
import lsst.meas.extensions.shapeHSM
4040
import lsst.pex.config as pexConfig
41-
from lsst.pex.exceptions import InvalidParameterError
41+
from lsst.pex.exceptions import InvalidParameterError, LengthError
4242
import lsst.pipe.base as pipeBase
4343
import lsst.utils
4444
from lsst.utils.timer import timeMethod
@@ -81,6 +81,33 @@ def metadata(self):
8181
return {}
8282

8383

84+
class TooManyCosmicRays(pipeBase.AlgorithmError):
85+
"""Raised if the cosmic ray task fails with too many cosmics.
86+
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
98+
99+
def __str__(self):
100+
# Exception doesn't handle **kwargs, so we need a custom str.
101+
return f"{self.msg}: {self.metadata}"
102+
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
109+
110+
84111
class DetectAndMeasureConnections(pipeBase.PipelineTaskConnections,
85112
dimensions=("instrument", "visit", "detector"),
86113
defaultTemplates={"coaddName": "deep",
@@ -202,6 +229,16 @@ class DetectAndMeasureConfig(pipeBase.PipelineTaskConfig,
202229
target=lsst.meas.algorithms.SubtractBackgroundTask,
203230
doc="Task to perform final background subtraction, after first detection pass.",
204231
)
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+
)
205242
detection = pexConfig.ConfigurableField(
206243
target=SourceDetectionTask,
207244
doc="Final source detection for diaSource measurement",
@@ -377,6 +414,8 @@ def setDefaults(self):
377414
"DETECTED_NEGATIVE",
378415
"NO_DATA",
379416
]
417+
# Cosmic ray detection
418+
self.cosmicray.keepCRs = True # do not interpolate over detected CRs
380419
# DiaSource Detection
381420
self.detection.thresholdPolarity = "both"
382421
self.detection.thresholdValue = 5.0
@@ -621,6 +660,11 @@ def run(self, science, matchedTemplate, difference, kernelSources=None,
621660

622661
self._prepareInputs(detectionExposure)
623662

663+
if self.config.doFindCosmicRays and not self.config.doSubtractBackground:
664+
# Detect and interpolate over any remaining cosmic rays
665+
# This only needs to run once, right before the final detection.
666+
self.findAndMaskCosmicRays(detectionExposure)
667+
624668
# Don't use the idFactory until after deblend+merge, so that we aren't
625669
# generating ids that just get thrown away (footprint merge doesn't
626670
# know about past ids).
@@ -639,6 +683,10 @@ def run(self, science, matchedTemplate, difference, kernelSources=None,
639683
difference.setMask(detectionExposure.mask)
640684
background = self.subtractFinalBackground.run(difference).background
641685

686+
if self.config.doFindCosmicRays:
687+
# Detect and interpolate over any remaining cosmic rays
688+
self.findAndMaskCosmicRays(difference)
689+
642690
# Re-run detection to get final footprints
643691
table = afwTable.SourceTable.make(self.schema)
644692
results = self.detection.run(
@@ -1049,6 +1097,39 @@ def measureForcedSources(self, diaSources, image, wcs, template=False):
10491097
for diaSource, forcedSource in zip(diaSources, forcedSources):
10501098
diaSource.assign(forcedSource, mapper)
10511099

1100+
def findAndMaskCosmicRays(self, difference):
1101+
"""Detect and mask cosmic rays on the difference image.
1102+
1103+
Parameters
1104+
----------
1105+
difference : `lsst.afw.image.Exposure`
1106+
The background-subtracted difference image.
1107+
The mask plane will be modified in place.
1108+
"""
1109+
# This is run on a background-subtracted difference image, so the
1110+
# remaining background should be ~0.
1111+
medianBg = 0.
1112+
try:
1113+
crs = findCosmicRays(
1114+
difference.maskedImage,
1115+
difference.psf,
1116+
medianBg,
1117+
pexConfig.makePropertySet(self.config.cosmicray),
1118+
self.config.cosmicray.keepCRs,
1119+
)
1120+
except LengthError:
1121+
raise TooManyCosmicRays(self.config.cosmicray.nCrPixelMax) from None
1122+
num = 0
1123+
if crs is not None:
1124+
mask = difference.maskedImage.mask
1125+
crBit = mask.getPlaneBitMask("CR")
1126+
afwDetection.setMaskFromFootprintList(mask, crs, crBit)
1127+
num = len(crs)
1128+
1129+
text = "masked" if self.config.cosmicray.keepCRs else "interpolated over"
1130+
self.log.info("Identified and %s %s cosmic rays.", text, num)
1131+
self.metadata["cosmic_ray_count"] = num
1132+
10521133
def calculateMetrics(self, science, difference, diaSources, kernelSources):
10531134
"""Add difference image QA metrics to the Task metadata.
10541135

tests/test_detectAndMeasure.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,46 @@ def _detection_wrapper(positive=True):
414414
_detection_wrapper(positive=True)
415415
_detection_wrapper(positive=False)
416416

417+
def test_mask_cosmic_rays(self):
418+
"""Run detection on a difference image containing a cosmic ray.
419+
"""
420+
# Set up the simulated images
421+
noiseLevel = 1.
422+
staticSeed = 1
423+
fluxLevel = 500
424+
xSize = 400
425+
ySize = 400
426+
kwargs = {"seed": staticSeed, "psfSize": 2.4, "fluxLevel": fluxLevel, "xSize": xSize, "ySize": ySize}
427+
science, sources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=6, **kwargs)
428+
matchedTemplate, _ = makeTestImage(noiseLevel=noiseLevel/4, noiseSeed=7, **kwargs)
429+
crMask = science.mask.getPlaneBitMask("CR")
430+
431+
# Configure the detection Task
432+
detectionTask = self._setup_detection(doMerge=False, doSkySources=True)
433+
434+
# Test that no CRs are detected
435+
transients, transientSources = makeTestImage(noiseLevel=noiseLevel, noiseSeed=8, nSrc=10, **kwargs)
436+
science.maskedImage += transients.maskedImage
437+
difference = science.clone()
438+
difference.maskedImage -= matchedTemplate.maskedImage
439+
output = detectionTask.run(science, matchedTemplate, difference, sources)
440+
crMaskSet = (output.subtractedMeasuredExposure.mask.array & crMask) > 0
441+
self.assertTrue(np.all(crMaskSet == 0))
442+
443+
crX0 = round(sources.getX()[0] - science.getX0())
444+
crY0 = round(sources.getY()[0] - science.getY0())
445+
crX1 = round(sources.getX()[1] - science.getX0())
446+
crY1 = round(sources.getY()[1] - science.getY0())
447+
# Add CR-like shape and check that CR is detected
448+
# Pick two locations on top of sources, since that is what is likely to
449+
# be missed in the first stage of CR rejection.
450+
difference.image.array[crX0:crX0+1, crY0:crY0+5] += 1234 # Arbitrary but large flux for the CRs
451+
difference.image.array[crX1:crX1+5, crY1:crY1+1] += 1234
452+
output = detectionTask.run(science, matchedTemplate, difference)
453+
crMaskSet = (output.subtractedMeasuredExposure.mask.array & crMask) > 0
454+
self.assertTrue(np.all(crMaskSet[crX0:crX0+1, crY0:crY0+5]))
455+
self.assertTrue(np.all(crMaskSet[crX1:crX1+5, crY1:crY1+1]))
456+
417457
def test_missing_mask_planes(self):
418458
"""Check that detection runs with missing mask planes.
419459
"""

0 commit comments

Comments
 (0)