Coverage for python/lsst/meas/algorithms/detection.py: 14%
320 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-25 00:22 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-25 00:22 -0700
1#
2# LSST Data Management System
3#
4# Copyright 2008-2017 AURA/LSST.
5#
6# This product includes software developed by the
7# LSST Project (http://www.lsst.org/).
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the LSST License Statement and
20# the GNU General Public License along with this program. If not,
21# see <https://www.lsstcorp.org/LegalNotices/>.
22#
24__all__ = ("SourceDetectionConfig", "SourceDetectionTask", "addExposures")
26from contextlib import contextmanager
28import numpy as np
30import lsst.geom
31import lsst.afw.display as afwDisplay
32import lsst.afw.detection as afwDet
33import lsst.afw.geom as afwGeom
34import lsst.afw.image as afwImage
35import lsst.afw.math as afwMath
36import lsst.afw.table as afwTable
37import lsst.pex.config as pexConfig
38import lsst.pipe.base as pipeBase
39from lsst.utils.timer import timeMethod
40from .subtractBackground import SubtractBackgroundTask
43class SourceDetectionConfig(pexConfig.Config):
44 """Configuration parameters for the SourceDetectionTask
45 """
46 minPixels = pexConfig.RangeField(
47 doc="detected sources with fewer than the specified number of pixels will be ignored",
48 dtype=int, optional=False, default=1, min=0,
49 )
50 isotropicGrow = pexConfig.Field(
51 doc="Pixels should be grown as isotropically as possible (slower)",
52 dtype=bool, optional=False, default=False,
53 )
54 combinedGrow = pexConfig.Field(
55 doc="Grow all footprints at the same time? This allows disconnected footprints to merge.",
56 dtype=bool, default=True,
57 )
58 nSigmaToGrow = pexConfig.Field(
59 doc="Grow detections by nSigmaToGrow * [PSF RMS width]; if 0 then do not grow",
60 dtype=float, default=2.4, # 2.4 pixels/sigma is roughly one pixel/FWHM
61 )
62 returnOriginalFootprints = pexConfig.Field(
63 doc="Grow detections to set the image mask bits, but return the original (not-grown) footprints",
64 dtype=bool, optional=False, default=False,
65 )
66 thresholdValue = pexConfig.RangeField(
67 doc="Threshold for footprints; exact meaning and units depend on thresholdType.",
68 dtype=float, optional=False, default=5.0, min=0.0,
69 )
70 includeThresholdMultiplier = pexConfig.RangeField(
71 doc="Include threshold relative to thresholdValue",
72 dtype=float, default=1.0, min=0.0,
73 )
74 thresholdType = pexConfig.ChoiceField(
75 doc="specifies the desired flavor of Threshold",
76 dtype=str, optional=False, default="stdev",
77 allowed={
78 "variance": "threshold applied to image variance",
79 "stdev": "threshold applied to image std deviation",
80 "value": "threshold applied to image value",
81 "pixel_stdev": "threshold applied to per-pixel std deviation",
82 },
83 )
84 thresholdPolarity = pexConfig.ChoiceField(
85 doc="specifies whether to detect positive, or negative sources, or both",
86 dtype=str, optional=False, default="positive",
87 allowed={
88 "positive": "detect only positive sources",
89 "negative": "detect only negative sources",
90 "both": "detect both positive and negative sources",
91 },
92 )
93 adjustBackground = pexConfig.Field(
94 dtype=float,
95 doc="Fiddle factor to add to the background; debugging only",
96 default=0.0,
97 )
98 reEstimateBackground = pexConfig.Field(
99 dtype=bool,
100 doc="Estimate the background again after final source detection?",
101 default=True, optional=False,
102 )
103 background = pexConfig.ConfigurableField(
104 doc="Background re-estimation; ignored if reEstimateBackground false",
105 target=SubtractBackgroundTask,
106 )
107 tempLocalBackground = pexConfig.ConfigurableField(
108 doc=("A local (small-scale), temporary background estimation step run between "
109 "detecting above-threshold regions and detecting the peaks within "
110 "them; used to avoid detecting spuerious peaks in the wings."),
111 target=SubtractBackgroundTask,
112 )
113 doTempLocalBackground = pexConfig.Field(
114 dtype=bool,
115 doc="Enable temporary local background subtraction? (see tempLocalBackground)",
116 default=True,
117 )
118 tempWideBackground = pexConfig.ConfigurableField(
119 doc=("A wide (large-scale) background estimation and removal before footprint and peak detection. "
120 "It is added back into the image after detection. The purpose is to suppress very large "
121 "footprints (e.g., from large artifacts) that the deblender may choke on."),
122 target=SubtractBackgroundTask,
123 )
124 doTempWideBackground = pexConfig.Field(
125 dtype=bool,
126 doc="Do temporary wide (large-scale) background subtraction before footprint detection?",
127 default=False,
128 )
129 nPeaksMaxSimple = pexConfig.Field(
130 dtype=int,
131 doc=("The maximum number of peaks in a Footprint before trying to "
132 "replace its peaks using the temporary local background"),
133 default=1,
134 )
135 nSigmaForKernel = pexConfig.Field(
136 dtype=float,
137 doc=("Multiple of PSF RMS size to use for convolution kernel bounding box size; "
138 "note that this is not a half-size. The size will be rounded up to the nearest odd integer"),
139 default=7.0,
140 )
141 statsMask = pexConfig.ListField(
142 dtype=str,
143 doc="Mask planes to ignore when calculating statistics of image (for thresholdType=stdev)",
144 default=['BAD', 'SAT', 'EDGE', 'NO_DATA'],
145 )
147 def setDefaults(self):
148 self.tempLocalBackground.binSize = 64
149 self.tempLocalBackground.algorithm = "AKIMA_SPLINE"
150 self.tempLocalBackground.useApprox = False
151 # Background subtraction to remove a large-scale background (e.g., scattered light); restored later.
152 # Want to keep it from exceeding the deblender size limit of 1 Mpix, so half that is reasonable.
153 self.tempWideBackground.binSize = 512
154 self.tempWideBackground.algorithm = "AKIMA_SPLINE"
155 self.tempWideBackground.useApprox = False
156 # Ensure we can remove even bright scattered light that is DETECTED
157 for maskPlane in ("DETECTED", "DETECTED_NEGATIVE"):
158 if maskPlane in self.tempWideBackground.ignoredPixelMask:
159 self.tempWideBackground.ignoredPixelMask.remove(maskPlane)
162class SourceDetectionTask(pipeBase.Task):
163 """Create the detection task. Most arguments are simply passed onto pipe.base.Task.
165 Parameters
166 ----------
167 schema : `lsst.afw.table.Schema`
168 Schema object used to create the output `lsst.afw.table.SourceCatalog`
169 **kwds
170 Keyword arguments passed to `lsst.pipe.base.task.Task.__init__`
172 If schema is not None and configured for 'both' detections,
173 a 'flags.negative' field will be added to label detections made with a
174 negative threshold.
176 Notes
177 -----
178 This task can add fields to the schema, so any code calling this task must ensure that
179 these columns are indeed present in the input match list.
180 """
182 ConfigClass = SourceDetectionConfig
183 _DefaultName = "sourceDetection"
185 def __init__(self, schema=None, **kwds):
186 pipeBase.Task.__init__(self, **kwds)
187 if schema is not None and self.config.thresholdPolarity == "both":
188 self.negativeFlagKey = schema.addField(
189 "flags_negative", type="Flag",
190 doc="set if source was detected as significantly negative"
191 )
192 else:
193 if self.config.thresholdPolarity == "both":
194 self.log.warning("Detection polarity set to 'both', but no flag will be "
195 "set to distinguish between positive and negative detections")
196 self.negativeFlagKey = None
197 if self.config.reEstimateBackground:
198 self.makeSubtask("background")
199 if self.config.doTempLocalBackground:
200 self.makeSubtask("tempLocalBackground")
201 if self.config.doTempWideBackground:
202 self.makeSubtask("tempWideBackground")
204 @timeMethod
205 def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
206 """Run source detection and create a SourceCatalog of detections.
208 Parameters
209 ----------
210 table : `lsst.afw.table.SourceTable`
211 Table object that will be used to create the SourceCatalog.
212 exposure : `lsst.afw.image.Exposure`
213 Exposure to process; DETECTED mask plane will be set in-place.
214 doSmooth : `bool`
215 If True, smooth the image before detection using a Gaussian of width
216 ``sigma``, or the measured PSF width. Set to False when running on
217 e.g. a pre-convolved image, or a mask plane.
218 sigma : `float`
219 Sigma of PSF (pixels); used for smoothing and to grow detections;
220 if None then measure the sigma of the PSF of the exposure
221 clearMask : `bool`
222 Clear DETECTED{,_NEGATIVE} planes before running detection.
223 expId : `int`
224 Exposure identifier; unused by this implementation, but used for
225 RNG seed by subclasses.
227 Returns
228 -------
229 result : `lsst.pipe.base.Struct`
230 ``sources``
231 The detected sources (`lsst.afw.table.SourceCatalog`)
232 ``fpSets``
233 The result resturned by `detectFootprints`
234 (`lsst.pipe.base.Struct`).
236 Raises
237 ------
238 ValueError
239 If flags.negative is needed, but isn't in table's schema.
240 lsst.pipe.base.TaskError
241 If sigma=None, doSmooth=True and the exposure has no PSF.
243 Notes
244 -----
245 If you want to avoid dealing with Sources and Tables, you can use
246 detectFootprints() to just get the `lsst.afw.detection.FootprintSet`s.
247 """
248 if self.negativeFlagKey is not None and self.negativeFlagKey not in table.getSchema():
249 raise ValueError("Table has incorrect Schema")
250 results = self.detectFootprints(exposure=exposure, doSmooth=doSmooth, sigma=sigma,
251 clearMask=clearMask, expId=expId)
252 sources = afwTable.SourceCatalog(table)
253 sources.reserve(results.numPos + results.numNeg)
254 if results.negative:
255 results.negative.makeSources(sources)
256 if self.negativeFlagKey:
257 for record in sources:
258 record.set(self.negativeFlagKey, True)
259 if results.positive:
260 results.positive.makeSources(sources)
261 results.fpSets = results.copy() # Backward compatibility
262 results.sources = sources
263 return results
265 def display(self, exposure, results, convolvedImage=None):
266 """Display detections if so configured
268 Displays the ``exposure`` in frame 0, overlays the detection peaks.
270 Requires that ``lsstDebug`` has been set up correctly, so that
271 ``lsstDebug.Info("lsst.meas.algorithms.detection")`` evaluates `True`.
273 If the ``convolvedImage`` is non-`None` and
274 ``lsstDebug.Info("lsst.meas.algorithms.detection") > 1``, the
275 ``convolvedImage`` will be displayed in frame 1.
277 Parameters
278 ----------
279 exposure : `lsst.afw.image.Exposure`
280 Exposure to display, on which will be plotted the detections.
281 results : `lsst.pipe.base.Struct`
282 Results of the 'detectFootprints' method, containing positive and
283 negative footprints (which contain the peak positions that we will
284 plot). This is a `Struct` with ``positive`` and ``negative``
285 elements that are of type `lsst.afw.detection.FootprintSet`.
286 convolvedImage : `lsst.afw.image.Image`, optional
287 Convolved image used for thresholding.
288 """
289 try:
290 import lsstDebug
291 display = lsstDebug.Info(__name__).display
292 except ImportError:
293 try:
294 display
295 except NameError:
296 display = False
297 if not display:
298 return
300 afwDisplay.setDefaultMaskTransparency(75)
302 disp0 = afwDisplay.Display(frame=0)
303 disp0.mtv(exposure, title="detection")
305 def plotPeaks(fps, ctype):
306 if fps is None:
307 return
308 with disp0.Buffering():
309 for fp in fps.getFootprints():
310 for pp in fp.getPeaks():
311 disp0.dot("+", pp.getFx(), pp.getFy(), ctype=ctype)
312 plotPeaks(results.positive, "yellow")
313 plotPeaks(results.negative, "red")
315 if convolvedImage and display > 1:
316 disp1 = afwDisplay.Display(frame=1)
317 disp1.mtv(convolvedImage, title="PSF smoothed")
319 disp2 = afwDisplay.Display(frame=2)
320 disp2.mtv(afwImage.ImageF(np.sqrt(exposure.variance.array)), title="stddev")
322 def applyTempLocalBackground(self, exposure, middle, results):
323 """Apply a temporary local background subtraction
325 This temporary local background serves to suppress noise fluctuations
326 in the wings of bright objects.
328 Peaks in the footprints will be updated.
330 Parameters
331 ----------
332 exposure : `lsst.afw.image.Exposure`
333 Exposure for which to fit local background.
334 middle : `lsst.afw.image.MaskedImage`
335 Convolved image on which detection will be performed
336 (typically smaller than ``exposure`` because the
337 half-kernel has been removed around the edges).
338 results : `lsst.pipe.base.Struct`
339 Results of the 'detectFootprints' method, containing positive and
340 negative footprints (which contain the peak positions that we will
341 plot). This is a `Struct` with ``positive`` and ``negative``
342 elements that are of type `lsst.afw.detection.FootprintSet`.
343 """
344 # Subtract the local background from the smoothed image. Since we
345 # never use the smoothed again we don't need to worry about adding
346 # it back in.
347 bg = self.tempLocalBackground.fitBackground(exposure.getMaskedImage())
348 bgImage = bg.getImageF(self.tempLocalBackground.config.algorithm,
349 self.tempLocalBackground.config.undersampleStyle)
350 middle -= bgImage.Factory(bgImage, middle.getBBox())
351 if self.config.thresholdPolarity != "negative":
352 results.positiveThreshold = self.makeThreshold(middle, "positive")
353 self.updatePeaks(results.positive, middle, results.positiveThreshold)
354 if self.config.thresholdPolarity != "positive":
355 results.negativeThreshold = self.makeThreshold(middle, "negative")
356 self.updatePeaks(results.negative, middle, results.negativeThreshold)
358 def clearMask(self, mask):
359 """Clear the DETECTED and DETECTED_NEGATIVE mask planes
361 Removes any previous detection mask in preparation for a new
362 detection pass.
364 Parameters
365 ----------
366 mask : `lsst.afw.image.Mask`
367 Mask to be cleared.
368 """
369 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
371 def calculateKernelSize(self, sigma):
372 """Calculate size of smoothing kernel
374 Uses the ``nSigmaForKernel`` configuration parameter. Note
375 that that is the full width of the kernel bounding box
376 (so a value of 7 means 3.5 sigma on either side of center).
377 The value will be rounded up to the nearest odd integer.
379 Parameters
380 ----------
381 sigma : `float`
382 Gaussian sigma of smoothing kernel.
384 Returns
385 -------
386 size : `int`
387 Size of the smoothing kernel.
388 """
389 return (int(sigma * self.config.nSigmaForKernel + 0.5)//2)*2 + 1 # make sure it is odd
391 def getPsf(self, exposure, sigma=None):
392 """Retrieve the PSF for an exposure
394 If ``sigma`` is provided, we make a ``GaussianPsf`` with that,
395 otherwise use the one from the ``exposure``.
397 Parameters
398 ----------
399 exposure : `lsst.afw.image.Exposure`
400 Exposure from which to retrieve the PSF.
401 sigma : `float`, optional
402 Gaussian sigma to use if provided.
404 Returns
405 -------
406 psf : `lsst.afw.detection.Psf`
407 PSF to use for detection.
408 """
409 if sigma is None:
410 psf = exposure.getPsf()
411 if psf is None:
412 raise RuntimeError("Unable to determine PSF to use for detection: no sigma provided")
413 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
414 size = self.calculateKernelSize(sigma)
415 psf = afwDet.GaussianPsf(size, size, sigma)
416 return psf
418 def convolveImage(self, maskedImage, psf, doSmooth=True):
419 """Convolve the image with the PSF
421 We convolve the image with a Gaussian approximation to the PSF,
422 because this is separable and therefore fast. It's technically a
423 correlation rather than a convolution, but since we use a symmetric
424 Gaussian there's no difference.
426 The convolution can be disabled with ``doSmooth=False``. If we do
427 convolve, we mask the edges as ``EDGE`` and return the convolved image
428 with the edges removed. This is because we can't convolve the edges
429 because the kernel would extend off the image.
431 Parameters
432 ----------
433 maskedImage : `lsst.afw.image.MaskedImage`
434 Image to convolve.
435 psf : `lsst.afw.detection.Psf`
436 PSF to convolve with (actually with a Gaussian approximation
437 to it).
438 doSmooth : `bool`
439 Actually do the convolution? Set to False when running on
440 e.g. a pre-convolved image, or a mask plane.
442 Return Struct contents
443 ----------------------
444 middle : `lsst.afw.image.MaskedImage`
445 Convolved image, without the edges.
446 sigma : `float`
447 Gaussian sigma used for the convolution.
448 """
449 self.metadata["doSmooth"] = doSmooth
450 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
451 self.metadata["sigma"] = sigma
453 if not doSmooth:
454 middle = maskedImage.Factory(maskedImage, deep=True)
455 return pipeBase.Struct(middle=middle, sigma=sigma)
457 # Smooth using a Gaussian (which is separable, hence fast) of width sigma
458 # Make a SingleGaussian (separable) kernel with the 'sigma'
459 kWidth = self.calculateKernelSize(sigma)
460 self.metadata["smoothingKernelWidth"] = kWidth
461 gaussFunc = afwMath.GaussianFunction1D(sigma)
462 gaussKernel = afwMath.SeparableKernel(kWidth, kWidth, gaussFunc, gaussFunc)
464 convolvedImage = maskedImage.Factory(maskedImage.getBBox())
466 afwMath.convolve(convolvedImage, maskedImage, gaussKernel, afwMath.ConvolutionControl())
467 #
468 # Only search psf-smoothed part of frame
469 #
470 goodBBox = gaussKernel.shrinkBBox(convolvedImage.getBBox())
471 middle = convolvedImage.Factory(convolvedImage, goodBBox, afwImage.PARENT, False)
472 #
473 # Mark the parts of the image outside goodBBox as EDGE
474 #
475 self.setEdgeBits(maskedImage, goodBBox, maskedImage.getMask().getPlaneBitMask("EDGE"))
477 return pipeBase.Struct(middle=middle, sigma=sigma)
479 def applyThreshold(self, middle, bbox, factor=1.0, factorNeg=None):
480 r"""Apply thresholds to the convolved image
482 The threshold can be modified by the provided multiplication
483 ``factor``.
485 Parameters
486 ----------
487 middle : `lsst.afw.image.MaskedImage`
488 Convolved image to threshold.
489 bbox : `lsst.geom.Box2I`
490 Bounding box of unconvolved image.
491 factor : `float`
492 Multiplier for the configured threshold.
493 factorNeg : `float` or `None`
494 Multiplier for the configured threshold for negative detection polarity.
495 If `None`, will be set equal to ``factor`` (i.e. equal to the factor used
496 for positive detection polarity).
498 Returns
499 -------
500 results : `lsst.pipe.base.Struct`
501 The `~lsst.pipe.base.Struct` contains:
503 ``positive``
504 Positive detection footprints, if configured.
505 (`lsst.afw.detection.FootprintSet` or `None`)
506 ``negative``
507 Negative detection footprints, if configured.
508 (`lsst.afw.detection.FootprintSet` or `None`)
509 ``factor``
510 Multiplier for the configured threshold.
511 (`float`)
512 ``factorNeg``
513 Multiplier for the configured threshold for negative detection polarity.
514 (`float`)
515 """
516 if factorNeg is None:
517 factorNeg = factor
518 self.log.info("Setting factor for negative detections equal to that for positive "
519 "detections: %f", factor)
520 results = pipeBase.Struct(positive=None, negative=None, factor=factor, factorNeg=factorNeg,
521 positiveThreshold=None, negativeThreshold=None)
522 # Detect the Footprints (peaks may be replaced if doTempLocalBackground)
523 if self.config.reEstimateBackground or self.config.thresholdPolarity != "negative":
524 results.positiveThreshold = self.makeThreshold(middle, "positive", factor=factor)
525 results.positive = afwDet.FootprintSet(
526 middle,
527 results.positiveThreshold,
528 "DETECTED",
529 self.config.minPixels
530 )
531 results.positive.setRegion(bbox)
532 if self.config.reEstimateBackground or self.config.thresholdPolarity != "positive":
533 results.negativeThreshold = self.makeThreshold(middle, "negative", factor=factorNeg)
534 results.negative = afwDet.FootprintSet(
535 middle,
536 results.negativeThreshold,
537 "DETECTED_NEGATIVE",
538 self.config.minPixels
539 )
540 results.negative.setRegion(bbox)
542 return results
544 def finalizeFootprints(self, mask, results, sigma, factor=1.0, factorNeg=None):
545 """Finalize the detected footprints.
547 Grows the footprints, sets the ``DETECTED`` and ``DETECTED_NEGATIVE``
548 mask planes, and logs the results.
550 ``numPos`` (number of positive footprints), ``numPosPeaks`` (number
551 of positive peaks), ``numNeg`` (number of negative footprints),
552 ``numNegPeaks`` (number of negative peaks) entries are added to the
553 detection results.
555 Parameters
556 ----------
557 mask : `lsst.afw.image.Mask`
558 Mask image on which to flag detected pixels.
559 results : `lsst.pipe.base.Struct`
560 Struct of detection results, including ``positive`` and
561 ``negative`` entries; modified.
562 sigma : `float`
563 Gaussian sigma of PSF.
564 factor : `float`
565 Multiplier for the configured threshold. Note that this is only
566 used here for logging purposes.
567 factorNeg : `float` or `None`
568 Multiplier used for the negative detection polarity threshold.
569 If `None`, a factor equal to ``factor`` (i.e. equal to the one used
570 for positive detection polarity) is assumed. Note that this is only
571 used here for logging purposes.
572 """
573 factorNeg = factor if factorNeg is None else factorNeg
574 for polarity, maskName in (("positive", "DETECTED"), ("negative", "DETECTED_NEGATIVE")):
575 fpSet = getattr(results, polarity)
576 if fpSet is None:
577 continue
578 if self.config.nSigmaToGrow > 0:
579 nGrow = int((self.config.nSigmaToGrow * sigma) + 0.5)
580 self.metadata["nGrow"] = nGrow
581 if self.config.combinedGrow:
582 fpSet = afwDet.FootprintSet(fpSet, nGrow, self.config.isotropicGrow)
583 else:
584 stencil = (afwGeom.Stencil.CIRCLE if self.config.isotropicGrow else
585 afwGeom.Stencil.MANHATTAN)
586 for fp in fpSet:
587 fp.dilate(nGrow, stencil)
588 fpSet.setMask(mask, maskName)
589 if not self.config.returnOriginalFootprints:
590 setattr(results, polarity, fpSet)
592 results.numPos = 0
593 results.numPosPeaks = 0
594 results.numNeg = 0
595 results.numNegPeaks = 0
596 positive = ""
597 negative = ""
599 if results.positive is not None:
600 results.numPos = len(results.positive.getFootprints())
601 results.numPosPeaks = sum(len(fp.getPeaks()) for fp in results.positive.getFootprints())
602 positive = " %d positive peaks in %d footprints" % (results.numPosPeaks, results.numPos)
603 if results.negative is not None:
604 results.numNeg = len(results.negative.getFootprints())
605 results.numNegPeaks = sum(len(fp.getPeaks()) for fp in results.negative.getFootprints())
606 negative = " %d negative peaks in %d footprints" % (results.numNegPeaks, results.numNeg)
608 self.log.info("Detected%s%s%s to %g +ve and %g -ve %s",
609 positive, " and" if positive and negative else "", negative,
610 self.config.thresholdValue*self.config.includeThresholdMultiplier*factor,
611 self.config.thresholdValue*self.config.includeThresholdMultiplier*factorNeg,
612 "DN" if self.config.thresholdType == "value" else "sigma")
614 def reEstimateBackground(self, maskedImage, backgrounds):
615 """Estimate the background after detection
617 Parameters
618 ----------
619 maskedImage : `lsst.afw.image.MaskedImage`
620 Image on which to estimate the background.
621 backgrounds : `lsst.afw.math.BackgroundList`
622 List of backgrounds; modified.
624 Returns
625 -------
626 bg : `lsst.afw.math.backgroundMI`
627 Empirical background model.
628 """
629 bg = self.background.fitBackground(maskedImage)
630 if self.config.adjustBackground:
631 self.log.warning("Fiddling the background by %g", self.config.adjustBackground)
632 bg += self.config.adjustBackground
633 self.log.info("Resubtracting the background after object detection")
634 maskedImage -= bg.getImageF(self.background.config.algorithm,
635 self.background.config.undersampleStyle)
637 actrl = bg.getBackgroundControl().getApproximateControl()
638 backgrounds.append((bg, getattr(afwMath.Interpolate, self.background.config.algorithm),
639 bg.getAsUsedUndersampleStyle(), actrl.getStyle(), actrl.getOrderX(),
640 actrl.getOrderY(), actrl.getWeighting()))
641 return bg
643 def clearUnwantedResults(self, mask, results):
644 """Clear unwanted results from the Struct of results
646 If we specifically want only positive or only negative detections,
647 drop the ones we don't want, and its associated mask plane.
649 Parameters
650 ----------
651 mask : `lsst.afw.image.Mask`
652 Mask image.
653 results : `lsst.pipe.base.Struct`
654 Detection results, with ``positive`` and ``negative`` elements;
655 modified.
656 """
657 if self.config.thresholdPolarity == "positive":
658 if self.config.reEstimateBackground:
659 mask &= ~mask.getPlaneBitMask("DETECTED_NEGATIVE")
660 results.negative = None
661 elif self.config.thresholdPolarity == "negative":
662 if self.config.reEstimateBackground:
663 mask &= ~mask.getPlaneBitMask("DETECTED")
664 results.positive = None
666 @timeMethod
667 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
668 """Detect footprints on an exposure.
670 Parameters
671 ----------
672 exposure : `lsst.afw.image.Exposure`
673 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
674 set in-place.
675 doSmooth : `bool`, optional
676 If True, smooth the image before detection using a Gaussian
677 of width ``sigma``, or the measured PSF width of ``exposure``.
678 Set to False when running on e.g. a pre-convolved image, or a mask
679 plane.
680 sigma : `float`, optional
681 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
682 detections; if `None` then measure the sigma of the PSF of the
683 ``exposure``.
684 clearMask : `bool`, optional
685 Clear both DETECTED and DETECTED_NEGATIVE planes before running
686 detection.
687 expId : `dict`, optional
688 Exposure identifier; unused by this implementation, but used for
689 RNG seed by subclasses.
691 Return Struct contents
692 ----------------------
693 positive : `lsst.afw.detection.FootprintSet`
694 Positive polarity footprints (may be `None`)
695 negative : `lsst.afw.detection.FootprintSet`
696 Negative polarity footprints (may be `None`)
697 numPos : `int`
698 Number of footprints in positive or 0 if detection polarity was
699 negative.
700 numNeg : `int`
701 Number of footprints in negative or 0 if detection polarity was
702 positive.
703 background : `lsst.afw.math.BackgroundList`
704 Re-estimated background. `None` if
705 ``reEstimateBackground==False``.
706 factor : `float`
707 Multiplication factor applied to the configured detection
708 threshold.
709 """
710 maskedImage = exposure.maskedImage
712 if clearMask:
713 self.clearMask(maskedImage.getMask())
715 psf = self.getPsf(exposure, sigma=sigma)
716 with self.tempWideBackgroundContext(exposure):
717 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
718 middle = convolveResults.middle
719 sigma = convolveResults.sigma
721 results = self.applyThreshold(middle, maskedImage.getBBox())
722 results.background = afwMath.BackgroundList()
723 if self.config.doTempLocalBackground:
724 self.applyTempLocalBackground(exposure, middle, results)
725 self.finalizeFootprints(maskedImage.mask, results, sigma)
727 # Compute the significance of peaks after the peaks have been
728 # finalized and after local background correction/updatePeaks, so
729 # that the significance represents the "final" detection S/N.
730 results.positive = self.setPeakSignificance(middle, results.positive, results.positiveThreshold)
731 results.negative = self.setPeakSignificance(middle, results.negative, results.negativeThreshold,
732 negative=True)
734 if self.config.reEstimateBackground:
735 self.reEstimateBackground(maskedImage, results.background)
737 self.clearUnwantedResults(maskedImage.getMask(), results)
739 self.display(exposure, results, middle)
741 return results
743 def setPeakSignificance(self, exposure, footprints, threshold, negative=False):
744 """Set the significance of each detected peak to the pixel value divided
745 by the appropriate standard-deviation for ``config.thresholdType``.
747 Only sets significance for "stdev" and "pixel_stdev" thresholdTypes;
748 we leave it undefined for "value" and "variance" as it does not have a
749 well-defined meaning in those cases.
751 Parameters
752 ----------
753 exposure : `lsst.afw.image.Exposure`
754 Exposure that footprints were detected on, likely the convolved,
755 local background-subtracted image.
756 footprints : `lsst.afw.detection.FootprintSet`
757 Footprints detected on the image.
758 threshold : `lsst.afw.detection.Threshold`
759 Threshold used to find footprints.
760 negative : `bool`, optional
761 Are we calculating for negative sources?
762 """
763 if footprints is None or footprints.getFootprints() == []:
764 return footprints
765 polarity = -1 if negative else 1
767 # All incoming footprints have the same schema.
768 mapper = afwTable.SchemaMapper(footprints.getFootprints()[0].peaks.schema)
769 mapper.addMinimalSchema(footprints.getFootprints()[0].peaks.schema)
770 mapper.addOutputField("significance", type=float,
771 doc="Ratio of peak value to configured standard deviation.")
773 # Copy the old peaks to the new ones with a significance field.
774 # Do this independent of the threshold type, so we always have a
775 # significance field.
776 newFootprints = afwDet.FootprintSet(footprints)
777 for old, new in zip(footprints.getFootprints(), newFootprints.getFootprints()):
778 newPeaks = afwDet.PeakCatalog(mapper.getOutputSchema())
779 newPeaks.extend(old.peaks, mapper=mapper)
780 new.getPeaks().clear()
781 new.setPeakCatalog(newPeaks)
783 # Compute the significance values.
784 if self.config.thresholdType == "pixel_stdev":
785 for footprint in newFootprints.getFootprints():
786 footprint.updatePeakSignificance(exposure.variance, polarity)
787 elif self.config.thresholdType == "stdev":
788 sigma = threshold.getValue() / self.config.thresholdValue
789 for footprint in newFootprints.getFootprints():
790 footprint.updatePeakSignificance(polarity*sigma)
791 else:
792 for footprint in newFootprints.getFootprints():
793 for peak in footprint.peaks:
794 peak["significance"] = 0
796 return newFootprints
798 def makeThreshold(self, image, thresholdParity, factor=1.0):
799 """Make an afw.detection.Threshold object corresponding to the task's
800 configuration and the statistics of the given image.
802 Parameters
803 ----------
804 image : `afw.image.MaskedImage`
805 Image to measure noise statistics from if needed.
806 thresholdParity: `str`
807 One of "positive" or "negative", to set the kind of fluctuations
808 the Threshold will detect.
809 factor : `float`
810 Factor by which to multiply the configured detection threshold.
811 This is useful for tweaking the detection threshold slightly.
813 Returns
814 -------
815 threshold : `lsst.afw.detection.Threshold`
816 Detection threshold.
817 """
818 parity = False if thresholdParity == "negative" else True
819 thresholdValue = self.config.thresholdValue
820 thresholdType = self.config.thresholdType
821 if self.config.thresholdType == 'stdev':
822 bad = image.getMask().getPlaneBitMask(self.config.statsMask)
823 sctrl = afwMath.StatisticsControl()
824 sctrl.setAndMask(bad)
825 stats = afwMath.makeStatistics(image, afwMath.STDEVCLIP, sctrl)
826 thresholdValue *= stats.getValue(afwMath.STDEVCLIP)
827 thresholdType = 'value'
829 threshold = afwDet.createThreshold(thresholdValue*factor, thresholdType, parity)
830 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier)
831 self.log.debug("Detection threshold: %s", threshold)
832 return threshold
834 def updatePeaks(self, fpSet, image, threshold):
835 """Update the Peaks in a FootprintSet by detecting new Footprints and
836 Peaks in an image and using the new Peaks instead of the old ones.
838 Parameters
839 ----------
840 fpSet : `afw.detection.FootprintSet`
841 Set of Footprints whose Peaks should be updated.
842 image : `afw.image.MaskedImage`
843 Image to detect new Footprints and Peak in.
844 threshold : `afw.detection.Threshold`
845 Threshold object for detection.
847 Input Footprints with fewer Peaks than self.config.nPeaksMaxSimple
848 are not modified, and if no new Peaks are detected in an input
849 Footprint, the brightest original Peak in that Footprint is kept.
850 """
851 for footprint in fpSet.getFootprints():
852 oldPeaks = footprint.getPeaks()
853 if len(oldPeaks) <= self.config.nPeaksMaxSimple:
854 continue
855 # We detect a new FootprintSet within each non-simple Footprint's
856 # bbox to avoid a big O(N^2) comparison between the two sets of
857 # Footprints.
858 sub = image.Factory(image, footprint.getBBox())
859 fpSetForPeaks = afwDet.FootprintSet(
860 sub,
861 threshold,
862 "", # don't set a mask plane
863 self.config.minPixels
864 )
865 newPeaks = afwDet.PeakCatalog(oldPeaks.getTable())
866 for fpForPeaks in fpSetForPeaks.getFootprints():
867 for peak in fpForPeaks.getPeaks():
868 if footprint.contains(peak.getI()):
869 newPeaks.append(peak)
870 if len(newPeaks) > 0:
871 del oldPeaks[:]
872 oldPeaks.extend(newPeaks)
873 else:
874 del oldPeaks[1:]
876 @staticmethod
877 def setEdgeBits(maskedImage, goodBBox, edgeBitmask):
878 """Set the edgeBitmask bits for all of maskedImage outside goodBBox
880 Parameters
881 ----------
882 maskedImage : `lsst.afw.image.MaskedImage`
883 Image on which to set edge bits in the mask.
884 goodBBox : `lsst.geom.Box2I`
885 Bounding box of good pixels, in ``LOCAL`` coordinates.
886 edgeBitmask : `lsst.afw.image.MaskPixel`
887 Bit mask to OR with the existing mask bits in the region
888 outside ``goodBBox``.
889 """
890 msk = maskedImage.getMask()
892 mx0, my0 = maskedImage.getXY0()
893 for x0, y0, w, h in ([0, 0,
894 msk.getWidth(), goodBBox.getBeginY() - my0],
895 [0, goodBBox.getEndY() - my0, msk.getWidth(),
896 maskedImage.getHeight() - (goodBBox.getEndY() - my0)],
897 [0, 0,
898 goodBBox.getBeginX() - mx0, msk.getHeight()],
899 [goodBBox.getEndX() - mx0, 0,
900 maskedImage.getWidth() - (goodBBox.getEndX() - mx0), msk.getHeight()],
901 ):
902 edgeMask = msk.Factory(msk, lsst.geom.BoxI(lsst.geom.PointI(x0, y0),
903 lsst.geom.ExtentI(w, h)), afwImage.LOCAL)
904 edgeMask |= edgeBitmask
906 @contextmanager
907 def tempWideBackgroundContext(self, exposure):
908 """Context manager for removing wide (large-scale) background
910 Removing a wide (large-scale) background helps to suppress the
911 detection of large footprints that may overwhelm the deblender.
912 It does, however, set a limit on the maximum scale of objects.
914 The background that we remove will be restored upon exit from
915 the context manager.
917 Parameters
918 ----------
919 exposure : `lsst.afw.image.Exposure`
920 Exposure on which to remove large-scale background.
922 Returns
923 -------
924 context : context manager
925 Context manager that will ensure the temporary wide background
926 is restored.
927 """
928 doTempWideBackground = self.config.doTempWideBackground
929 if doTempWideBackground:
930 self.log.info("Applying temporary wide background subtraction")
931 original = exposure.maskedImage.image.array[:].copy()
932 self.tempWideBackground.run(exposure).background
933 # Remove NO_DATA regions (e.g., edge of the field-of-view); these can cause detections after
934 # subtraction because of extrapolation of the background model into areas with no constraints.
935 image = exposure.maskedImage.image
936 mask = exposure.maskedImage.mask
937 noData = mask.array & mask.getPlaneBitMask("NO_DATA") > 0
938 isGood = mask.array & mask.getPlaneBitMask(self.config.statsMask) == 0
939 image.array[noData] = np.median(image.array[~noData & isGood])
940 try:
941 yield
942 finally:
943 if doTempWideBackground:
944 exposure.maskedImage.image.array[:] = original
947def addExposures(exposureList):
948 """Add a set of exposures together.
950 Parameters
951 ----------
952 exposureList : `list` of `lsst.afw.image.Exposure`
953 Sequence of exposures to add.
955 Returns
956 -------
957 addedExposure : `lsst.afw.image.Exposure`
958 An exposure of the same size as each exposure in ``exposureList``,
959 with the metadata from ``exposureList[0]`` and a masked image equal
960 to the sum of all the exposure's masked images.
961 """
962 exposure0 = exposureList[0]
963 image0 = exposure0.getMaskedImage()
965 addedImage = image0.Factory(image0, True)
966 addedImage.setXY0(image0.getXY0())
968 for exposure in exposureList[1:]:
969 image = exposure.getMaskedImage()
970 addedImage += image
972 addedExposure = exposure0.Factory(addedImage, exposure0.getWcs())
973 return addedExposure