198 isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10):
199 """Calculate new threshold
201 This is the main functional addition to the vanilla
202 `SourceDetectionTask`.
204 We identify sky objects and perform forced PSF photometry on
205 them. Using those PSF flux measurements and estimated errors,
206 we set the threshold so that the stdev of the measurements
207 matches the median estimated error.
211 exposure : `lsst.afw.image.Exposure`
212 Exposure on which we're detecting sources.
214 RNG seed to use for finding sky objects.
215 sigma : `float`, optional
216 Gaussian sigma of smoothing kernel; if not provided,
217 will be deduced from the exposure's PSF.
218 minFractionSourcesFactor : `float`
219 Change the fraction of required sky sources from that set in
220 ``self.config.minFractionSources`` by this factor. NOTE: this
221 is intended for use in the background tweak pass (the detection
222 threshold is much lower there, so many more pixels end up marked
223 as DETECTED or DETECTED_NEGATIVE, leaving less room for sky
226 Set to ``True`` for the background tweak pass (for more helpful
228 nPixMaskErode : `int`, optional
229 Number of pixels by which to erode the detection masks on each
230 iteration of best-effort sky object placement.
231 maxMaskErodeIter : `int`, optional
232 Maximum number of iterations for the detection mask erosion.
236 result : `lsst.pipe.base.Struct`
237 Result struct with components:
240 Multiplicative factor to be applied to the
241 configured detection threshold (`float`).
243 Additive factor to be applied to the background
249 Raised if the number of good sky sources found is less than the
251 (``self.config.minFractionSources``*``minFractionSourcesFactor``)
252 of the number requested (``self.skyObjects.config.nSources``).
254 wcsIsNone = exposure.getWcs()
is None
256 self.log.info(
"WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
259 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
260 minNumSources = int(self.config.minFractionSources*self.skyObjects.config.nSources)
263 if minFractionSourcesFactor != 1.0:
264 minNumSources = max(3, int(minNumSources*minFractionSourcesFactor))
265 fp = self.skyObjects.run(exposure.maskedImage.mask, seed)
267 if self.config.allowMaskErode:
268 detectedMaskPlanes = [
"DETECTED",
"DETECTED_NEGATIVE"]
269 mask = exposure.maskedImage.mask
270 for nIter
in range(maxMaskErodeIter):
272 fp = self.skyObjects.run(mask, seed)
273 if len(fp) < int(2*minNumSources):
274 self.log.info(
"Current number of sky sources is below 2*minimum required "
275 "(%d < %d, allowing for some subsequent measurement failures). "
276 "Allowing erosion of detected mask planes for sky placement "
277 "nIter: %d [of %d max]",
278 len(fp), 2*minNumSources, nIter, maxMaskErodeIter)
279 if nPixMaskErode
is None:
282 elif len(fp) < int(0.75*minNumSources):
286 for maskName
in detectedMaskPlanes:
288 detectedMaskBit = mask.getPlaneBitMask(maskName)
289 detectedMaskSpanSet = SpanSet.fromMask(mask, detectedMaskBit)
290 detectedMaskSpanSet = detectedMaskSpanSet.eroded(nPixMaskErode)
292 detectedMask = mask.getMaskPlane(maskName)
293 mask.clearMaskPlane(detectedMask)
295 detectedMaskSpanSet.setMask(mask, detectedMaskBit)
300 skyFootprints.setFootprints(fp)
303 catalog.reserve(len(skyFootprints.getFootprints()))
304 skyFootprints.makeSources(catalog)
305 key = catalog.getCentroidSlot().getMeasKey()
306 for source
in catalog:
307 peaks = source.getFootprint().getPeaks()
308 assert len(peaks) == 1
309 source.set(key, peaks[0].getF())
311 source.updateCoord(exposure.getWcs(), include_covariance=
False)
314 self.
skyMeasurement.run(catalog, exposure, catalog, exposure.getWcs())
317 fluxes = catalog[
"base_PsfFlux_instFlux"]
318 area = catalog[
"base_PsfFlux_area"]
319 good = (~catalog[
"base_PsfFlux_flag"] & np.isfinite(fluxes))
321 if good.sum() < minNumSources:
323 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
324 f
"{minNumSources}) for dynamic threshold calculation.")
326 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
327 f
"{minNumSources}) for background tweak calculation.")
329 nPix = exposure.mask.array.size
331 nGoodPix = np.sum(exposure.mask.array & badPixelMask == 0)
332 if nGoodPix/nPix > 0.2:
334 nDetectedPix = np.sum(exposure.mask.array & detectedPixelMask != 0)
335 msg += (f
" However, {nGoodPix}/{nPix} pixels are not marked NO_DATA or BAD, "
336 "so there should be sufficient area to locate suitable sky sources. "
337 f
"Note that {nDetectedPix} of {nGoodPix} \"good\" pixels were marked "
338 "as DETECTED or DETECTED_NEGATIVE.")
343 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
344 good.sum(), self.skyObjects.config.nSources)
346 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
347 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
349 bgMedian = np.median((fluxes/area)[good])
350 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
351 stdevMeas = 0.741*(uq - lq)
352 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
354 exposure.setWcs(
None)
355 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
357 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
358 background=None, backgroundToPhotometricRatio=None):
359 """Detect footprints with a dynamic threshold
361 This varies from the vanilla ``detectFootprints`` method because we
362 do detection three times: first with a high threshold to detect
363 "bright" (both positive and negative, the latter to identify very
364 over-subtracted regions) sources for which we grow the DETECTED and
365 DETECTED_NEGATIVE masks significantly to account for wings. Second,
366 with a low threshold to mask all non-empty regions of the image. These
367 two masks are combined and used to identify regions of sky
368 uncontaminated by objects. A final round of detection is then done
369 with the new calculated threshold.
373 exposure : `lsst.afw.image.Exposure`
374 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
376 doSmooth : `bool`, optional
377 If True, smooth the image before detection using a Gaussian
379 sigma : `float`, optional
380 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
381 detections; if `None` then measure the sigma of the PSF of the
383 clearMask : `bool`, optional
384 Clear both DETECTED and DETECTED_NEGATIVE planes before running
386 expId : `int`, optional
387 Exposure identifier, used as a seed for the random number
388 generator. If absent, the seed will be the sum of the image.
389 background : `lsst.afw.math.BackgroundList`, optional
390 Background that was already subtracted from the exposure; will be
391 modified in-place if ``reEstimateBackground=True``.
392 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
393 Unused; if set will Raise.
397 results : `lsst.pipe.base.Struct`
398 The results `~lsst.pipe.base.Struct` contains:
401 Positive polarity footprints.
402 (`lsst.afw.detection.FootprintSet` or `None`)
404 Negative polarity footprints.
405 (`lsst.afw.detection.FootprintSet` or `None`)
407 Number of footprints in positive or 0 if detection polarity was
410 Number of footprints in negative or 0 if detection polarity was
413 Re-estimated background. `None` or the input ``background``
414 if ``reEstimateBackground==False``.
415 (`lsst.afw.math.BackgroundList`)
417 Multiplication factor applied to the configured detection
420 Results from preliminary detection pass.
421 (`lsst.pipe.base.Struct`)
423 if backgroundToPhotometricRatio
is not None:
424 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
425 maskedImage = exposure.maskedImage
430 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
431 "DETECTED_NEGATIVE"])
432 nPix = maskedImage.mask.array.size
434 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
435 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.2f}% of total)".
436 format(nGoodPix, 100*nGoodPix/nPix))
437 if nGoodPix/nPix < self.config.minGoodPixelFraction:
438 msg = (f
"Image has a very low good pixel fraction ({nGoodPix} of {nPix}), so not worth further "
440 raise NoWorkFound(msg)
446 psf = self.
getPsf(exposure, sigma=sigma)
447 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
449 if self.config.doThresholdScaling:
450 if self.config.doBrightPrelimDetection:
457 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
459 middle = convolveResults.middle
460 sigma = convolveResults.sigma
461 if self.config.doThresholdScaling:
463 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
464 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
467 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
468 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
470 if self.config.doBrightPrelimDetection:
473 maskedImage.mask.array |= brightDetectedMask
477 if (self.config.minThresholdScaleFactor
478 and threshResults.multiplicative < self.config.minThresholdScaleFactor):
479 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
480 "bounds [%.2f, %.2f]. Setting factor to lower limit: %.2f.",
481 threshResults.multiplicative, self.config.minThresholdScaleFactor,
482 self.config.maxThresholdScaleFactor, self.config.minThresholdScaleFactor)
483 factor = self.config.minThresholdScaleFactor
484 elif (self.config.maxThresholdScaleFactor
485 and threshResults.multiplicative > self.config.maxThresholdScaleFactor):
486 self.log.warning(
"Measured threshold scaling factor (%.2f) is outside [min, max] "
487 "bounds [%.2f, %.2f]. Setting factor to upper limit: %.2f.",
488 threshResults.multiplicative, self.config.minThresholdScaleFactor,
489 self.config.maxThresholdScaleFactor, self.config.maxThresholdScaleFactor)
490 factor = self.config.maxThresholdScaleFactor
492 factor = threshResults.multiplicative
493 self.log.info(
"Modifying configured detection threshold by factor %.2f to %.2f",
494 factor, factor*self.config.thresholdValue)
503 maskedImage.mask.array |= oldDetected
506 results = self.
applyThreshold(middle, maskedImage.getBBox(), factor)
507 results.prelim = prelim
509 if self.config.doTempLocalBackground:
512 growOverride=growOverride)
513 self.log.warning(
"nPeaks/nFootprint = %.2f (max is %.1f)",
514 results.numPosPeaks/results.numPos,
515 self.config.maxPeakToFootRatio)
516 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
517 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
522 if growOverride
is None:
523 growOverride = 0.75*self.config.nSigmaToGrow
526 self.log.warning(
"Decreasing nSigmaToGrow to %.2f", growOverride)
528 self.log.warning(
"New theshold value would be > 5 times the initially requested "
529 "one (%.2f > %.2f). Leaving inFinalize iteration without "
530 "getting the number of peaks per footprint below %.1f",
531 factor*self.config.thresholdValue, self.config.thresholdValue,
532 self.config.maxPeakToFootRatio)
536 self.log.warning(
"numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
537 "Increasing threshold factor to %.2f and re-running,",
538 results.numPosPeaks/results.numPos, self.config.maxPeakToFootRatio,
543 if self.config.reEstimateBackground:
546 self.
display(exposure, results, middle)
563 if self.config.doBackgroundTweak:
564 originalMask = maskedImage.mask.array.copy()
567 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
568 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
570 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
571 isBgTweak=
True).additive
572 if self.config.minBackgroundTweak
and bgLevel < self.config.minBackgroundTweak:
573 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
574 "[%.2f, %.2f]. Setting tweak to lower limit: %.2f.", bgLevel,
575 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
576 self.config.minBackgroundTweak)
577 bgLevel = self.config.minBackgroundTweak
578 if self.config.maxBackgroundTweak
and bgLevel > self.config.maxBackgroundTweak:
579 self.log.warning(
"Measured background tweak (%.2f) is outside [min, max] bounds "
580 "[%.2f, %.2f]. Setting tweak to upper limit: %.2f.", bgLevel,
581 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
582 self.config.maxBackgroundTweak)
583 bgLevel = self.config.maxBackgroundTweak
585 maskedImage.mask.array[:] = originalMask
622 """Perform an initial bright source detection pass.
624 Perform an initial bright object detection pass using a high detection
625 threshold. The footprints in this pass are grown significantly more
626 than is typical to account for wings around bright sources. The
627 negative polarity detections in this pass help in masking severely
628 over-subtracted regions.
630 A maximum fraction of masked pixel from this pass is ensured via
631 the config ``brightMaskFractionMax``. If the masked pixel fraction is
632 above this value, the detection thresholds here are increased by
633 ``bisectFactor`` in a while loop until the detected masked fraction
634 falls below this value.
638 maskedImage : `lsst.afw.image.MaskedImage`
639 Masked image on which to run the detection.
640 convolveResults : `lsst.pipe.base.Struct`
641 The results of the self.convolveImage function with attributes:
644 Convolved image, without the edges
645 (`lsst.afw.image.MaskedImage`).
647 Gaussian sigma used for the convolution (`float`).
651 brightDetectedMask : `numpy.ndarray`
652 Boolean array representing the union of the bright detection pass
653 DETECTED and DETECTED_NEGATIVE masks.
657 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
659 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
663 brightMaskFractionMax = self.config.brightMaskFractionMax
669 while nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
671 brightPosFactor *= self.config.bisectFactor
672 brightNegFactor *= self.config.bisectFactor
673 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
674 factor=brightPosFactor, factorNeg=brightNegFactor)
676 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
677 factor=brightPosFactor, factorNeg=brightNegFactor
680 nPix = maskedImage.mask.array.size
682 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
683 format(nPixDet, 100*nPixDet/nPix))
685 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
686 format(nPixDetNeg, 100*nPixDetNeg/nPix))
687 if nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
688 self.log.warn(
"Too high a fraction (%.1f > %.1f) of pixels were masked with current "
689 "\"bright\" detection round thresholds. Increasing by a factor of %.2f "
690 "and trying again.", max(nPixDetNeg, nPixDet)/nPix,
691 brightMaskFractionMax, self.config.bisectFactor)
695 brightDetectedMask = (maskedImage.mask.array
696 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
698 return brightDetectedMask