Coverage for python/lsst/meas/algorithms/detection.py: 14%
315 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-16 02:19 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-16 02:19 -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 """Detect peaks and footprints of sources in an image.
165 This task convolves the image with a Gaussian approximation to the PSF,
166 matched to the sigma of the input exposure, because this is separable and
167 fast. The PSF would have to be very non-Gaussian or non-circular for this
168 approximation to have a significant impact on the signal-to-noise of the
169 detected sources.
171 Parameters
172 ----------
173 schema : `lsst.afw.table.Schema`
174 Schema object used to create the output `lsst.afw.table.SourceCatalog`
175 **kwds
176 Keyword arguments passed to `lsst.pipe.base.Task.__init__`
178 If schema is not None and configured for 'both' detections,
179 a 'flags.negative' field will be added to label detections made with a
180 negative threshold.
182 Notes
183 -----
184 This task can add fields to the schema, so any code calling this task must
185 ensure that these columns are indeed present in the input match list.
186 """
187 ConfigClass = SourceDetectionConfig
188 _DefaultName = "sourceDetection"
190 def __init__(self, schema=None, **kwds):
191 pipeBase.Task.__init__(self, **kwds)
192 if schema is not None and self.config.thresholdPolarity == "both":
193 self.negativeFlagKey = schema.addField(
194 "flags_negative", type="Flag",
195 doc="set if source was detected as significantly negative"
196 )
197 else:
198 if self.config.thresholdPolarity == "both":
199 self.log.warning("Detection polarity set to 'both', but no flag will be "
200 "set to distinguish between positive and negative detections")
201 self.negativeFlagKey = None
202 if self.config.reEstimateBackground:
203 self.makeSubtask("background")
204 if self.config.doTempLocalBackground:
205 self.makeSubtask("tempLocalBackground")
206 if self.config.doTempWideBackground:
207 self.makeSubtask("tempWideBackground")
209 @timeMethod
210 def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
211 r"""Detect sources and return catalog(s) of detections.
213 Parameters
214 ----------
215 table : `lsst.afw.table.SourceTable`
216 Table object that will be used to create the SourceCatalog.
217 exposure : `lsst.afw.image.Exposure`
218 Exposure to process; DETECTED mask plane will be set in-place.
219 doSmooth : `bool`
220 If True, smooth the image before detection using a Gaussian of width
221 ``sigma``, or the measured PSF width. Set to False when running on
222 e.g. a pre-convolved image, or a mask plane.
223 sigma : `float`
224 Sigma of PSF (pixels); used for smoothing and to grow detections;
225 if None then measure the sigma of the PSF of the exposure
226 clearMask : `bool`
227 Clear DETECTED{,_NEGATIVE} planes before running detection.
228 expId : `int`
229 Exposure identifier; unused by this implementation, but used for
230 RNG seed by subclasses.
232 Returns
233 -------
234 result : `lsst.pipe.base.Struct`
235 The `~lsst.pipe.base.Struct` contains:
237 ``sources``
238 Detected sources on the exposure.
239 (`lsst.afw.table.SourceCatalog`)
240 ``positive``
241 Positive polarity footprints.
242 (`lsst.afw.detection.FootprintSet` or `None`)
243 ``negative``
244 Negative polarity footprints.
245 (`lsst.afw.detection.FootprintSet` or `None`)
246 ``numPos``
247 Number of footprints in positive or 0 if detection polarity was
248 negative. (`int`)
249 ``numNeg``
250 Number of footprints in negative or 0 if detection polarity was
251 positive. (`int`)
252 ``background``
253 Re-estimated background. `None` if
254 ``reEstimateBackground==False``.
255 (`lsst.afw.math.BackgroundList`)
256 ``factor``
257 Multiplication factor applied to the configured detection
258 threshold. (`float`)
260 Raises
261 ------
262 ValueError
263 Raised if flags.negative is needed, but isn't in table's schema.
264 lsst.pipe.base.TaskError
265 Raised if sigma=None, doSmooth=True and the exposure has no PSF.
267 Notes
268 -----
269 If you want to avoid dealing with Sources and Tables, you can use
270 `detectFootprints()` to just get the
271 `~lsst.afw.detection.FootprintSet`\s.
272 """
273 if self.negativeFlagKey is not None and self.negativeFlagKey not in table.getSchema():
274 raise ValueError("Table has incorrect Schema")
275 results = self.detectFootprints(exposure=exposure, doSmooth=doSmooth, sigma=sigma,
276 clearMask=clearMask, expId=expId)
277 sources = afwTable.SourceCatalog(table)
278 sources.reserve(results.numPos + results.numNeg)
279 if results.negative:
280 results.negative.makeSources(sources)
281 if self.negativeFlagKey:
282 for record in sources:
283 record.set(self.negativeFlagKey, True)
284 if results.positive:
285 results.positive.makeSources(sources)
286 results.sources = sources
287 return results
289 def display(self, exposure, results, convolvedImage=None):
290 """Display detections if so configured
292 Displays the ``exposure`` in frame 0, overlays the detection peaks.
294 Requires that ``lsstDebug`` has been set up correctly, so that
295 ``lsstDebug.Info("lsst.meas.algorithms.detection")`` evaluates `True`.
297 If the ``convolvedImage`` is non-`None` and
298 ``lsstDebug.Info("lsst.meas.algorithms.detection") > 1``, the
299 ``convolvedImage`` will be displayed in frame 1.
301 Parameters
302 ----------
303 exposure : `lsst.afw.image.Exposure`
304 Exposure to display, on which will be plotted the detections.
305 results : `lsst.pipe.base.Struct`
306 Results of the 'detectFootprints' method, containing positive and
307 negative footprints (which contain the peak positions that we will
308 plot). This is a `Struct` with ``positive`` and ``negative``
309 elements that are of type `lsst.afw.detection.FootprintSet`.
310 convolvedImage : `lsst.afw.image.Image`, optional
311 Convolved image used for thresholding.
312 """
313 try:
314 import lsstDebug
315 display = lsstDebug.Info(__name__).display
316 except ImportError:
317 try:
318 display
319 except NameError:
320 display = False
321 if not display:
322 return
324 afwDisplay.setDefaultMaskTransparency(75)
326 disp0 = afwDisplay.Display(frame=0)
327 disp0.mtv(exposure, title="detection")
329 def plotPeaks(fps, ctype):
330 if fps is None:
331 return
332 with disp0.Buffering():
333 for fp in fps.getFootprints():
334 for pp in fp.getPeaks():
335 disp0.dot("+", pp.getFx(), pp.getFy(), ctype=ctype)
336 plotPeaks(results.positive, "yellow")
337 plotPeaks(results.negative, "red")
339 if convolvedImage and display > 1:
340 disp1 = afwDisplay.Display(frame=1)
341 disp1.mtv(convolvedImage, title="PSF smoothed")
343 disp2 = afwDisplay.Display(frame=2)
344 disp2.mtv(afwImage.ImageF(np.sqrt(exposure.variance.array)), title="stddev")
346 def applyTempLocalBackground(self, exposure, middle, results):
347 """Apply a temporary local background subtraction
349 This temporary local background serves to suppress noise fluctuations
350 in the wings of bright objects.
352 Peaks in the footprints will be updated.
354 Parameters
355 ----------
356 exposure : `lsst.afw.image.Exposure`
357 Exposure for which to fit local background.
358 middle : `lsst.afw.image.MaskedImage`
359 Convolved image on which detection will be performed
360 (typically smaller than ``exposure`` because the
361 half-kernel has been removed around the edges).
362 results : `lsst.pipe.base.Struct`
363 Results of the 'detectFootprints' method, containing positive and
364 negative footprints (which contain the peak positions that we will
365 plot). This is a `Struct` with ``positive`` and ``negative``
366 elements that are of type `lsst.afw.detection.FootprintSet`.
367 """
368 # Subtract the local background from the smoothed image. Since we
369 # never use the smoothed again we don't need to worry about adding
370 # it back in.
371 bg = self.tempLocalBackground.fitBackground(exposure.getMaskedImage())
372 bgImage = bg.getImageF(self.tempLocalBackground.config.algorithm,
373 self.tempLocalBackground.config.undersampleStyle)
374 middle -= bgImage.Factory(bgImage, middle.getBBox())
375 if self.config.thresholdPolarity != "negative":
376 results.positiveThreshold = self.makeThreshold(middle, "positive")
377 self.updatePeaks(results.positive, middle, results.positiveThreshold)
378 if self.config.thresholdPolarity != "positive":
379 results.negativeThreshold = self.makeThreshold(middle, "negative")
380 self.updatePeaks(results.negative, middle, results.negativeThreshold)
382 def clearMask(self, mask):
383 """Clear the DETECTED and DETECTED_NEGATIVE mask planes.
385 Removes any previous detection mask in preparation for a new
386 detection pass.
388 Parameters
389 ----------
390 mask : `lsst.afw.image.Mask`
391 Mask to be cleared.
392 """
393 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
395 def calculateKernelSize(self, sigma):
396 """Calculate the size of the smoothing kernel.
398 Uses the ``nSigmaForKernel`` configuration parameter. Note
399 that that is the full width of the kernel bounding box
400 (so a value of 7 means 3.5 sigma on either side of center).
401 The value will be rounded up to the nearest odd integer.
403 Parameters
404 ----------
405 sigma : `float`
406 Gaussian sigma of smoothing kernel.
408 Returns
409 -------
410 size : `int`
411 Size of the smoothing kernel.
412 """
413 return (int(sigma * self.config.nSigmaForKernel + 0.5)//2)*2 + 1 # make sure it is odd
415 def getPsf(self, exposure, sigma=None):
416 """Create a single Gaussian PSF for an exposure.
418 If ``sigma`` is provided, we make a `~lsst.afw.detection.GaussianPsf`
419 with that, otherwise use the sigma from the psf of the ``exposure`` to
420 make the `~lsst.afw.detection.GaussianPsf`.
422 Parameters
423 ----------
424 exposure : `lsst.afw.image.Exposure`
425 Exposure from which to retrieve the PSF.
426 sigma : `float`, optional
427 Gaussian sigma to use if provided.
429 Returns
430 -------
431 psf : `lsst.afw.detection.GaussianPsf`
432 PSF to use for detection.
434 Raises
435 ------
436 RuntimeError
437 Raised if ``sigma`` is not provided and ``exposure`` does not
438 contain a ``Psf`` object.
439 """
440 if sigma is None:
441 psf = exposure.getPsf()
442 if psf is None:
443 raise RuntimeError("Unable to determine PSF to use for detection: no sigma provided")
444 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
445 size = self.calculateKernelSize(sigma)
446 psf = afwDet.GaussianPsf(size, size, sigma)
447 return psf
449 def convolveImage(self, maskedImage, psf, doSmooth=True):
450 """Convolve the image with the PSF.
452 We convolve the image with a Gaussian approximation to the PSF,
453 because this is separable and therefore fast. It's technically a
454 correlation rather than a convolution, but since we use a symmetric
455 Gaussian there's no difference.
457 The convolution can be disabled with ``doSmooth=False``. If we do
458 convolve, we mask the edges as ``EDGE`` and return the convolved image
459 with the edges removed. This is because we can't convolve the edges
460 because the kernel would extend off the image.
462 Parameters
463 ----------
464 maskedImage : `lsst.afw.image.MaskedImage`
465 Image to convolve.
466 psf : `lsst.afw.detection.Psf`
467 PSF to convolve with (actually with a Gaussian approximation
468 to it).
469 doSmooth : `bool`
470 Actually do the convolution? Set to False when running on
471 e.g. a pre-convolved image, or a mask plane.
473 Returns
474 -------
475 results : `lsst.pipe.base.Struct`
476 The `~lsst.pipe.base.Struct` contains:
478 ``middle``
479 Convolved image, without the edges. (`lsst.afw.image.MaskedImage`)
480 ``sigma``
481 Gaussian sigma used for the convolution. (`float`)
482 """
483 self.metadata["doSmooth"] = doSmooth
484 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
485 self.metadata["sigma"] = sigma
487 if not doSmooth:
488 middle = maskedImage.Factory(maskedImage, deep=True)
489 return pipeBase.Struct(middle=middle, sigma=sigma)
491 # Smooth using a Gaussian (which is separable, hence fast) of width sigma
492 # Make a SingleGaussian (separable) kernel with the 'sigma'
493 kWidth = self.calculateKernelSize(sigma)
494 self.metadata["smoothingKernelWidth"] = kWidth
495 gaussFunc = afwMath.GaussianFunction1D(sigma)
496 gaussKernel = afwMath.SeparableKernel(kWidth, kWidth, gaussFunc, gaussFunc)
498 convolvedImage = maskedImage.Factory(maskedImage.getBBox())
500 afwMath.convolve(convolvedImage, maskedImage, gaussKernel, afwMath.ConvolutionControl())
502 # Only search psf-smoothed part of frame
503 goodBBox = gaussKernel.shrinkBBox(convolvedImage.getBBox())
504 middle = convolvedImage.Factory(convolvedImage, goodBBox, afwImage.PARENT, False)
506 # Mark the parts of the image outside goodBBox as EDGE
507 self.setEdgeBits(maskedImage, goodBBox, maskedImage.getMask().getPlaneBitMask("EDGE"))
509 return pipeBase.Struct(middle=middle, sigma=sigma)
511 def applyThreshold(self, middle, bbox, factor=1.0):
512 r"""Apply thresholds to the convolved image
514 Identifies `~lsst.afw.detection.Footprint`\s, both positive and negative.
515 The threshold can be modified by the provided multiplication
516 ``factor``.
518 Parameters
519 ----------
520 middle : `lsst.afw.image.MaskedImage`
521 Convolved image to threshold.
522 bbox : `lsst.geom.Box2I`
523 Bounding box of unconvolved image.
524 factor : `float`
525 Multiplier for the configured threshold.
527 Returns
528 -------
529 results : `lsst.pipe.base.Struct`
530 The `~lsst.pipe.base.Struct` contains:
532 ``positive``
533 Positive detection footprints, if configured.
534 (`lsst.afw.detection.FootprintSet` or `None`)
535 ``negative``
536 Negative detection footprints, if configured.
537 (`lsst.afw.detection.FootprintSet` or `None`)
538 ``factor``
539 Multiplier for the configured threshold.
540 (`float`)
541 """
542 results = pipeBase.Struct(positive=None, negative=None, factor=factor,
543 positiveThreshold=None, negativeThreshold=None)
544 # Detect the Footprints (peaks may be replaced if doTempLocalBackground)
545 if self.config.reEstimateBackground or self.config.thresholdPolarity != "negative":
546 results.positiveThreshold = self.makeThreshold(middle, "positive", factor=factor)
547 results.positive = afwDet.FootprintSet(
548 middle,
549 results.positiveThreshold,
550 "DETECTED",
551 self.config.minPixels
552 )
553 results.positive.setRegion(bbox)
554 if self.config.reEstimateBackground or self.config.thresholdPolarity != "positive":
555 results.negativeThreshold = self.makeThreshold(middle, "negative", factor=factor)
556 results.negative = afwDet.FootprintSet(
557 middle,
558 results.negativeThreshold,
559 "DETECTED_NEGATIVE",
560 self.config.minPixels
561 )
562 results.negative.setRegion(bbox)
564 return results
566 def finalizeFootprints(self, mask, results, sigma, factor=1.0):
567 """Finalize the detected footprints.
569 Grow the footprints, set the ``DETECTED`` and ``DETECTED_NEGATIVE``
570 mask planes, and log the results.
572 ``numPos`` (number of positive footprints), ``numPosPeaks`` (number
573 of positive peaks), ``numNeg`` (number of negative footprints),
574 ``numNegPeaks`` (number of negative peaks) entries are added to the
575 ``results`` struct.
577 Parameters
578 ----------
579 mask : `lsst.afw.image.Mask`
580 Mask image on which to flag detected pixels.
581 results : `lsst.pipe.base.Struct`
582 Struct of detection results, including ``positive`` and
583 ``negative`` entries; modified.
584 sigma : `float`
585 Gaussian sigma of PSF.
586 factor : `float`
587 Multiplier for the configured threshold.
588 """
589 for polarity, maskName in (("positive", "DETECTED"), ("negative", "DETECTED_NEGATIVE")):
590 fpSet = getattr(results, polarity)
591 if fpSet is None:
592 continue
593 if self.config.nSigmaToGrow > 0:
594 nGrow = int((self.config.nSigmaToGrow * sigma) + 0.5)
595 self.metadata["nGrow"] = nGrow
596 if self.config.combinedGrow:
597 fpSet = afwDet.FootprintSet(fpSet, nGrow, self.config.isotropicGrow)
598 else:
599 stencil = (afwGeom.Stencil.CIRCLE if self.config.isotropicGrow else
600 afwGeom.Stencil.MANHATTAN)
601 for fp in fpSet:
602 fp.dilate(nGrow, stencil)
603 fpSet.setMask(mask, maskName)
604 if not self.config.returnOriginalFootprints:
605 setattr(results, polarity, fpSet)
607 results.numPos = 0
608 results.numPosPeaks = 0
609 results.numNeg = 0
610 results.numNegPeaks = 0
611 positive = ""
612 negative = ""
614 if results.positive is not None:
615 results.numPos = len(results.positive.getFootprints())
616 results.numPosPeaks = sum(len(fp.getPeaks()) for fp in results.positive.getFootprints())
617 positive = " %d positive peaks in %d footprints" % (results.numPosPeaks, results.numPos)
618 if results.negative is not None:
619 results.numNeg = len(results.negative.getFootprints())
620 results.numNegPeaks = sum(len(fp.getPeaks()) for fp in results.negative.getFootprints())
621 negative = " %d negative peaks in %d footprints" % (results.numNegPeaks, results.numNeg)
623 self.log.info("Detected%s%s%s to %g %s",
624 positive, " and" if positive and negative else "", negative,
625 self.config.thresholdValue*self.config.includeThresholdMultiplier*factor,
626 "DN" if self.config.thresholdType == "value" else "sigma")
628 def reEstimateBackground(self, maskedImage, backgrounds):
629 """Estimate the background after detection
631 Parameters
632 ----------
633 maskedImage : `lsst.afw.image.MaskedImage`
634 Image on which to estimate the background.
635 backgrounds : `lsst.afw.math.BackgroundList`
636 List of backgrounds; modified.
638 Returns
639 -------
640 bg : `lsst.afw.math.backgroundMI`
641 Empirical background model.
642 """
643 bg = self.background.fitBackground(maskedImage)
644 if self.config.adjustBackground:
645 self.log.warning("Fiddling the background by %g", self.config.adjustBackground)
646 bg += self.config.adjustBackground
647 self.log.info("Resubtracting the background after object detection")
648 maskedImage -= bg.getImageF(self.background.config.algorithm,
649 self.background.config.undersampleStyle)
651 actrl = bg.getBackgroundControl().getApproximateControl()
652 backgrounds.append((bg, getattr(afwMath.Interpolate, self.background.config.algorithm),
653 bg.getAsUsedUndersampleStyle(), actrl.getStyle(), actrl.getOrderX(),
654 actrl.getOrderY(), actrl.getWeighting()))
655 return bg
657 def clearUnwantedResults(self, mask, results):
658 """Clear unwanted results from the Struct of results
660 If we specifically want only positive or only negative detections,
661 drop the ones we don't want, and its associated mask plane.
663 Parameters
664 ----------
665 mask : `lsst.afw.image.Mask`
666 Mask image.
667 results : `lsst.pipe.base.Struct`
668 Detection results, with ``positive`` and ``negative`` elements;
669 modified.
670 """
671 if self.config.thresholdPolarity == "positive":
672 if self.config.reEstimateBackground:
673 mask &= ~mask.getPlaneBitMask("DETECTED_NEGATIVE")
674 results.negative = None
675 elif self.config.thresholdPolarity == "negative":
676 if self.config.reEstimateBackground:
677 mask &= ~mask.getPlaneBitMask("DETECTED")
678 results.positive = None
680 @timeMethod
681 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
682 """Detect footprints on an exposure.
684 Parameters
685 ----------
686 exposure : `lsst.afw.image.Exposure`
687 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
688 set in-place.
689 doSmooth : `bool`, optional
690 If True, smooth the image before detection using a Gaussian
691 of width ``sigma``, or the measured PSF width of ``exposure``.
692 Set to False when running on e.g. a pre-convolved image, or a mask
693 plane.
694 sigma : `float`, optional
695 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
696 detections; if `None` then measure the sigma of the PSF of the
697 ``exposure``.
698 clearMask : `bool`, optional
699 Clear both DETECTED and DETECTED_NEGATIVE planes before running
700 detection.
701 expId : `dict`, optional
702 Exposure identifier; unused by this implementation, but used for
703 RNG seed by subclasses.
705 Returns
706 -------
707 results : `lsst.pipe.base.Struct`
708 A `~lsst.pipe.base.Struct` containing:
710 ``positive``
711 Positive polarity footprints.
712 (`lsst.afw.detection.FootprintSet` or `None`)
713 ``negative``
714 Negative polarity footprints.
715 (`lsst.afw.detection.FootprintSet` or `None`)
716 ``numPos``
717 Number of footprints in positive or 0 if detection polarity was
718 negative. (`int`)
719 ``numNeg``
720 Number of footprints in negative or 0 if detection polarity was
721 positive. (`int`)
722 ``background``
723 Re-estimated background. `None` if
724 ``reEstimateBackground==False``.
725 (`lsst.afw.math.BackgroundList`)
726 ``factor``
727 Multiplication factor applied to the configured detection
728 threshold. (`float`)
729 """
730 maskedImage = exposure.maskedImage
732 if clearMask:
733 self.clearMask(maskedImage.getMask())
735 psf = self.getPsf(exposure, sigma=sigma)
736 with self.tempWideBackgroundContext(exposure):
737 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
738 middle = convolveResults.middle
739 sigma = convolveResults.sigma
741 results = self.applyThreshold(middle, maskedImage.getBBox())
742 results.background = afwMath.BackgroundList()
743 if self.config.doTempLocalBackground:
744 self.applyTempLocalBackground(exposure, middle, results)
745 self.finalizeFootprints(maskedImage.mask, results, sigma)
747 # Compute the significance of peaks after the peaks have been
748 # finalized and after local background correction/updatePeaks, so
749 # that the significance represents the "final" detection S/N.
750 results.positive = self.setPeakSignificance(middle, results.positive, results.positiveThreshold)
751 results.negative = self.setPeakSignificance(middle, results.negative, results.negativeThreshold,
752 negative=True)
754 if self.config.reEstimateBackground:
755 self.reEstimateBackground(maskedImage, results.background)
757 self.clearUnwantedResults(maskedImage.getMask(), results)
759 self.display(exposure, results, middle)
761 return results
763 def setPeakSignificance(self, exposure, footprints, threshold, negative=False):
764 """Set the significance of each detected peak to the pixel value divided
765 by the appropriate standard-deviation for ``config.thresholdType``.
767 Only sets significance for "stdev" and "pixel_stdev" thresholdTypes;
768 we leave it undefined for "value" and "variance" as it does not have a
769 well-defined meaning in those cases.
771 Parameters
772 ----------
773 exposure : `lsst.afw.image.Exposure`
774 Exposure that footprints were detected on, likely the convolved,
775 local background-subtracted image.
776 footprints : `lsst.afw.detection.FootprintSet`
777 Footprints detected on the image.
778 threshold : `lsst.afw.detection.Threshold`
779 Threshold used to find footprints.
780 negative : `bool`, optional
781 Are we calculating for negative sources?
782 """
783 if footprints is None or footprints.getFootprints() == []:
784 return footprints
785 polarity = -1 if negative else 1
787 # All incoming footprints have the same schema.
788 mapper = afwTable.SchemaMapper(footprints.getFootprints()[0].peaks.schema)
789 mapper.addMinimalSchema(footprints.getFootprints()[0].peaks.schema)
790 mapper.addOutputField("significance", type=float,
791 doc="Ratio of peak value to configured standard deviation.")
793 # Copy the old peaks to the new ones with a significance field.
794 # Do this independent of the threshold type, so we always have a
795 # significance field.
796 newFootprints = afwDet.FootprintSet(footprints)
797 for old, new in zip(footprints.getFootprints(), newFootprints.getFootprints()):
798 newPeaks = afwDet.PeakCatalog(mapper.getOutputSchema())
799 newPeaks.extend(old.peaks, mapper=mapper)
800 new.getPeaks().clear()
801 new.setPeakCatalog(newPeaks)
803 # Compute the significance values.
804 if self.config.thresholdType == "pixel_stdev":
805 for footprint in newFootprints.getFootprints():
806 footprint.updatePeakSignificance(exposure.variance, polarity)
807 elif self.config.thresholdType == "stdev":
808 sigma = threshold.getValue() / self.config.thresholdValue
809 for footprint in newFootprints.getFootprints():
810 footprint.updatePeakSignificance(polarity*sigma)
811 else:
812 for footprint in newFootprints.getFootprints():
813 for peak in footprint.peaks:
814 peak["significance"] = 0
816 return newFootprints
818 def makeThreshold(self, image, thresholdParity, factor=1.0):
819 """Make an afw.detection.Threshold object corresponding to the task's
820 configuration and the statistics of the given image.
822 Parameters
823 ----------
824 image : `afw.image.MaskedImage`
825 Image to measure noise statistics from if needed.
826 thresholdParity: `str`
827 One of "positive" or "negative", to set the kind of fluctuations
828 the Threshold will detect.
829 factor : `float`
830 Factor by which to multiply the configured detection threshold.
831 This is useful for tweaking the detection threshold slightly.
833 Returns
834 -------
835 threshold : `lsst.afw.detection.Threshold`
836 Detection threshold.
837 """
838 parity = False if thresholdParity == "negative" else True
839 thresholdValue = self.config.thresholdValue
840 thresholdType = self.config.thresholdType
841 if self.config.thresholdType == 'stdev':
842 bad = image.getMask().getPlaneBitMask(self.config.statsMask)
843 sctrl = afwMath.StatisticsControl()
844 sctrl.setAndMask(bad)
845 stats = afwMath.makeStatistics(image, afwMath.STDEVCLIP, sctrl)
846 thresholdValue *= stats.getValue(afwMath.STDEVCLIP)
847 thresholdType = 'value'
849 threshold = afwDet.createThreshold(thresholdValue*factor, thresholdType, parity)
850 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier)
851 self.log.debug("Detection threshold: %s", threshold)
852 return threshold
854 def updatePeaks(self, fpSet, image, threshold):
855 """Update the Peaks in a FootprintSet by detecting new Footprints and
856 Peaks in an image and using the new Peaks instead of the old ones.
858 Parameters
859 ----------
860 fpSet : `afw.detection.FootprintSet`
861 Set of Footprints whose Peaks should be updated.
862 image : `afw.image.MaskedImage`
863 Image to detect new Footprints and Peak in.
864 threshold : `afw.detection.Threshold`
865 Threshold object for detection.
867 Input Footprints with fewer Peaks than self.config.nPeaksMaxSimple
868 are not modified, and if no new Peaks are detected in an input
869 Footprint, the brightest original Peak in that Footprint is kept.
870 """
871 for footprint in fpSet.getFootprints():
872 oldPeaks = footprint.getPeaks()
873 if len(oldPeaks) <= self.config.nPeaksMaxSimple:
874 continue
875 # We detect a new FootprintSet within each non-simple Footprint's
876 # bbox to avoid a big O(N^2) comparison between the two sets of
877 # Footprints.
878 sub = image.Factory(image, footprint.getBBox())
879 fpSetForPeaks = afwDet.FootprintSet(
880 sub,
881 threshold,
882 "", # don't set a mask plane
883 self.config.minPixels
884 )
885 newPeaks = afwDet.PeakCatalog(oldPeaks.getTable())
886 for fpForPeaks in fpSetForPeaks.getFootprints():
887 for peak in fpForPeaks.getPeaks():
888 if footprint.contains(peak.getI()):
889 newPeaks.append(peak)
890 if len(newPeaks) > 0:
891 del oldPeaks[:]
892 oldPeaks.extend(newPeaks)
893 else:
894 del oldPeaks[1:]
896 @staticmethod
897 def setEdgeBits(maskedImage, goodBBox, edgeBitmask):
898 """Set the edgeBitmask bits for all of maskedImage outside goodBBox
900 Parameters
901 ----------
902 maskedImage : `lsst.afw.image.MaskedImage`
903 Image on which to set edge bits in the mask.
904 goodBBox : `lsst.geom.Box2I`
905 Bounding box of good pixels, in ``LOCAL`` coordinates.
906 edgeBitmask : `lsst.afw.image.MaskPixel`
907 Bit mask to OR with the existing mask bits in the region
908 outside ``goodBBox``.
909 """
910 msk = maskedImage.getMask()
912 mx0, my0 = maskedImage.getXY0()
913 for x0, y0, w, h in ([0, 0,
914 msk.getWidth(), goodBBox.getBeginY() - my0],
915 [0, goodBBox.getEndY() - my0, msk.getWidth(),
916 maskedImage.getHeight() - (goodBBox.getEndY() - my0)],
917 [0, 0,
918 goodBBox.getBeginX() - mx0, msk.getHeight()],
919 [goodBBox.getEndX() - mx0, 0,
920 maskedImage.getWidth() - (goodBBox.getEndX() - mx0), msk.getHeight()],
921 ):
922 edgeMask = msk.Factory(msk, lsst.geom.BoxI(lsst.geom.PointI(x0, y0),
923 lsst.geom.ExtentI(w, h)), afwImage.LOCAL)
924 edgeMask |= edgeBitmask
926 @contextmanager
927 def tempWideBackgroundContext(self, exposure):
928 """Context manager for removing wide (large-scale) background
930 Removing a wide (large-scale) background helps to suppress the
931 detection of large footprints that may overwhelm the deblender.
932 It does, however, set a limit on the maximum scale of objects.
934 The background that we remove will be restored upon exit from
935 the context manager.
937 Parameters
938 ----------
939 exposure : `lsst.afw.image.Exposure`
940 Exposure on which to remove large-scale background.
942 Returns
943 -------
944 context : context manager
945 Context manager that will ensure the temporary wide background
946 is restored.
947 """
948 doTempWideBackground = self.config.doTempWideBackground
949 if doTempWideBackground:
950 self.log.info("Applying temporary wide background subtraction")
951 original = exposure.maskedImage.image.array[:].copy()
952 self.tempWideBackground.run(exposure).background
953 # Remove NO_DATA regions (e.g., edge of the field-of-view); these can cause detections after
954 # subtraction because of extrapolation of the background model into areas with no constraints.
955 image = exposure.maskedImage.image
956 mask = exposure.maskedImage.mask
957 noData = mask.array & mask.getPlaneBitMask("NO_DATA") > 0
958 isGood = mask.array & mask.getPlaneBitMask(self.config.statsMask) == 0
959 image.array[noData] = np.median(image.array[~noData & isGood])
960 try:
961 yield
962 finally:
963 if doTempWideBackground:
964 exposure.maskedImage.image.array[:] = original
967def addExposures(exposureList):
968 """Add a set of exposures together.
970 Parameters
971 ----------
972 exposureList : `list` of `lsst.afw.image.Exposure`
973 Sequence of exposures to add.
975 Returns
976 -------
977 addedExposure : `lsst.afw.image.Exposure`
978 An exposure of the same size as each exposure in ``exposureList``,
979 with the metadata from ``exposureList[0]`` and a masked image equal
980 to the sum of all the exposure's masked images.
981 """
982 exposure0 = exposureList[0]
983 image0 = exposure0.getMaskedImage()
985 addedImage = image0.Factory(image0, True)
986 addedImage.setXY0(image0.getXY0())
988 for exposure in exposureList[1:]:
989 image = exposure.getMaskedImage()
990 addedImage += image
992 addedExposure = exposure0.Factory(addedImage, exposure0.getWcs())
993 return addedExposure