165 isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10):
166 """Calculate new threshold
168 This is the main functional addition to the vanilla
169 `SourceDetectionTask`.
171 We identify sky objects and perform forced PSF photometry on
172 them. Using those PSF flux measurements and estimated errors,
173 we set the threshold so that the stdev of the measurements
174 matches the median estimated error.
178 exposure : `lsst.afw.image.Exposure`
179 Exposure on which we're detecting sources.
181 RNG seed to use for finding sky objects.
182 sigma : `float`, optional
183 Gaussian sigma of smoothing kernel; if not provided,
184 will be deduced from the exposure's PSF.
185 minFractionSourcesFactor : `float`
186 Change the fraction of required sky sources from that set in
187 ``self.config.minFractionSources`` by this factor. NOTE: this
188 is intended for use in the background tweak pass (the detection
189 threshold is much lower there, so many more pixels end up marked
190 as DETECTED or DETECTED_NEGATIVE, leaving less room for sky
193 Set to ``True`` for the background tweak pass (for more helpful
195 nPixMaskErode : `int`, optional
196 Number of pixels by which to erode the detection masks on each
197 iteration of best-effort sky object placement.
198 maxMaskErodeIter : `int`, optional
199 Maximum number of iterations for the detection mask erosion.
203 result : `lsst.pipe.base.Struct`
204 Result struct with components:
207 Multiplicative factor to be applied to the
208 configured detection threshold (`float`).
210 Additive factor to be applied to the background
216 Raised if the number of good sky sources found is less than the
218 (``self.config.minFractionSources``*``minFractionSourcesFactor``)
219 of the number requested (``self.skyObjects.config.nSources``).
221 wcsIsNone = exposure.getWcs()
is None
223 self.log.info(
"WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
226 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
227 minNumSources = int(self.config.minFractionSources*self.skyObjects.config.nSources)
230 if minFractionSourcesFactor != 1.0:
231 minNumSources = max(3, int(minNumSources*minFractionSourcesFactor))
232 fp = self.skyObjects.
run(exposure.maskedImage.mask, seed)
234 if self.config.allowMaskErode:
235 detectedMaskPlanes = [
"DETECTED",
"DETECTED_NEGATIVE"]
236 mask = exposure.maskedImage.mask
237 for nIter
in range(maxMaskErodeIter):
239 fp = self.skyObjects.
run(mask, seed)
240 if len(fp) < int(2*minNumSources):
241 self.log.info(
"Current number of sky sources is below 2*minimum required "
242 "(%d < %d, allowing for some subsequent measurement failures). "
243 "Allowing erosion of detected mask planes for sky placement "
244 "nIter: %d [of %d max]",
245 len(fp), 2*minNumSources, nIter, maxMaskErodeIter)
246 if nPixMaskErode
is None:
249 elif len(fp) < int(0.75*minNumSources):
253 for maskName
in detectedMaskPlanes:
255 detectedMaskBit = mask.getPlaneBitMask(maskName)
256 detectedMaskSpanSet = SpanSet.fromMask(mask, detectedMaskBit)
257 detectedMaskSpanSet = detectedMaskSpanSet.eroded(nPixMaskErode)
259 detectedMask = mask.getMaskPlane(maskName)
260 mask.clearMaskPlane(detectedMask)
262 detectedMaskSpanSet.setMask(mask, detectedMaskBit)
267 skyFootprints.setFootprints(fp)
270 catalog.reserve(len(skyFootprints.getFootprints()))
271 skyFootprints.makeSources(catalog)
272 key = catalog.getCentroidSlot().getMeasKey()
273 for source
in catalog:
274 peaks = source.getFootprint().getPeaks()
275 assert len(peaks) == 1
276 source.set(key, peaks[0].getF())
278 source.updateCoord(exposure.getWcs(), include_covariance=
False)
284 fluxes = catalog[
"base_PsfFlux_instFlux"]
285 area = catalog[
"base_PsfFlux_area"]
286 good = (~catalog[
"base_PsfFlux_flag"] & np.isfinite(fluxes))
288 if good.sum() < minNumSources:
290 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
291 f
"{minNumSources}) for dynamic threshold calculation.")
293 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
294 f
"{minNumSources}) for background tweak calculation.")
296 nPix = exposure.mask.array.size
298 nGoodPix = np.sum(exposure.mask.array & badPixelMask == 0)
299 if nGoodPix/nPix > 0.2:
301 nDetectedPix = np.sum(exposure.mask.array & detectedPixelMask != 0)
302 msg += (f
" However, {nGoodPix}/{nPix} pixels are not marked NO_DATA or BAD, "
303 "so there should be sufficient area to locate suitable sky sources. "
304 f
"Note that {nDetectedPix} of {nGoodPix} \"good\" pixels were marked "
305 "as DETECTED or DETECTED_NEGATIVE.")
310 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
311 good.sum(), self.skyObjects.config.nSources)
313 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
314 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
316 bgMedian = np.median((fluxes/area)[good])
317 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
318 stdevMeas = 0.741*(uq - lq)
319 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
321 exposure.setWcs(
None)
322 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
324 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
325 background=None, backgroundToPhotometricRatio=None):
326 """Detect footprints with a dynamic threshold
328 This varies from the vanilla ``detectFootprints`` method because we
329 do detection three times: first with a high threshold to detect
330 "bright" (both positive and negative, the latter to identify very
331 over-subtracted regions) sources for which we grow the DETECTED and
332 DETECTED_NEGATIVE masks significantly to account for wings. Second,
333 with a low threshold to mask all non-empty regions of the image. These
334 two masks are combined and used to identify regions of sky
335 uncontaminated by objects. A final round of detection is then done
336 with the new calculated threshold.
340 exposure : `lsst.afw.image.Exposure`
341 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
343 doSmooth : `bool`, optional
344 If True, smooth the image before detection using a Gaussian
346 sigma : `float`, optional
347 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
348 detections; if `None` then measure the sigma of the PSF of the
350 clearMask : `bool`, optional
351 Clear both DETECTED and DETECTED_NEGATIVE planes before running
353 expId : `int`, optional
354 Exposure identifier, used as a seed for the random number
355 generator. If absent, the seed will be the sum of the image.
356 background : `lsst.afw.math.BackgroundList`, optional
357 Background that was already subtracted from the exposure; will be
358 modified in-place if ``reEstimateBackground=True``.
359 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
360 Unused; if set will Raise.
364 results : `lsst.pipe.base.Struct`
365 The results `~lsst.pipe.base.Struct` contains:
368 Positive polarity footprints.
369 (`lsst.afw.detection.FootprintSet` or `None`)
371 Negative polarity footprints.
372 (`lsst.afw.detection.FootprintSet` or `None`)
374 Number of footprints in positive or 0 if detection polarity was
377 Number of footprints in negative or 0 if detection polarity was
380 Re-estimated background. `None` or the input ``background``
381 if ``reEstimateBackground==False``.
382 (`lsst.afw.math.BackgroundList`)
384 Multiplication factor applied to the configured detection
387 Results from preliminary detection pass.
388 (`lsst.pipe.base.Struct`)
390 if backgroundToPhotometricRatio
is not None:
391 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
392 maskedImage = exposure.maskedImage
397 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
398 "DETECTED_NEGATIVE"])
399 nPix = maskedImage.mask.array.size
401 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
402 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.1f}% of total)".
403 format(nGoodPix, 100*nGoodPix/nPix))
409 psf = self.
getPsf(exposure, sigma=sigma)
410 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
412 if self.config.doBrightPrelimDetection:
415 middle = convolveResults.middle
416 sigma = convolveResults.sigma
418 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
419 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
422 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
423 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
425 if self.config.doBrightPrelimDetection:
428 maskedImage.mask.array |= brightDetectedMask
432 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
434 minMultiplicative = 0.5
435 if threshResults.multiplicative < minMultiplicative:
436 self.log.warning(
"threshResults.multiplicative = %.2f is less than minimum value (%.2f). "
437 "Setting to %.2f.", threshResults.multiplicative, minMultiplicative,
439 factor = max(minMultiplicative, threshResults.multiplicative)
440 self.log.info(
"Modifying configured detection threshold by factor %.2f to %.2f",
441 factor, factor*self.config.thresholdValue)
450 maskedImage.mask.array |= oldDetected
453 results = self.
applyThreshold(middle, maskedImage.getBBox(), factor)
454 results.prelim = prelim
455 results.background = background
if background
is not None else lsst.afw.math.BackgroundList()
456 if self.config.doTempLocalBackground:
459 growOverride=growOverride)
460 self.log.warning(
"nPeaks/nFootprint = %.2f (max is %.1f)",
461 results.numPosPeaks/results.numPos,
462 self.config.maxPeakToFootRatio)
463 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
464 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
469 if growOverride
is None:
470 growOverride = 0.75*self.config.nSigmaToGrow
473 self.log.warning(
"Decreasing nSigmaToGrow to %.2f", growOverride)
475 self.log.warning(
"New theshold value would be > 5 times the initially requested "
476 "one (%.2f > %.2f). Leaving inFinalize iteration without "
477 "getting the number of peaks per footprint below %.1f",
478 factor*self.config.thresholdValue, self.config.thresholdValue,
479 self.config.maxPeakToFootRatio)
483 self.log.warning(
"numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
484 "Increasing threshold factor to %.2f and re-running,",
485 results.numPosPeaks/results.numPos, self.config.maxPeakToFootRatio,
490 if self.config.reEstimateBackground:
493 self.
display(exposure, results, middle)
510 if self.config.doBackgroundTweak:
511 originalMask = maskedImage.mask.array.copy()
514 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
515 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
517 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
518 isBgTweak=
True).additive
520 maskedImage.mask.array[:] = originalMask
528 """Modify the background by a constant value
532 exposure : `lsst.afw.image.Exposure`
533 Exposure for which to tweak background.
535 Background level to remove
536 bgList : `lsst.afw.math.BackgroundList`, optional
537 List of backgrounds to append to.
541 bg : `lsst.afw.math.BackgroundMI`
542 Constant background model.
545 self.log.info(
"Tweaking background by %f to match sky photometry", bgLevel)
546 exposure.image -= bgLevel
547 bgStats = lsst.afw.image.MaskedImageF(1, 1)
548 bgStats.set(bgLevel, 0, bgLevel)
550 bgData = (bg, lsst.afw.math.Interpolate.LINEAR, lsst.afw.math.REDUCE_INTERP_ORDER,
551 lsst.afw.math.ApproximateControl.UNKNOWN, 0, 0,
False)
552 if bgList
is not None:
553 bgList.append(bgData)
557 """Perform an initial bright source detection pass.
559 Perform an initial bright object detection pass using a high detection
560 threshold. The footprints in this pass are grown significantly more
561 than is typical to account for wings around bright sources. The
562 negative polarity detections in this pass help in masking severely
563 over-subtracted regions.
565 A maximum fraction of masked pixel from this pass is ensured via
566 the config ``brightMaskFractionMax``. If the masked pixel fraction is
567 above this value, the detection thresholds here are increased by
568 ``bisectFactor`` in a while loop until the detected masked fraction
569 falls below this value.
573 maskedImage : `lsst.afw.image.MaskedImage`
574 Masked image on which to run the detection.
575 convolveResults : `lsst.pipe.base.Struct`
576 The results of the self.convolveImage function with attributes:
579 Convolved image, without the edges
580 (`lsst.afw.image.MaskedImage`).
582 Gaussian sigma used for the convolution (`float`).
586 brightDetectedMask : `numpy.ndarray`
587 Boolean array representing the union of the bright detection pass
588 DETECTED and DETECTED_NEGATIVE masks.
592 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
594 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
598 brightMaskFractionMax = self.config.brightMaskFractionMax
604 while nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
606 brightPosFactor *= self.config.bisectFactor
607 brightNegFactor *= self.config.bisectFactor
608 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
609 factor=brightPosFactor, factorNeg=brightNegFactor)
611 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
612 factor=brightPosFactor, factorNeg=brightNegFactor
615 nPix = maskedImage.mask.array.size
617 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
618 format(nPixDet, 100*nPixDet/nPix))
620 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
621 format(nPixDetNeg, 100*nPixDetNeg/nPix))
622 if nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
623 self.log.warn(
"Too high a fraction (%.1f > %.1f) of pixels were masked with current "
624 "\"bright\" detection round thresholds. Increasing by a factor of %.2f "
625 "and trying again.", max(nPixDetNeg, nPixDet)/nPix,
626 brightMaskFractionMax, self.config.bisectFactor)
630 brightDetectedMask = (maskedImage.mask.array
631 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
633 return brightDetectedMask