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:
367 nDetectedPix = np.sum((exposure.mask.array & detectedPixelMask != 0)
368 & (exposure.mask.array & badPixelMask == 0))
369 msg += (
" However, {} of {} ({:.3f}%) pixels are not marked NO_DATA or BAD, "
370 "so there should be sufficient area to locate suitable sky sources. "
371 "Note that {} of {} ({:.3f}%) \"good\" pixels were marked "
372 "as DETECTED or DETECTED_NEGATIVE.".format(
373 nGoodPix, nPix, 100.0*nGoodPix/nPix,
374 nDetectedPix, nGoodPix, 100.0*nDetectedPix/nGoodPix))
379 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
380 good.sum(), self.skyObjects.config.nSources)
382 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
383 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
385 bgMedian = np.median((fluxes/area)[good])
386 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
387 stdevMeas = 0.741*(uq - lq)
388 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
390 exposure.setWcs(
None)
391 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
393 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
394 background=None, backgroundToPhotometricRatio=None):
395 """Detect footprints with a dynamic threshold
397 This varies from the vanilla ``detectFootprints`` method because we
398 do detection three times: first with a high threshold to detect
399 "bright" (both positive and negative, the latter to identify very
400 over-subtracted regions) sources for which we grow the DETECTED and
401 DETECTED_NEGATIVE masks significantly to account for wings. Second,
402 with a low threshold to mask all non-empty regions of the image. These
403 two masks are combined and used to identify regions of sky
404 uncontaminated by objects. A final round of detection is then done
405 with the new calculated threshold.
409 exposure : `lsst.afw.image.Exposure`
410 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
412 doSmooth : `bool`, optional
413 If True, smooth the image before detection using a Gaussian
415 sigma : `float`, optional
416 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
417 detections; if `None` then measure the sigma of the PSF of the
419 clearMask : `bool`, optional
420 Clear both DETECTED and DETECTED_NEGATIVE planes before running
422 expId : `int`, optional
423 Exposure identifier, used as a seed for the random number
424 generator. If absent, the seed will be the sum of the image.
425 background : `lsst.afw.math.BackgroundList`, optional
426 Background that was already subtracted from the exposure; will be
427 modified in-place if ``reEstimateBackground=True``.
428 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
429 Unused; if set will Raise.
433 results : `lsst.pipe.base.Struct`
434 The results `~lsst.pipe.base.Struct` contains:
437 Positive polarity footprints.
438 (`lsst.afw.detection.FootprintSet` or `None`)
440 Negative polarity footprints.
441 (`lsst.afw.detection.FootprintSet` or `None`)
443 Number of footprints in positive or 0 if detection polarity was
446 Number of footprints in negative or 0 if detection polarity was
449 Re-estimated background. `None` or the input ``background``
450 if ``reEstimateBackground==False``.
451 (`lsst.afw.math.BackgroundList`)
453 Multiplication factor applied to the configured detection
456 Results from preliminary detection pass.
457 (`lsst.pipe.base.Struct`)
459 if backgroundToPhotometricRatio
is not None:
460 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
461 maskedImage = exposure.maskedImage
466 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
467 "DETECTED_NEGATIVE"])
468 nPix = maskedImage.mask.array.size
470 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
471 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.2f}% of total)".
472 format(nGoodPix, 100*nGoodPix/nPix))
473 if nGoodPix/nPix < self.config.minGoodPixelFraction:
474 msg = (f
"Image has a very low good pixel fraction ({nGoodPix} of {nPix}), so not worth further "
482 psf = self.
getPsf(exposure, sigma=sigma)
483 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
485 if self.config.doThresholdScaling:
486 if self.config.doBrightPrelimDetection:
493 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
495 middle = convolveResults.middle
496 sigma = convolveResults.sigma
497 if self.config.doThresholdScaling:
499 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
500 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
503 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
504 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
506 if self.config.doBrightPrelimDetection:
509 maskedImage.mask.array |= brightDetectedMask
513 if (self.config.minThresholdScaleFactor
514 and threshResults.multiplicative < self.config.minThresholdScaleFactor):
515 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
516 "bounds [%.2f, %.2f]. Setting factor to lower limit: %.2f.",
517 threshResults.multiplicative, self.config.minThresholdScaleFactor,
518 self.config.maxThresholdScaleFactor, self.config.minThresholdScaleFactor)
519 factor = self.config.minThresholdScaleFactor
520 elif (self.config.maxThresholdScaleFactor
521 and threshResults.multiplicative > self.config.maxThresholdScaleFactor):
522 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
523 "bounds [%.2f, %.2f]. Setting factor to upper limit: %.2f.",
524 threshResults.multiplicative, self.config.minThresholdScaleFactor,
525 self.config.maxThresholdScaleFactor, self.config.maxThresholdScaleFactor)
526 factor = self.config.maxThresholdScaleFactor
528 factor = threshResults.multiplicative
529 self.log.info(
"Modifying configured detection threshold by factor %.2f to %.2f",
530 factor, factor*self.config.thresholdValue)
539 maskedImage.mask.array |= oldDetected
542 results = self.
applyThreshold(middle, maskedImage.getBBox(), factor)
543 results.prelim = prelim
544 results.background = background
if background
is not None else lsst.afw.math.BackgroundList()
545 if self.config.doTempLocalBackground:
548 growOverride=growOverride)
549 if results.numPos == 0:
550 msg =
"No footprints were detected, so further processing would be moot"
553 self.log.warning(
"nPeaks/nFootprint = %.2f (max is %.1f)",
554 results.numPosPeaks/results.numPos,
555 self.config.maxPeakToFootRatio)
556 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
557 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
562 if growOverride
is None:
563 growOverride = 0.75*self.config.nSigmaToGrow
566 self.log.warning(
"Decreasing nSigmaToGrow to %.2f", growOverride)
568 self.log.warning(
"New theshold value would be > 5 times the initially requested "
569 "one (%.2f > %.2f). Leaving inFinalize iteration without "
570 "getting the number of peaks per footprint below %.1f",
571 factor*self.config.thresholdValue, self.config.thresholdValue,
572 self.config.maxPeakToFootRatio)
576 self.log.warning(
"numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
577 "Increasing threshold factor to %.2f and re-running,",
578 results.numPosPeaks/results.numPos,
579 self.config.maxPeakToFootRatio, factor)
583 if self.config.reEstimateBackground:
586 self.
display(exposure, results, middle)
603 if self.config.doBackgroundTweak:
604 originalMask = maskedImage.mask.array.copy()
607 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
608 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
610 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
611 isBgTweak=
True).additive
612 if self.config.minBackgroundTweak
and bgLevel < self.config.minBackgroundTweak:
613 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
614 "[%.2f, %.2f]. Setting tweak to lower limit: %.2f.", bgLevel,
615 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
616 self.config.minBackgroundTweak)
617 bgLevel = self.config.minBackgroundTweak
618 if self.config.maxBackgroundTweak
and bgLevel > self.config.maxBackgroundTweak:
619 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
620 "[%.2f, %.2f]. Setting tweak to upper limit: %.2f.", bgLevel,
621 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
622 self.config.maxBackgroundTweak)
623 bgLevel = self.config.maxBackgroundTweak
625 maskedImage.mask.array[:] = originalMask
662 """Perform an initial bright source detection pass.
664 Perform an initial bright object detection pass using a high detection
665 threshold. The footprints in this pass are grown significantly more
666 than is typical to account for wings around bright sources. The
667 negative polarity detections in this pass help in masking severely
668 over-subtracted regions.
670 A maximum fraction of masked pixel from this pass is ensured via
671 the config ``brightMaskFractionMax``. If the masked pixel fraction is
672 above this value, the detection thresholds here are increased by
673 ``bisectFactor`` in a while loop until the detected masked fraction
674 falls below this value.
678 maskedImage : `lsst.afw.image.MaskedImage`
679 Masked image on which to run the detection.
680 convolveResults : `lsst.pipe.base.Struct`
681 The results of the self.convolveImage function with attributes:
684 Convolved image, without the edges
685 (`lsst.afw.image.MaskedImage`).
687 Gaussian sigma used for the convolution (`float`).
691 brightDetectedMask : `numpy.ndarray`
692 Boolean array representing the union of the bright detection pass
693 DETECTED and DETECTED_NEGATIVE masks.
697 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
699 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
703 brightMaskFractionMax = self.config.brightMaskFractionMax
709 for nIter
in range(self.config.brightDetectionIterMax):
711 brightPosFactor *= self.config.bisectFactor
712 brightNegFactor *= self.config.bisectFactor
713 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
714 factor=brightPosFactor, factorNeg=brightNegFactor)
716 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
717 factor=brightPosFactor, factorNeg=brightNegFactor
720 nPix = maskedImage.mask.array.size
722 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
723 format(nPixDet, 100*nPixDet/nPix))
725 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
726 format(nPixDetNeg, 100*nPixDetNeg/nPix))
727 if nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
728 self.log.warning(
"Too high a fraction (%.2f > %.2f) of pixels were masked with current "
729 "\"bright\" detection round thresholds (at nIter = %d). Increasing by "
730 "a factor of %.2f and trying again.", max(nPixDetNeg, nPixDet)/nPix,
731 brightMaskFractionMax, nIter, self.config.bisectFactor)
732 if nIter == self.config.brightDetectionIterMax - 1:
733 self.log.warning(
"Reached maximum number of iterations and still have too high "
734 "detected mask fractions in bright detection pass. Image is "
735 "likely mostly masked with BAD or NO_DATA or \"bad\" in some "
736 "other respect (so expected to likely fail further downstream).")
742 brightDetectedMask = (maskedImage.mask.array
743 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
745 return brightDetectedMask