231 isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10):
232 """Calculate new threshold
234 This is the main functional addition to the vanilla
235 `SourceDetectionTask`.
237 We identify sky objects and perform forced PSF photometry on
238 them. Using those PSF flux measurements and estimated errors,
239 we set the threshold so that the stdev of the measurements
240 matches the median estimated error.
244 exposure : `lsst.afw.image.Exposure`
245 Exposure on which we're detecting sources.
247 RNG seed to use for finding sky objects.
248 sigma : `float`, optional
249 Gaussian sigma of smoothing kernel; if not provided,
250 will be deduced from the exposure's PSF.
251 minFractionSourcesFactor : `float`
252 Change the fraction of required sky sources from that set in
253 ``self.config.minFractionSources`` by this factor. NOTE: this
254 is intended for use in the background tweak pass (the detection
255 threshold is much lower there, so many more pixels end up marked
256 as DETECTED or DETECTED_NEGATIVE, leaving less room for sky
259 Set to ``True`` for the background tweak pass (for more helpful
261 nPixMaskErode : `int`, optional
262 Number of pixels by which to erode the detection masks on each
263 iteration of best-effort sky object placement.
264 maxMaskErodeIter : `int`, optional
265 Maximum number of iterations for the detection mask erosion.
269 result : `lsst.pipe.base.Struct`
270 Result struct with components:
273 Multiplicative factor to be applied to the
274 configured detection threshold (`float`).
276 Additive factor to be applied to the background
281 InsufficientSourcesError
282 Raised if the number of good sky sources found is less than the
284 (``self.config.minFractionSources``*``minFractionSourcesFactor``)
285 of the number requested (``self.skyObjects.config.nSources``).
287 wcsIsNone = exposure.getWcs()
is None
289 self.log.info(
"WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
292 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
293 minNumSources = int(self.config.minFractionSources*self.skyObjects.config.nSources)
296 if minFractionSourcesFactor != 1.0:
297 minNumSources = max(3, int(minNumSources*minFractionSourcesFactor))
298 fp = self.skyObjects.
run(exposure.maskedImage.mask, seed)
300 if self.config.allowMaskErode:
301 detectedMaskPlanes = [
"DETECTED",
"DETECTED_NEGATIVE"]
302 mask = exposure.maskedImage.mask
303 for nIter
in range(maxMaskErodeIter):
305 fp = self.skyObjects.
run(mask, seed)
306 if len(fp) < int(2*minNumSources):
307 self.log.info(
"Current number of sky sources is below 2*minimum required "
308 "(%d < %d, allowing for some subsequent measurement failures). "
309 "Allowing erosion of detected mask planes for sky placement "
310 "nIter: %d [of %d max]",
311 len(fp), 2*minNumSources, nIter, maxMaskErodeIter)
312 if nPixMaskErode
is None:
315 elif len(fp) < int(0.75*minNumSources):
319 for maskName
in detectedMaskPlanes:
321 detectedMaskBit = mask.getPlaneBitMask(maskName)
322 detectedMaskSpanSet = SpanSet.fromMask(mask, detectedMaskBit)
323 detectedMaskSpanSet = detectedMaskSpanSet.eroded(nPixMaskErode)
325 detectedMask = mask.getMaskPlane(maskName)
326 mask.clearMaskPlane(detectedMask)
328 detectedMaskSpanSet.setMask(mask, detectedMaskBit)
333 skyFootprints.setFootprints(fp)
336 catalog.reserve(len(skyFootprints.getFootprints()))
337 skyFootprints.makeSources(catalog)
338 key = catalog.getCentroidSlot().getMeasKey()
339 for source
in catalog:
340 peaks = source.getFootprint().getPeaks()
341 assert len(peaks) == 1
342 source.set(key, peaks[0].getF())
344 source.updateCoord(exposure.getWcs(), include_covariance=
False)
350 fluxes = catalog[
"base_PsfFlux_instFlux"]
351 area = catalog[
"base_PsfFlux_area"]
352 good = (~catalog[
"base_PsfFlux_flag"] & np.isfinite(fluxes))
354 if good.sum() < minNumSources:
356 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
357 f
"{minNumSources}) for dynamic threshold calculation.")
359 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
360 f
"{minNumSources}) for background tweak calculation.")
362 nPix = exposure.mask.array.size
364 nGoodPix = np.sum(exposure.mask.array & badPixelMask == 0)
365 if nGoodPix/nPix > 0.2:
368 nDetectedPix = np.sum(((exposure.mask.array & detectedPosPixelMask != 0)
369 | (exposure.mask.array & detectedNegPixelMask != 0))
370 & (exposure.mask.array & badPixelMask == 0))
371 msg += (
" However, {} of {} ({:.3f}%) pixels are not marked NO_DATA or BAD, "
372 "so there should be sufficient area to locate suitable sky sources. "
373 "Note that {} of {} ({:.3f}%) \"good\" pixels were marked "
374 "as DETECTED or DETECTED_NEGATIVE.".format(
375 nGoodPix, nPix, 100.0*nGoodPix/nPix,
376 nDetectedPix, nGoodPix, 100.0*nDetectedPix/nGoodPix))
381 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
382 good.sum(), self.skyObjects.config.nSources)
384 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
385 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
387 bgMedian = np.median((fluxes/area)[good])
388 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
389 stdevMeas = 0.741*(uq - lq)
390 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
392 exposure.setWcs(
None)
393 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
395 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
396 background=None, backgroundToPhotometricRatio=None):
397 """Detect footprints with a dynamic threshold
399 This varies from the vanilla ``detectFootprints`` method because we
400 do detection three times: first with a high threshold to detect
401 "bright" (both positive and negative, the latter to identify very
402 over-subtracted regions) sources for which we grow the DETECTED and
403 DETECTED_NEGATIVE masks significantly to account for wings. Second,
404 with a low threshold to mask all non-empty regions of the image. These
405 two masks are combined and used to identify regions of sky
406 uncontaminated by objects. A final round of detection is then done
407 with the new calculated threshold.
411 exposure : `lsst.afw.image.Exposure`
412 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
414 doSmooth : `bool`, optional
415 If True, smooth the image before detection using a Gaussian
417 sigma : `float`, optional
418 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
419 detections; if `None` then measure the sigma of the PSF of the
421 clearMask : `bool`, optional
422 Clear both DETECTED and DETECTED_NEGATIVE planes before running
424 expId : `int`, optional
425 Exposure identifier, used as a seed for the random number
426 generator. If absent, the seed will be the sum of the image.
427 background : `lsst.afw.math.BackgroundList`, optional
428 Background that was already subtracted from the exposure; will be
429 modified in-place if ``reEstimateBackground=True``.
430 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
431 Unused; if set will Raise.
435 results : `lsst.pipe.base.Struct`
436 The results `~lsst.pipe.base.Struct` contains:
439 Positive polarity footprints.
440 (`lsst.afw.detection.FootprintSet` or `None`)
442 Negative polarity footprints.
443 (`lsst.afw.detection.FootprintSet` or `None`)
445 Number of footprints in positive or 0 if detection polarity was
448 Number of footprints in negative or 0 if detection polarity was
451 Re-estimated background. `None` or the input ``background``
452 if ``reEstimateBackground==False``.
453 (`lsst.afw.math.BackgroundList`)
455 Multiplication factor applied to the configured detection
458 Results from preliminary detection pass.
459 (`lsst.pipe.base.Struct`)
461 if backgroundToPhotometricRatio
is not None:
462 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
463 maskedImage = exposure.maskedImage
468 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
469 "DETECTED_NEGATIVE"])
470 nPix = maskedImage.mask.array.size
472 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
473 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.2f}% of total)".
474 format(nGoodPix, 100*nGoodPix/nPix))
475 if nGoodPix/nPix < self.config.minGoodPixelFraction:
476 msg = (f
"Image has a very low good pixel fraction ({nGoodPix} of {nPix}), so not worth further "
484 psf = self.
getPsf(exposure, sigma=sigma)
485 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
487 if self.config.doThresholdScaling:
488 if self.config.doBrightPrelimDetection:
490 maskedImage, convolveResults)
495 factorNeg = min(20.0, brightFactorNeg/self.config.brightNegFactor)
502 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
504 middle = convolveResults.middle
505 sigma = convolveResults.sigma
506 if self.config.doThresholdScaling:
507 factorNeg *= self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
509 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
513 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
516 if self.config.doBrightPrelimDetection:
519 maskedImage.mask.array |= brightDetectedMask
523 if (self.config.minThresholdScaleFactor
524 and threshResults.multiplicative < self.config.minThresholdScaleFactor):
525 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
526 "bounds [%.2f, %.2f]. Setting factor to lower limit: %.2f.",
527 threshResults.multiplicative, self.config.minThresholdScaleFactor,
528 self.config.maxThresholdScaleFactor, self.config.minThresholdScaleFactor)
529 factor = self.config.minThresholdScaleFactor
530 elif (self.config.maxThresholdScaleFactor
531 and threshResults.multiplicative > self.config.maxThresholdScaleFactor):
532 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
533 "bounds [%.2f, %.2f]. Setting factor to upper limit: %.2f.",
534 threshResults.multiplicative, self.config.minThresholdScaleFactor,
535 self.config.maxThresholdScaleFactor, self.config.maxThresholdScaleFactor)
536 factor = self.config.maxThresholdScaleFactor
538 factor = threshResults.multiplicative
541 self.log.info(
"Modifying configured detection threshold by factor %.2f to %.2f",
542 factor, factor*self.config.thresholdValue)
551 maskedImage.mask.array |= oldDetected
555 middle, maskedImage.getBBox(), factor=factor, factorNeg=factorNeg
557 results.prelim = prelim
558 results.background = background
if background
is not None else lsst.afw.math.BackgroundList()
559 if self.config.doTempLocalBackground:
561 self.
finalizeFootprints(maskedImage.mask, results, sigma, factor=factor, factorNeg=factorNeg,
562 growOverride=growOverride)
563 if results.numPos == 0:
564 msg =
"No footprints were detected, so further processing would be moot"
567 self.log.warning(
"nPeaks/nFootprint = %.2f (max is %.1f)",
568 results.numPosPeaks/results.numPos,
569 self.config.maxPeakToFootRatio)
570 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
571 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
576 if growOverride
is None:
577 growOverride = 0.75*self.config.nSigmaToGrow
580 self.log.warning(
"Decreasing nSigmaToGrow to %.2f", growOverride)
582 self.log.warning(
"New theshold value would be > 5 times the initially requested "
583 "one (%.2f > %.2f). Leaving inFinalize iteration without "
584 "getting the number of peaks per footprint below %.1f",
585 factor*self.config.thresholdValue, self.config.thresholdValue,
586 self.config.maxPeakToFootRatio)
590 self.log.warning(
"numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
591 "Increasing threshold factor to %.2f and re-running,",
592 results.numPosPeaks/results.numPos,
593 self.config.maxPeakToFootRatio, factor)
597 if self.config.reEstimateBackground:
600 self.
display(exposure, results, middle)
617 if self.config.doBackgroundTweak:
618 originalMask = maskedImage.mask.array.copy()
621 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
624 tweakFactorNeg =
None if self.config.reEstimateBackground
else factorNeg
625 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
626 factor=factor, factorNeg=tweakFactorNeg)
628 factorNeg=tweakFactorNeg)
629 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
630 isBgTweak=
True).additive
631 if self.config.minBackgroundTweak:
632 minBackgroundTweak = self.config.minBackgroundTweak
635 if factorNeg > 5*factor:
636 if factorNeg > 8*factor:
637 minBackgroundTweak *= 3.0
639 minBackgroundTweak *= 2.0
640 self.log.warning(
"All evidence suggests the image is very oversubtracted. "
641 "Allowing for a larger corection (%.2f), than that set in "
642 "config.minBackgroundTweak (%.2f).", minBackgroundTweak,
643 self.config.minBackgroundTweak)
644 if bgLevel < minBackgroundTweak:
645 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
646 "[%.2f, %.2f]. Setting tweak to lower limit: %.2f.", bgLevel,
647 minBackgroundTweak, self.config.maxBackgroundTweak,
649 bgLevel = minBackgroundTweak
650 if self.config.maxBackgroundTweak
and bgLevel > self.config.maxBackgroundTweak:
651 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
652 "[%.2f, %.2f]. Setting tweak to upper limit: %.2f.", bgLevel,
653 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
654 self.config.maxBackgroundTweak)
655 bgLevel = self.config.maxBackgroundTweak
657 maskedImage.mask.array[:] = originalMask
694 """Perform an initial bright source detection pass.
696 Perform an initial bright object detection pass using a high detection
697 threshold. The footprints in this pass are grown significantly more
698 than is typical to account for wings around bright sources. The
699 negative polarity detections in this pass help in masking severely
700 over-subtracted regions.
702 A maximum fraction of masked pixel from this pass is ensured via
703 the config ``brightMaskFractionMax``. If the masked pixel fraction is
704 above this value, the detection thresholds here are increased by
705 ``bisectFactor`` in a while loop until the detected masked fraction
706 falls below this value.
710 maskedImage : `lsst.afw.image.MaskedImage`
711 Masked image on which to run the detection.
712 convolveResults : `lsst.pipe.base.Struct`
713 The results of the self.convolveImage function with attributes:
716 Convolved image, without the edges
717 (`lsst.afw.image.MaskedImage`).
719 Gaussian sigma used for the convolution (`float`).
723 brightDetectedMask : `numpy.ndarray`
724 Boolean array representing the union of the bright detection pass
725 DETECTED and DETECTED_NEGATIVE masks.
726 brightFactorNeg : `float`
727 Factor applied to the threshold for negative polarity detections.
728 This can get altered significantly in background over-subtracted
729 images and is useful to know for subsequent steps in the dynamic
734 self.config.prelimThresholdFactor*self.config.brightMultiplier
736 brightFactorNeg = self.config.brightNegFactor
737 brightMaskFractionMax = self.config.brightMaskFractionMax
739 brightMaskNegFractionMax = max(0.3, 0.75*brightMaskFractionMax)
742 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
748 for nIter
in range(self.config.brightDetectionIterMax):
750 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
751 factor=brightFactorPos, factorNeg=brightFactorNeg)
753 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
754 factor=brightFactorPos, factorNeg=brightFactorNeg
760 nPixDetPos = np.sum((maskedImage.mask.array & detectedPosPixelMask != 0)
761 & (maskedImage.mask.array & badPixelMask == 0))
762 nPixDetNeg = np.sum((maskedImage.mask.array & detectedNegPixelMask != 0)
763 & (maskedImage.mask.array & badPixelMask == 0))
764 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
765 format(nPixDetPos, 100*nPixDetPos/nGoodPix))
766 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
767 format(nPixDetNeg, 100*nPixDetNeg/nGoodPix))
769 if nPixDetPos/nGoodPix > brightMaskFractionMax
or nPixDetNeg/nGoodPix > brightMaskNegFractionMax:
770 if nIter == self.config.brightDetectionIterMax - 1:
771 self.log.warning(
"Reached maximum number of iterations and still have too high "
772 "detected mask fractions in bright detection pass. Image is "
773 "likely mostly masked with BAD or NO_DATA or \"bad\" in some "
774 "other respect (so expected to likely fail further downstream).")
776 if nPixDetPos/nGoodPix > brightMaskFractionMax:
777 brightFactorPos *= self.config.bisectFactor
778 self.log.warning(
"Too high a fraction (%.2f > %.2f) of pixels were masked as "
779 "DETECTED with current \"bright\" detection round thresholds "
780 "(at nIter = %d). Increasing to a factor of %.2f and trying again.",
781 nPixDetPos/nGoodPix, brightMaskFractionMax, nIter, brightFactorPos)
782 if nPixDetNeg/nGoodPix > brightMaskNegFractionMax:
784 if nPixDetNeg/nGoodPix > min(0.98, 1.25*brightMaskNegFractionMax):
787 elif nPixDetNeg/nGoodPix > 0.9999:
791 brightFactorNeg *= self.config.bisectFactor*extraFactorNeg
792 self.log.warning(
"Too high a fraction (%.2f > %.2f) of pixels were masked as "
793 "DETECTED_NEGATIVE with current \"bright\" detection round thresholds "
794 "(at nIter = %d). Increasing to a factor of %.2f and trying again.",
795 nPixDetNeg/nGoodPix, brightMaskNegFractionMax, nIter, brightFactorNeg)
801 brightDetectedMask = (maskedImage.mask.array
802 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
804 return brightDetectedMask, brightFactorNeg