From 67c24f661f66498ef244e55d5ad1d043b7d4dbf0 Mon Sep 17 00:00:00 2001 From: Lauren MacArthur Date: Tue, 20 Jan 2026 15:12:35 -0800 Subject: [PATCH] Add max iterations for bright dynamic detection This guards against potentially very long (or infinite) running loops. Also adds a unittest check that forces entry into this loop to make sure it iterates just two times, having set the full images as having the DETECTION mask set, and checking that it fails at trying to lay down sky sources. --- .../lsst/meas/algorithms/dynamicDetection.py | 24 +++++++++++++------ tests/test_dynamicDetection.py | 17 ++++++++++++- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/python/lsst/meas/algorithms/dynamicDetection.py b/python/lsst/meas/algorithms/dynamicDetection.py index 119408d9..549b1a00 100644 --- a/python/lsst/meas/algorithms/dynamicDetection.py +++ b/python/lsst/meas/algorithms/dynamicDetection.py @@ -124,6 +124,9 @@ class DynamicDetectionConfig(SourceDetectionConfig): doBrightPrelimDetection = Field(dtype=bool, default=True, doc="Do initial bright detection pass where footprints are grown " "by brightGrowFactor?") + brightDetectionIterMax = Field(dtype=int, default=10, + doc="Maximum number of iterations in the initial bright detection " + "pass.") brightMultiplier = Field(dtype=float, default=2000.0, doc="Multiplier to apply to the prelimThresholdFactor for the " "\"bright\" detections stage (want this to be large to only " @@ -660,12 +663,12 @@ def _computeBrightDetectionMask(self, maskedImage, convolveResults): Perform an initial bright object detection pass using a high detection threshold. The footprints in this pass are grown significantly more - than is typical to account for wings around bright sources. The + than is typical to account for wings around bright sources. The negative polarity detections in this pass help in masking severely over-subtracted regions. A maximum fraction of masked pixel from this pass is ensured via - the config ``brightMaskFractionMax``. If the masked pixel fraction is + the config ``brightMaskFractionMax``. If the masked pixel fraction is above this value, the detection thresholds here are increased by ``bisectFactor`` in a while loop until the detected masked fraction falls below this value. @@ -703,7 +706,7 @@ def _computeBrightDetectionMask(self, maskedImage, convolveResults): # brightMaskFractionMax, increasing the thresholds by # config.bisectFactor on each iteration (rarely necessary # for current defaults). - while nPixDetNeg/nPix > brightMaskFractionMax or nPixDet/nPix > brightMaskFractionMax: + for nIter in range(self.config.brightDetectionIterMax): self.clearMask(maskedImage.mask) brightPosFactor *= self.config.bisectFactor brightNegFactor *= self.config.bisectFactor @@ -722,10 +725,17 @@ def _computeBrightDetectionMask(self, maskedImage, convolveResults): self.log.info("Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)". format(nPixDetNeg, 100*nPixDetNeg/nPix)) if nPixDetNeg/nPix > brightMaskFractionMax or nPixDet/nPix > brightMaskFractionMax: - self.log.warn("Too high a fraction (%.1f > %.1f) of pixels were masked with current " - "\"bright\" detection round thresholds. Increasing by a factor of %.2f " - "and trying again.", max(nPixDetNeg, nPixDet)/nPix, - brightMaskFractionMax, self.config.bisectFactor) + self.log.warning("Too high a fraction (%.2f > %.2f) of pixels were masked with current " + "\"bright\" detection round thresholds (at nIter = %d). Increasing by " + "a factor of %.2f and trying again.", max(nPixDetNeg, nPixDet)/nPix, + brightMaskFractionMax, nIter, self.config.bisectFactor) + if nIter == self.config.brightDetectionIterMax - 1: + self.log.warning("Reached maximum number of iterations and still have too high " + "detected mask fractions in bright detection pass. Image is " + "likely mostly masked with BAD or NO_DATA or \"bad\" in some " + "other respect (so expected to likely fail further downstream).") + else: + break # Save the mask planes from the "bright" detection round, then # clear them before moving on to the "prelim" detection phase. diff --git a/tests/test_dynamicDetection.py b/tests/test_dynamicDetection.py index 97204e55..ddba7e03 100644 --- a/tests/test_dynamicDetection.py +++ b/tests/test_dynamicDetection.py @@ -5,7 +5,7 @@ from lsst.afw.geom import makeCdMatrix, makeSkyWcs from lsst.afw.table import SourceTable from lsst.geom import Box2I, Extent2I, Point2D, Point2I, SpherePoint, degrees -from lsst.meas.algorithms import DynamicDetectionTask +from lsst.meas.algorithms import DynamicDetectionTask, InsufficientSourcesError from lsst.meas.algorithms.testUtils import plantSources from lsst.pex.config import FieldValidationError @@ -110,6 +110,21 @@ def testThresholdScalingAndNoBackgroundTweak(self): config.doBackgroundTweak = False self.check(1.0, config) + def testBrightDetectionPass(self): + """Check that the maximum number of bright detection iterations is + observed. + + The bright detection loop is forced by setting config.brightMultiplier + so low such that the entire image is marked as DETECTED. As such, the + task is doomed to fail where it tries to lay down sky sources." + """ + config = DynamicDetectionTask.ConfigClass() + config.brightMultiplier = 0.05 + config.brightDetectionIterMax = 2 + with self.assertRaisesRegex( + InsufficientSourcesError, "Insufficient good sky source flux measurements"): + self.check(1.0, config) + def testThresholdsOutsideBounds(self): """Check that dynamic detection properly sets threshold limits. """