152 isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10):
153 """Calculate new threshold
155 This is the main functional addition to the vanilla
156 `SourceDetectionTask`.
158 We identify sky objects and perform forced PSF photometry on
159 them. Using those PSF flux measurements and estimated errors,
160 we set the threshold so that the stdev of the measurements
161 matches the median estimated error.
165 exposure : `lsst.afw.image.Exposure`
166 Exposure on which we're detecting sources.
168 RNG seed to use for finding sky objects.
169 sigma : `float`, optional
170 Gaussian sigma of smoothing kernel; if not provided,
171 will be deduced from the exposure's PSF.
172 minFractionSourcesFactor : `float`
173 Change the fraction of required sky sources from that set in
174 ``self.config.minFractionSources`` by this factor. NOTE: this
175 is intended for use in the background tweak pass (the detection
176 threshold is much lower there, so many more pixels end up marked
177 as DETECTED or DETECTED_NEGATIVE, leaving less room for sky
180 Set to ``True`` for the background tweak pass (for more helpful
182 nPixMaskErode : `int`, optional
183 Number of pixels by which to erode the detection masks on each
184 iteration of best-effort sky object placement.
185 maxMaskErodeIter : `int`, optional
186 Maximum number of iterations for the detection mask erosion.
190 result : `lsst.pipe.base.Struct`
191 Result struct with components:
194 Multiplicative factor to be applied to the
195 configured detection threshold (`float`).
197 Additive factor to be applied to the background
203 Raised if the number of good sky sources found is less than the
205 (``self.config.minFractionSources``*``minFractionSourcesFactor``)
206 of the number requested (``self.skyObjects.config.nSources``).
208 wcsIsNone = exposure.getWcs()
is None
210 self.log.info(
"WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
213 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
214 minNumSources = int(self.config.minFractionSources*self.skyObjects.config.nSources)
217 if minFractionSourcesFactor != 1.0:
218 minNumSources = max(3, int(minNumSources*minFractionSourcesFactor))
219 fp = self.skyObjects.
run(exposure.maskedImage.mask, seed)
221 if self.config.allowMaskErode:
222 detectedMaskPlanes = [
"DETECTED",
"DETECTED_NEGATIVE"]
223 mask = exposure.maskedImage.mask
224 for nIter
in range(maxMaskErodeIter):
226 fp = self.skyObjects.
run(mask, seed)
227 if len(fp) < int(2*minNumSources):
228 self.log.info(
"Current number of sky sources is below 2*minimum required "
229 "(%d < %d, allowing for some subsequent measurement failures). "
230 "Allowing erosion of detected mask planes for sky placement "
231 "nIter: %d [of %d max]",
232 len(fp), 2*minNumSources, nIter, maxMaskErodeIter)
233 if nPixMaskErode
is None:
236 elif len(fp) < int(0.75*minNumSources):
240 for maskName
in detectedMaskPlanes:
242 detectedMaskBit = mask.getPlaneBitMask(maskName)
243 detectedMaskSpanSet = SpanSet.fromMask(mask, detectedMaskBit)
244 detectedMaskSpanSet = detectedMaskSpanSet.eroded(nPixMaskErode)
246 detectedMask = mask.getMaskPlane(maskName)
247 mask.clearMaskPlane(detectedMask)
249 detectedMaskSpanSet.setMask(mask, detectedMaskBit)
254 skyFootprints.setFootprints(fp)
257 catalog.reserve(len(skyFootprints.getFootprints()))
258 skyFootprints.makeSources(catalog)
259 key = catalog.getCentroidSlot().getMeasKey()
260 for source
in catalog:
261 peaks = source.getFootprint().getPeaks()
262 assert len(peaks) == 1
263 source.set(key, peaks[0].getF())
265 source.updateCoord(exposure.getWcs(), include_covariance=
False)
271 fluxes = catalog[
"base_PsfFlux_instFlux"]
272 area = catalog[
"base_PsfFlux_area"]
273 bg = catalog[
"base_LocalBackground_instFlux"]
275 good = (~catalog[
"base_PsfFlux_flag"] & ~catalog[
"base_LocalBackground_flag"]
276 & np.isfinite(fluxes) & np.isfinite(area) & np.isfinite(bg))
278 if good.sum() < minNumSources:
280 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
281 f
"{minNumSources}) for dynamic threshold calculation.")
283 msg = (f
"Insufficient good sky source flux measurements ({good.sum()} < "
284 f
"{minNumSources}) for background tweak calculation.")
286 nPix = exposure.mask.array.size
288 nGoodPix = np.sum(exposure.mask.array & badPixelMask == 0)
289 if nGoodPix/nPix > 0.2:
291 nDetectedPix = np.sum(exposure.mask.array & detectedPixelMask != 0)
292 msg += (f
" However, {nGoodPix}/{nPix} pixels are not marked NO_DATA or BAD, "
293 "so there should be sufficient area to locate suitable sky sources. "
294 f
"Note that {nDetectedPix} of {nGoodPix} \"good\" pixels were marked "
295 "as DETECTED or DETECTED_NEGATIVE.")
300 self.log.info(
"Number of good sky sources used for dynamic detection: %d (of %d requested).",
301 good.sum(), self.skyObjects.config.nSources)
303 self.log.info(
"Number of good sky sources used for dynamic detection background tweak:"
304 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
305 bgMedian = np.median((fluxes/area)[good])
307 lq, uq = np.percentile((fluxes - bg*area)[good], [25.0, 75.0])
308 stdevMeas = 0.741*(uq - lq)
309 medianError = np.median(catalog[
"base_PsfFlux_instFluxErr"][good])
311 exposure.setWcs(
None)
312 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
314 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
315 background=None, backgroundToPhotometricRatio=None):
316 """Detect footprints with a dynamic threshold
318 This varies from the vanilla ``detectFootprints`` method because we
319 do detection three times: first with a high threshold to detect
320 "bright" (both positive and negative, the latter to identify very
321 over-subtracted regions) sources for which we grow the DETECTED and
322 DETECTED_NEGATIVE masks significantly to account for wings. Second,
323 with a low threshold to mask all non-empty regions of the image. These
324 two masks are combined and used to identify regions of sky
325 uncontaminated by objects. A final round of detection is then done
326 with the new calculated threshold.
330 exposure : `lsst.afw.image.Exposure`
331 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
333 doSmooth : `bool`, optional
334 If True, smooth the image before detection using a Gaussian
336 sigma : `float`, optional
337 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
338 detections; if `None` then measure the sigma of the PSF of the
340 clearMask : `bool`, optional
341 Clear both DETECTED and DETECTED_NEGATIVE planes before running
343 expId : `int`, optional
344 Exposure identifier, used as a seed for the random number
345 generator. If absent, the seed will be the sum of the image.
346 background : `lsst.afw.math.BackgroundList`, optional
347 Background that was already subtracted from the exposure; will be
348 modified in-place if ``reEstimateBackground=True``.
349 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
350 Unused; if set will Raise.
354 results : `lsst.pipe.base.Struct`
355 The results `~lsst.pipe.base.Struct` contains:
358 Positive polarity footprints.
359 (`lsst.afw.detection.FootprintSet` or `None`)
361 Negative polarity footprints.
362 (`lsst.afw.detection.FootprintSet` or `None`)
364 Number of footprints in positive or 0 if detection polarity was
367 Number of footprints in negative or 0 if detection polarity was
370 Re-estimated background. `None` or the input ``background``
371 if ``reEstimateBackground==False``.
372 (`lsst.afw.math.BackgroundList`)
374 Multiplication factor applied to the configured detection
377 Results from preliminary detection pass.
378 (`lsst.pipe.base.Struct`)
380 if backgroundToPhotometricRatio
is not None:
381 raise RuntimeError(
"DynamicDetectionTask does not support backgroundToPhotometricRatio.")
382 maskedImage = exposure.maskedImage
387 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask([
"DETECTED",
388 "DETECTED_NEGATIVE"])
389 nPix = maskedImage.mask.array.size
391 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
392 self.log.info(
"Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.1f}% of total)".
393 format(nGoodPix, 100*nGoodPix/nPix))
399 psf = self.
getPsf(exposure, sigma=sigma)
400 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
402 if self.config.doBrightPrelimDetection:
405 middle = convolveResults.middle
406 sigma = convolveResults.sigma
408 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
409 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
412 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
413 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
415 if self.config.doBrightPrelimDetection:
418 maskedImage.mask.array |= brightDetectedMask
422 seed = (expId
if expId
is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
424 factor = threshResults.multiplicative
425 self.log.info(
"Modifying configured detection threshold by factor %f to %f",
426 factor, factor*self.config.thresholdValue)
431 maskedImage.mask.array |= oldDetected
434 results = self.
applyThreshold(middle, maskedImage.getBBox(), factor)
435 results.prelim = prelim
436 results.background = background
if background
is not None else lsst.afw.math.BackgroundList()
437 if self.config.doTempLocalBackground:
443 if self.config.reEstimateBackground:
446 self.
display(exposure, results, middle)
448 if self.config.doBackgroundTweak:
457 originalMask = maskedImage.mask.array.copy()
460 convolveResults = self.
convolveImage(maskedImage, psf, doSmooth=doSmooth)
461 tweakDetResults = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
463 bgLevel = self.
calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
464 isBgTweak=
True).additive
466 maskedImage.mask.array[:] = originalMask
472 """Modify the background by a constant value
476 exposure : `lsst.afw.image.Exposure`
477 Exposure for which to tweak background.
479 Background level to remove
480 bgList : `lsst.afw.math.BackgroundList`, optional
481 List of backgrounds to append to.
485 bg : `lsst.afw.math.BackgroundMI`
486 Constant background model.
488 self.log.info(
"Tweaking background by %f to match sky photometry", bgLevel)
489 exposure.image -= bgLevel
490 bgStats = lsst.afw.image.MaskedImageF(1, 1)
491 bgStats.set(bgLevel, 0, bgLevel)
493 bgData = (bg, lsst.afw.math.Interpolate.LINEAR, lsst.afw.math.REDUCE_INTERP_ORDER,
494 lsst.afw.math.ApproximateControl.UNKNOWN, 0, 0,
False)
495 if bgList
is not None:
496 bgList.append(bgData)
500 """Perform an initial bright source detection pass.
502 Perform an initial bright object detection pass using a high detection
503 threshold. The footprints in this pass are grown significantly more
504 than is typical to account for wings around bright sources. The
505 negative polarity detections in this pass help in masking severely
506 over-subtracted regions.
508 A maximum fraction of masked pixel from this pass is ensured via
509 the config ``brightMaskFractionMax``. If the masked pixel fraction is
510 above this value, the detection thresholds here are increased by
511 ``bisectFactor`` in a while loop until the detected masked fraction
512 falls below this value.
516 maskedImage : `lsst.afw.image.MaskedImage`
517 Masked image on which to run the detection.
518 convolveResults : `lsst.pipe.base.Struct`
519 The results of the self.convolveImage function with attributes:
522 Convolved image, without the edges
523 (`lsst.afw.image.MaskedImage`).
525 Gaussian sigma used for the convolution (`float`).
529 brightDetectedMask : `numpy.ndarray`
530 Boolean array representing the union of the bright detection pass
531 DETECTED and DETECTED_NEGATIVE masks.
535 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
537 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
541 brightMaskFractionMax = self.config.brightMaskFractionMax
547 while nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
549 brightPosFactor *= self.config.bisectFactor
550 brightNegFactor *= self.config.bisectFactor
551 prelimBright = self.
applyThreshold(convolveResults.middle, maskedImage.getBBox(),
552 factor=brightPosFactor, factorNeg=brightNegFactor)
554 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
555 factor=brightPosFactor, factorNeg=brightNegFactor
558 nPix = maskedImage.mask.array.size
560 self.log.info(
"Number (%) of bright DETECTED pix: {} ({:.1f}%)".
561 format(nPixDet, 100*nPixDet/nPix))
563 self.log.info(
"Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
564 format(nPixDetNeg, 100*nPixDetNeg/nPix))
565 if nPixDetNeg/nPix > brightMaskFractionMax
or nPixDet/nPix > brightMaskFractionMax:
566 self.log.warn(
"Too high a fraction (%.1f > %.1f) of pixels were masked with current "
567 "\"bright\" detection round thresholds. Increasing by a factor of %f "
568 "and trying again.", max(nPixDetNeg, nPixDet)/nPix,
569 brightMaskFractionMax, self.config.bisectFactor)
573 brightDetectedMask = (maskedImage.mask.array
574 & maskedImage.mask.getPlaneBitMask([
"DETECTED",
"DETECTED_NEGATIVE"]))
576 return brightDetectedMask