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)
344 self.
skyMeasurement.run(catalog, exposure, catalog, exposure.getWcs())
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 msg += (f
" However, {nGoodPix}/{nPix} pixels are not marked NO_DATA or BAD, "
366 "so there should be sufficient area to locate suitable sky sources. "
367 f
"Note that {nDetectedPix} of {nGoodPix} \"good\" pixels were marked "
368 "as DETECTED or DETECTED_NEGATIVE.")
373 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
374 good.sum(), self.skyObjects.config.nSources)
376 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
377 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
379 bgMedian = np.median((fluxes/area)[good])
380 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
381 stdevMeas = 0.741*(uq - lq)
382 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
384 exposure.setWcs(
None)
385 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
387 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
388 background=None, backgroundToPhotometricRatio=None):
389 """Detect footprints with a dynamic threshold
391 This varies from the vanilla ``detectFootprints`` method because we
392 do detection three times: first with a high threshold to detect
393 "bright" (both positive and negative, the latter to identify very
394 over-subtracted regions) sources for which we grow the DETECTED and
395 DETECTED_NEGATIVE masks significantly to account for wings. Second,
396 with a low threshold to mask all non-empty regions of the image. These
397 two masks are combined and used to identify regions of sky
398 uncontaminated by objects. A final round of detection is then done
399 with the new calculated threshold.
403 exposure : `lsst.afw.image.Exposure`
404 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
406 doSmooth : `bool`, optional
407 If True, smooth the image before detection using a Gaussian
409 sigma : `float`, optional
410 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
411 detections; if `None` then measure the sigma of the PSF of the
413 clearMask : `bool`, optional
414 Clear both DETECTED and DETECTED_NEGATIVE planes before running
416 expId : `int`, optional
417 Exposure identifier, used as a seed for the random number
418 generator. If absent, the seed will be the sum of the image.
419 background : `lsst.afw.math.BackgroundList`, optional
420 Background that was already subtracted from the exposure; will be
421 modified in-place if ``reEstimateBackground=True``.
422 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
423 Unused; if set will Raise.
427 results : `lsst.pipe.base.Struct`
428 The results `~lsst.pipe.base.Struct` contains:
431 Positive polarity footprints.
432 (`lsst.afw.detection.FootprintSet` or `None`)
434 Negative polarity footprints.
435 (`lsst.afw.detection.FootprintSet` or `None`)
437 Number of footprints in positive or 0 if detection polarity was
440 Number of footprints in negative or 0 if detection polarity was
443 Re-estimated background. `None` or the input ``background``
444 if ``reEstimateBackground==False``.
445 (`lsst.afw.math.BackgroundList`)
447 Multiplication factor applied to the configured detection
450 Results from preliminary detection pass.
451 (`lsst.pipe.base.Struct`)
453 if backgroundToPhotometricRatio
is not None:
454 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
455 maskedImage = exposure.maskedImage
460 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
461 "DETECTED_NEGATIVE"])
462 nPix = maskedImage.mask.array.size
464 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
465 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.2f}% of total)".
466 format(nGoodPix, 100*nGoodPix/nPix))
467 if nGoodPix/nPix < self.config.minGoodPixelFraction:
468 msg = (f
"Image has a very low good pixel fraction ({nGoodPix} of {nPix}), so not worth further "
476 psf = self.
getPsf(exposure, sigma=sigma)
477 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
479 if self.config.doThresholdScaling:
480 if self.config.doBrightPrelimDetection:
487 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
489 middle = convolveResults.middle
490 sigma = convolveResults.sigma
491 if self.config.doThresholdScaling:
493 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
494 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
497 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
498 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
500 if self.config.doBrightPrelimDetection:
503 maskedImage.mask.array |= brightDetectedMask
507 if (self.config.minThresholdScaleFactor
508 and threshResults.multiplicative < self.config.minThresholdScaleFactor):
509 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
510 "bounds [%.2f, %.2f]. Setting factor to lower limit: %.2f.",
511 threshResults.multiplicative, self.config.minThresholdScaleFactor,
512 self.config.maxThresholdScaleFactor, self.config.minThresholdScaleFactor)
513 factor = self.config.minThresholdScaleFactor
514 elif (self.config.maxThresholdScaleFactor
515 and threshResults.multiplicative > self.config.maxThresholdScaleFactor):
516 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
517 "bounds [%.2f, %.2f]. Setting factor to upper limit: %.2f.",
518 threshResults.multiplicative, self.config.minThresholdScaleFactor,
519 self.config.maxThresholdScaleFactor, self.config.maxThresholdScaleFactor)
520 factor = self.config.maxThresholdScaleFactor
522 factor = threshResults.multiplicative
523 self.log.info(
"Modifying configured detection threshold by factor %.2f to %.2f",
524 factor, factor*self.config.thresholdValue)
533 maskedImage.mask.array |= oldDetected
536 results = self.
applyThreshold(middle, maskedImage.getBBox(), factor)
537 results.prelim = prelim
539 if self.config.doTempLocalBackground:
542 growOverride=growOverride)
543 if results.numPos == 0:
544 msg =
"No footprints were detected, so further processing would be moot"
547 self.log.warning(
"nPeaks/nFootprint = %.2f (max is %.1f)",
548 results.numPosPeaks/results.numPos,
549 self.config.maxPeakToFootRatio)
550 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
551 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
556 if growOverride
is None:
557 growOverride = 0.75*self.config.nSigmaToGrow
560 self.log.warning(
"Decreasing nSigmaToGrow to %.2f", growOverride)
562 self.log.warning(
"New theshold value would be > 5 times the initially requested "
563 "one (%.2f > %.2f). Leaving inFinalize iteration without "
564 "getting the number of peaks per footprint below %.1f",
565 factor*self.config.thresholdValue, self.config.thresholdValue,
566 self.config.maxPeakToFootRatio)
570 self.log.warning(
"numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
571 "Increasing threshold factor to %.2f and re-running,",
572 results.numPosPeaks/results.numPos,
573 self.config.maxPeakToFootRatio, factor)
577 if self.config.reEstimateBackground:
580 self.
display(exposure, results, middle)
597 if self.config.doBackgroundTweak:
598 originalMask = maskedImage.mask.array.copy()
601 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
602 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
604 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
605 isBgTweak=
True).additive
606 if self.config.minBackgroundTweak
and bgLevel < self.config.minBackgroundTweak:
607 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
608 "[%.2f, %.2f]. Setting tweak to lower limit: %.2f.", bgLevel,
609 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
610 self.config.minBackgroundTweak)
611 bgLevel = self.config.minBackgroundTweak
612 if self.config.maxBackgroundTweak
and bgLevel > self.config.maxBackgroundTweak:
613 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
614 "[%.2f, %.2f]. Setting tweak to upper limit: %.2f.", bgLevel,
615 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
616 self.config.maxBackgroundTweak)
617 bgLevel = self.config.maxBackgroundTweak
619 maskedImage.mask.array[:] = originalMask
656 """Perform an initial bright source detection pass.
658 Perform an initial bright object detection pass using a high detection
659 threshold. The footprints in this pass are grown significantly more
660 than is typical to account for wings around bright sources. The
661 negative polarity detections in this pass help in masking severely
662 over-subtracted regions.
664 A maximum fraction of masked pixel from this pass is ensured via
665 the config ``brightMaskFractionMax``. If the masked pixel fraction is
666 above this value, the detection thresholds here are increased by
667 ``bisectFactor`` in a while loop until the detected masked fraction
668 falls below this value.
672 maskedImage : `lsst.afw.image.MaskedImage`
673 Masked image on which to run the detection.
674 convolveResults : `lsst.pipe.base.Struct`
675 The results of the self.convolveImage function with attributes:
678 Convolved image, without the edges
679 (`lsst.afw.image.MaskedImage`).
681 Gaussian sigma used for the convolution (`float`).
685 brightDetectedMask : `numpy.ndarray`
686 Boolean array representing the union of the bright detection pass
687 DETECTED and DETECTED_NEGATIVE masks.
691 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
693 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
697 brightMaskFractionMax = self.config.brightMaskFractionMax
703 while nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
705 brightPosFactor *= self.config.bisectFactor
706 brightNegFactor *= self.config.bisectFactor
707 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
708 factor=brightPosFactor, factorNeg=brightNegFactor)
710 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
711 factor=brightPosFactor, factorNeg=brightNegFactor
714 nPix = maskedImage.mask.array.size
716 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
717 format(nPixDet, 100*nPixDet/nPix))
719 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
720 format(nPixDetNeg, 100*nPixDetNeg/nPix))
721 if nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
722 self.log.warn(
"Too high a fraction (%.1f > %.1f) of pixels were masked with current "
723 "\"bright\" detection round thresholds. Increasing by a factor of %.2f "
724 "and trying again.", max(nPixDetNeg, nPixDet)/nPix,
725 brightMaskFractionMax, self.config.bisectFactor)
729 brightDetectedMask = (maskedImage.mask.array
730 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
732 return brightDetectedMask