228 isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10):
229 """Calculate new threshold
231 This is the main functional addition to the vanilla
232 `SourceDetectionTask`.
234 We identify sky objects and perform forced PSF photometry on
235 them. Using those PSF flux measurements and estimated errors,
236 we set the threshold so that the stdev of the measurements
237 matches the median estimated error.
241 exposure : `lsst.afw.image.Exposure`
242 Exposure on which we're detecting sources.
244 RNG seed to use for finding sky objects.
245 sigma : `float`, optional
246 Gaussian sigma of smoothing kernel; if not provided,
247 will be deduced from the exposure's PSF.
248 minFractionSourcesFactor : `float`
249 Change the fraction of required sky sources from that set in
250 ``self.config.minFractionSources`` by this factor. NOTE: this
251 is intended for use in the background tweak pass (the detection
252 threshold is much lower there, so many more pixels end up marked
253 as DETECTED or DETECTED_NEGATIVE, leaving less room for sky
256 Set to ``True`` for the background tweak pass (for more helpful
258 nPixMaskErode : `int`, optional
259 Number of pixels by which to erode the detection masks on each
260 iteration of best-effort sky object placement.
261 maxMaskErodeIter : `int`, optional
262 Maximum number of iterations for the detection mask erosion.
266 result : `lsst.pipe.base.Struct`
267 Result struct with components:
270 Multiplicative factor to be applied to the
271 configured detection threshold (`float`).
273 Additive factor to be applied to the background
278 InsufficientSourcesError
279 Raised if the number of good sky sources found is less than the
281 (``self.config.minFractionSources``*``minFractionSourcesFactor``)
282 of the number requested (``self.skyObjects.config.nSources``).
284 wcsIsNone = exposure.getWcs()
is None
286 self.log.info(
"WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
289 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
290 minNumSources = int(self.config.minFractionSources*self.skyObjects.config.nSources)
293 if minFractionSourcesFactor != 1.0:
294 minNumSources = max(3, int(minNumSources*minFractionSourcesFactor))
295 fp = self.skyObjects.
run(exposure.maskedImage.mask, seed)
297 if self.config.allowMaskErode:
298 detectedMaskPlanes = [
"DETECTED",
"DETECTED_NEGATIVE"]
299 mask = exposure.maskedImage.mask
300 for nIter
in range(maxMaskErodeIter):
302 fp = self.skyObjects.
run(mask, seed)
303 if len(fp) < int(2*minNumSources):
304 self.log.info(
"Current number of sky sources is below 2*minimum required "
305 "(%d < %d, allowing for some subsequent measurement failures). "
306 "Allowing erosion of detected mask planes for sky placement "
307 "nIter: %d [of %d max]",
308 len(fp), 2*minNumSources, nIter, maxMaskErodeIter)
309 if nPixMaskErode
is None:
312 elif len(fp) < int(0.75*minNumSources):
316 for maskName
in detectedMaskPlanes:
318 detectedMaskBit = mask.getPlaneBitMask(maskName)
319 detectedMaskSpanSet = SpanSet.fromMask(mask, detectedMaskBit)
320 detectedMaskSpanSet = detectedMaskSpanSet.eroded(nPixMaskErode)
322 detectedMask = mask.getMaskPlane(maskName)
323 mask.clearMaskPlane(detectedMask)
325 detectedMaskSpanSet.setMask(mask, detectedMaskBit)
330 skyFootprints.setFootprints(fp)
333 catalog.reserve(len(skyFootprints.getFootprints()))
334 skyFootprints.makeSources(catalog)
335 key = catalog.getCentroidSlot().getMeasKey()
336 for source
in catalog:
337 peaks = source.getFootprint().getPeaks()
338 assert len(peaks) == 1
339 source.set(key, peaks[0].getF())
341 source.updateCoord(exposure.getWcs(), include_covariance=
False)
347 fluxes = catalog[
"base_PsfFlux_instFlux"]
348 area = catalog[
"base_PsfFlux_area"]
349 good = (~catalog[
"base_PsfFlux_flag"] & np.isfinite(fluxes))
351 if good.sum() < minNumSources:
353 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
354 f
"{minNumSources}) for dynamic threshold calculation.")
356 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
357 f
"{minNumSources}) for background tweak calculation.")
359 nPix = exposure.mask.array.size
361 nGoodPix = np.sum(exposure.mask.array & badPixelMask == 0)
362 if nGoodPix/nPix > 0.2:
364 nDetectedPix = np.sum((exposure.mask.array & detectedPixelMask != 0)
365 & (exposure.mask.array & badPixelMask == 0))
366 msg += (
" However, {} of {} ({:.3f}%) pixels are not marked NO_DATA or BAD, "
367 "so there should be sufficient area to locate suitable sky sources. "
368 "Note that {} of {} ({:.3f}%) \"good\" pixels were marked "
369 "as DETECTED or DETECTED_NEGATIVE.".format(
370 nGoodPix, nPix, 100.0*nGoodPix/nPix,
371 nDetectedPix, nGoodPix, 100.0*nDetectedPix/nGoodPix))
376 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
377 good.sum(), self.skyObjects.config.nSources)
379 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
380 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
382 bgMedian = np.median((fluxes/area)[good])
383 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
384 stdevMeas = 0.741*(uq - lq)
385 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
387 exposure.setWcs(
None)
388 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
390 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
391 background=None, backgroundToPhotometricRatio=None):
392 """Detect footprints with a dynamic threshold
394 This varies from the vanilla ``detectFootprints`` method because we
395 do detection three times: first with a high threshold to detect
396 "bright" (both positive and negative, the latter to identify very
397 over-subtracted regions) sources for which we grow the DETECTED and
398 DETECTED_NEGATIVE masks significantly to account for wings. Second,
399 with a low threshold to mask all non-empty regions of the image. These
400 two masks are combined and used to identify regions of sky
401 uncontaminated by objects. A final round of detection is then done
402 with the new calculated threshold.
406 exposure : `lsst.afw.image.Exposure`
407 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
409 doSmooth : `bool`, optional
410 If True, smooth the image before detection using a Gaussian
412 sigma : `float`, optional
413 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
414 detections; if `None` then measure the sigma of the PSF of the
416 clearMask : `bool`, optional
417 Clear both DETECTED and DETECTED_NEGATIVE planes before running
419 expId : `int`, optional
420 Exposure identifier, used as a seed for the random number
421 generator. If absent, the seed will be the sum of the image.
422 background : `lsst.afw.math.BackgroundList`, optional
423 Background that was already subtracted from the exposure; will be
424 modified in-place if ``reEstimateBackground=True``.
425 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
426 Unused; if set will Raise.
430 results : `lsst.pipe.base.Struct`
431 The results `~lsst.pipe.base.Struct` contains:
434 Positive polarity footprints.
435 (`lsst.afw.detection.FootprintSet` or `None`)
437 Negative polarity footprints.
438 (`lsst.afw.detection.FootprintSet` or `None`)
440 Number of footprints in positive or 0 if detection polarity was
443 Number of footprints in negative or 0 if detection polarity was
446 Re-estimated background. `None` or the input ``background``
447 if ``reEstimateBackground==False``.
448 (`lsst.afw.math.BackgroundList`)
450 Multiplication factor applied to the configured detection
453 Results from preliminary detection pass.
454 (`lsst.pipe.base.Struct`)
456 if backgroundToPhotometricRatio
is not None:
457 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
458 maskedImage = exposure.maskedImage
463 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
464 "DETECTED_NEGATIVE"])
465 nPix = maskedImage.mask.array.size
467 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
468 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.2f}% of total)".
469 format(nGoodPix, 100*nGoodPix/nPix))
470 if nGoodPix/nPix < self.config.minGoodPixelFraction:
471 msg = (f
"Image has a very low good pixel fraction ({nGoodPix} of {nPix}), so not worth further "
479 psf = self.
getPsf(exposure, sigma=sigma)
480 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
482 if self.config.doThresholdScaling:
483 if self.config.doBrightPrelimDetection:
490 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
492 middle = convolveResults.middle
493 sigma = convolveResults.sigma
494 if self.config.doThresholdScaling:
496 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
497 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
500 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
501 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
503 if self.config.doBrightPrelimDetection:
506 maskedImage.mask.array |= brightDetectedMask
510 if (self.config.minThresholdScaleFactor
511 and threshResults.multiplicative < self.config.minThresholdScaleFactor):
512 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
513 "bounds [%.2f, %.2f]. Setting factor to lower limit: %.2f.",
514 threshResults.multiplicative, self.config.minThresholdScaleFactor,
515 self.config.maxThresholdScaleFactor, self.config.minThresholdScaleFactor)
516 factor = self.config.minThresholdScaleFactor
517 elif (self.config.maxThresholdScaleFactor
518 and threshResults.multiplicative > self.config.maxThresholdScaleFactor):
519 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
520 "bounds [%.2f, %.2f]. Setting factor to upper limit: %.2f.",
521 threshResults.multiplicative, self.config.minThresholdScaleFactor,
522 self.config.maxThresholdScaleFactor, self.config.maxThresholdScaleFactor)
523 factor = self.config.maxThresholdScaleFactor
525 factor = threshResults.multiplicative
526 self.log.info(
"Modifying configured detection threshold by factor %.2f to %.2f",
527 factor, factor*self.config.thresholdValue)
536 maskedImage.mask.array |= oldDetected
539 results = self.
applyThreshold(middle, maskedImage.getBBox(), factor)
540 results.prelim = prelim
541 results.background = background
if background
is not None else lsst.afw.math.BackgroundList()
542 if self.config.doTempLocalBackground:
545 growOverride=growOverride)
546 if results.numPos == 0:
547 msg =
"No footprints were detected, so further processing would be moot"
550 self.log.warning(
"nPeaks/nFootprint = %.2f (max is %.1f)",
551 results.numPosPeaks/results.numPos,
552 self.config.maxPeakToFootRatio)
553 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
554 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
559 if growOverride
is None:
560 growOverride = 0.75*self.config.nSigmaToGrow
563 self.log.warning(
"Decreasing nSigmaToGrow to %.2f", growOverride)
565 self.log.warning(
"New theshold value would be > 5 times the initially requested "
566 "one (%.2f > %.2f). Leaving inFinalize iteration without "
567 "getting the number of peaks per footprint below %.1f",
568 factor*self.config.thresholdValue, self.config.thresholdValue,
569 self.config.maxPeakToFootRatio)
573 self.log.warning(
"numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
574 "Increasing threshold factor to %.2f and re-running,",
575 results.numPosPeaks/results.numPos,
576 self.config.maxPeakToFootRatio, factor)
580 if self.config.reEstimateBackground:
583 self.
display(exposure, results, middle)
600 if self.config.doBackgroundTweak:
601 originalMask = maskedImage.mask.array.copy()
604 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
605 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
607 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
608 isBgTweak=
True).additive
609 if self.config.minBackgroundTweak
and bgLevel < self.config.minBackgroundTweak:
610 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
611 "[%.2f, %.2f]. Setting tweak to lower limit: %.2f.", bgLevel,
612 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
613 self.config.minBackgroundTweak)
614 bgLevel = self.config.minBackgroundTweak
615 if self.config.maxBackgroundTweak
and bgLevel > self.config.maxBackgroundTweak:
616 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
617 "[%.2f, %.2f]. Setting tweak to upper limit: %.2f.", bgLevel,
618 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
619 self.config.maxBackgroundTweak)
620 bgLevel = self.config.maxBackgroundTweak
622 maskedImage.mask.array[:] = originalMask
659 """Perform an initial bright source detection pass.
661 Perform an initial bright object detection pass using a high detection
662 threshold. The footprints in this pass are grown significantly more
663 than is typical to account for wings around bright sources. The
664 negative polarity detections in this pass help in masking severely
665 over-subtracted regions.
667 A maximum fraction of masked pixel from this pass is ensured via
668 the config ``brightMaskFractionMax``. If the masked pixel fraction is
669 above this value, the detection thresholds here are increased by
670 ``bisectFactor`` in a while loop until the detected masked fraction
671 falls below this value.
675 maskedImage : `lsst.afw.image.MaskedImage`
676 Masked image on which to run the detection.
677 convolveResults : `lsst.pipe.base.Struct`
678 The results of the self.convolveImage function with attributes:
681 Convolved image, without the edges
682 (`lsst.afw.image.MaskedImage`).
684 Gaussian sigma used for the convolution (`float`).
688 brightDetectedMask : `numpy.ndarray`
689 Boolean array representing the union of the bright detection pass
690 DETECTED and DETECTED_NEGATIVE masks.
694 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
696 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
700 brightMaskFractionMax = self.config.brightMaskFractionMax
706 while nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
708 brightPosFactor *= self.config.bisectFactor
709 brightNegFactor *= self.config.bisectFactor
710 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
711 factor=brightPosFactor, factorNeg=brightNegFactor)
713 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
714 factor=brightPosFactor, factorNeg=brightNegFactor
717 nPix = maskedImage.mask.array.size
719 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
720 format(nPixDet, 100*nPixDet/nPix))
722 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
723 format(nPixDetNeg, 100*nPixDetNeg/nPix))
724 if nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
725 self.log.warn(
"Too high a fraction (%.1f > %.1f) of pixels were masked with current "
726 "\"bright\" detection round thresholds. Increasing by a factor of %.2f "
727 "and trying again.", max(nPixDetNeg, nPixDet)/nPix,
728 brightMaskFractionMax, self.config.bisectFactor)
732 brightDetectedMask = (maskedImage.mask.array
733 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
735 return brightDetectedMask