Coverage for python/lsst/meas/algorithms/detection.py: 15%
325 statements
« prev ^ index » next coverage.py v7.4.1, created at 2024-01-31 10:58 +0000
« prev ^ index » next coverage.py v7.4.1, created at 2024-01-31 10:58 +0000
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="Grow pixels as isotropically as possible? If False, use a Manhattan metric instead.",
52 dtype=bool, default=True,
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 detecting 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="Multiplier on thresholdValue for whether a source is included in the output catalog."
72 " For example, thresholdValue=5, includeThresholdMultiplier=10, thresholdType='pixel_stdev' "
73 "results in a catalog of sources at >50 sigma with the detection mask and footprints "
74 "including pixels >5 sigma.",
75 dtype=float, default=1.0, min=0.0,
76 )
77 thresholdType = pexConfig.ChoiceField(
78 doc="Specifies the meaning of thresholdValue.",
79 dtype=str, optional=False, default="pixel_stdev",
80 allowed={
81 "variance": "threshold applied to image variance",
82 "stdev": "threshold applied to image std deviation",
83 "value": "threshold applied to image value",
84 "pixel_stdev": "threshold applied to per-pixel std deviation",
85 },
86 )
87 thresholdPolarity = pexConfig.ChoiceField(
88 doc="Specifies whether to detect positive, or negative sources, or both.",
89 dtype=str, optional=False, default="positive",
90 allowed={
91 "positive": "detect only positive sources",
92 "negative": "detect only negative sources",
93 "both": "detect both positive and negative sources",
94 },
95 )
96 adjustBackground = pexConfig.Field(
97 dtype=float,
98 doc="Fiddle factor to add to the background; debugging only",
99 default=0.0,
100 )
101 reEstimateBackground = pexConfig.Field(
102 dtype=bool,
103 doc="Estimate the background again after final source detection?",
104 default=True, optional=False,
105 )
106 background = pexConfig.ConfigurableField(
107 doc="Background re-estimation; ignored if reEstimateBackground false",
108 target=SubtractBackgroundTask,
109 )
110 tempLocalBackground = pexConfig.ConfigurableField(
111 doc=("A local (small-scale), temporary background estimation step run between "
112 "detecting above-threshold regions and detecting the peaks within "
113 "them; used to avoid detecting spuerious peaks in the wings."),
114 target=SubtractBackgroundTask,
115 )
116 doTempLocalBackground = pexConfig.Field(
117 dtype=bool,
118 doc="Enable temporary local background subtraction? (see tempLocalBackground)",
119 default=True,
120 )
121 tempWideBackground = pexConfig.ConfigurableField(
122 doc=("A wide (large-scale) background estimation and removal before footprint and peak detection. "
123 "It is added back into the image after detection. The purpose is to suppress very large "
124 "footprints (e.g., from large artifacts) that the deblender may choke on."),
125 target=SubtractBackgroundTask,
126 )
127 doTempWideBackground = pexConfig.Field(
128 dtype=bool,
129 doc="Do temporary wide (large-scale) background subtraction before footprint detection?",
130 default=False,
131 )
132 nPeaksMaxSimple = pexConfig.Field(
133 dtype=int,
134 doc=("The maximum number of peaks in a Footprint before trying to "
135 "replace its peaks using the temporary local background"),
136 default=1,
137 )
138 nSigmaForKernel = pexConfig.Field(
139 dtype=float,
140 doc=("Multiple of PSF RMS size to use for convolution kernel bounding box size; "
141 "note that this is not a half-size. The size will be rounded up to the nearest odd integer"),
142 default=7.0,
143 )
144 statsMask = pexConfig.ListField(
145 dtype=str,
146 doc="Mask planes to ignore when calculating statistics of image (for thresholdType=stdev)",
147 default=['BAD', 'SAT', 'EDGE', 'NO_DATA'],
148 )
149 excludeMaskPlanes = lsst.pex.config.ListField(
150 dtype=str,
151 default=[],
152 doc="Mask planes to exclude when detecting sources."
153 )
155 def setDefaults(self):
156 self.tempLocalBackground.binSize = 64
157 self.tempLocalBackground.algorithm = "AKIMA_SPLINE"
158 self.tempLocalBackground.useApprox = False
159 # Background subtraction to remove a large-scale background (e.g., scattered light); restored later.
160 # Want to keep it from exceeding the deblender size limit of 1 Mpix, so half that is reasonable.
161 self.tempWideBackground.binSize = 512
162 self.tempWideBackground.algorithm = "AKIMA_SPLINE"
163 self.tempWideBackground.useApprox = False
164 # Ensure we can remove even bright scattered light that is DETECTED
165 for maskPlane in ("DETECTED", "DETECTED_NEGATIVE"):
166 if maskPlane in self.tempWideBackground.ignoredPixelMask:
167 self.tempWideBackground.ignoredPixelMask.remove(maskPlane)
170class SourceDetectionTask(pipeBase.Task):
171 """Detect peaks and footprints of sources in an image.
173 This task convolves the image with a Gaussian approximation to the PSF,
174 matched to the sigma of the input exposure, because this is separable and
175 fast. The PSF would have to be very non-Gaussian or non-circular for this
176 approximation to have a significant impact on the signal-to-noise of the
177 detected sources.
179 Parameters
180 ----------
181 schema : `lsst.afw.table.Schema`
182 Schema object used to create the output `lsst.afw.table.SourceCatalog`
183 **kwds
184 Keyword arguments passed to `lsst.pipe.base.Task.__init__`
186 If schema is not None and configured for 'both' detections,
187 a 'flags.negative' field will be added to label detections made with a
188 negative threshold.
190 Notes
191 -----
192 This task can add fields to the schema, so any code calling this task must
193 ensure that these columns are indeed present in the input match list.
194 """
195 ConfigClass = SourceDetectionConfig
196 _DefaultName = "sourceDetection"
198 def __init__(self, schema=None, **kwds):
199 pipeBase.Task.__init__(self, **kwds)
200 if schema is not None and self.config.thresholdPolarity == "both":
201 self.negativeFlagKey = schema.addField(
202 "flags_negative", type="Flag",
203 doc="set if source was detected as significantly negative"
204 )
205 else:
206 if self.config.thresholdPolarity == "both":
207 self.log.warning("Detection polarity set to 'both', but no flag will be "
208 "set to distinguish between positive and negative detections")
209 self.negativeFlagKey = None
210 if self.config.reEstimateBackground:
211 self.makeSubtask("background")
212 if self.config.doTempLocalBackground:
213 self.makeSubtask("tempLocalBackground")
214 if self.config.doTempWideBackground:
215 self.makeSubtask("tempWideBackground")
217 @timeMethod
218 def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
219 background=None):
220 r"""Detect sources and return catalog(s) of detections.
222 Parameters
223 ----------
224 table : `lsst.afw.table.SourceTable`
225 Table object that will be used to create the SourceCatalog.
226 exposure : `lsst.afw.image.Exposure`
227 Exposure to process; DETECTED mask plane will be set in-place.
228 doSmooth : `bool`, optional
229 If True, smooth the image before detection using a Gaussian of width
230 ``sigma``, or the measured PSF width. Set to False when running on
231 e.g. a pre-convolved image, or a mask plane.
232 sigma : `float`, optional
233 Sigma of PSF (pixels); used for smoothing and to grow detections;
234 if None then measure the sigma of the PSF of the exposure
235 clearMask : `bool`, optional
236 Clear DETECTED{,_NEGATIVE} planes before running detection.
237 expId : `int`, optional
238 Exposure identifier; unused by this implementation, but used for
239 RNG seed by subclasses.
240 background : `lsst.afw.math.BackgroundList`, optional
241 Background that was already subtracted from the exposure; will be
242 modified in-place if ``reEstimateBackground=True``.
244 Returns
245 -------
246 result : `lsst.pipe.base.Struct`
247 The `~lsst.pipe.base.Struct` contains:
249 ``sources``
250 Detected sources on the exposure.
251 (`lsst.afw.table.SourceCatalog`)
252 ``positive``
253 Positive polarity footprints.
254 (`lsst.afw.detection.FootprintSet` or `None`)
255 ``negative``
256 Negative polarity footprints.
257 (`lsst.afw.detection.FootprintSet` or `None`)
258 ``numPos``
259 Number of footprints in positive or 0 if detection polarity was
260 negative. (`int`)
261 ``numNeg``
262 Number of footprints in negative or 0 if detection polarity was
263 positive. (`int`)
264 ``background``
265 Re-estimated background. `None` if
266 ``reEstimateBackground==False``.
267 (`lsst.afw.math.BackgroundList`)
268 ``factor``
269 Multiplication factor applied to the configured detection
270 threshold. (`float`)
272 Raises
273 ------
274 ValueError
275 Raised if flags.negative is needed, but isn't in table's schema.
276 lsst.pipe.base.TaskError
277 Raised if sigma=None, doSmooth=True and the exposure has no PSF.
279 Notes
280 -----
281 If you want to avoid dealing with Sources and Tables, you can use
282 `detectFootprints()` to just get the
283 `~lsst.afw.detection.FootprintSet`\s.
284 """
285 if self.negativeFlagKey is not None and self.negativeFlagKey not in table.getSchema():
286 raise ValueError("Table has incorrect Schema")
287 results = self.detectFootprints(exposure=exposure, doSmooth=doSmooth, sigma=sigma,
288 clearMask=clearMask, expId=expId, background=background)
289 sources = afwTable.SourceCatalog(table)
290 sources.reserve(results.numPos + results.numNeg)
291 if results.negative:
292 results.negative.makeSources(sources)
293 if self.negativeFlagKey:
294 for record in sources:
295 record.set(self.negativeFlagKey, True)
296 if results.positive:
297 results.positive.makeSources(sources)
298 results.sources = sources
299 return results
301 def display(self, exposure, results, convolvedImage=None):
302 """Display detections if so configured
304 Displays the ``exposure`` in frame 0, overlays the detection peaks.
306 Requires that ``lsstDebug`` has been set up correctly, so that
307 ``lsstDebug.Info("lsst.meas.algorithms.detection")`` evaluates `True`.
309 If the ``convolvedImage`` is non-`None` and
310 ``lsstDebug.Info("lsst.meas.algorithms.detection") > 1``, the
311 ``convolvedImage`` will be displayed in frame 1.
313 Parameters
314 ----------
315 exposure : `lsst.afw.image.Exposure`
316 Exposure to display, on which will be plotted the detections.
317 results : `lsst.pipe.base.Struct`
318 Results of the 'detectFootprints' method, containing positive and
319 negative footprints (which contain the peak positions that we will
320 plot). This is a `Struct` with ``positive`` and ``negative``
321 elements that are of type `lsst.afw.detection.FootprintSet`.
322 convolvedImage : `lsst.afw.image.Image`, optional
323 Convolved image used for thresholding.
324 """
325 try:
326 import lsstDebug
327 display = lsstDebug.Info(__name__).display
328 except ImportError:
329 try:
330 display
331 except NameError:
332 display = False
333 if not display:
334 return
336 afwDisplay.setDefaultMaskTransparency(75)
338 disp0 = afwDisplay.Display(frame=0)
339 disp0.mtv(exposure, title="detection")
341 def plotPeaks(fps, ctype):
342 if fps is None:
343 return
344 with disp0.Buffering():
345 for fp in fps.getFootprints():
346 for pp in fp.getPeaks():
347 disp0.dot("+", pp.getFx(), pp.getFy(), ctype=ctype)
348 plotPeaks(results.positive, "yellow")
349 plotPeaks(results.negative, "red")
351 if convolvedImage and display > 1:
352 disp1 = afwDisplay.Display(frame=1)
353 disp1.mtv(convolvedImage, title="PSF smoothed")
355 disp2 = afwDisplay.Display(frame=2)
356 disp2.mtv(afwImage.ImageF(np.sqrt(exposure.variance.array)), title="stddev")
358 def applyTempLocalBackground(self, exposure, middle, results):
359 """Apply a temporary local background subtraction
361 This temporary local background serves to suppress noise fluctuations
362 in the wings of bright objects.
364 Peaks in the footprints will be updated.
366 Parameters
367 ----------
368 exposure : `lsst.afw.image.Exposure`
369 Exposure for which to fit local background.
370 middle : `lsst.afw.image.MaskedImage`
371 Convolved image on which detection will be performed
372 (typically smaller than ``exposure`` because the
373 half-kernel has been removed around the edges).
374 results : `lsst.pipe.base.Struct`
375 Results of the 'detectFootprints' method, containing positive and
376 negative footprints (which contain the peak positions that we will
377 plot). This is a `Struct` with ``positive`` and ``negative``
378 elements that are of type `lsst.afw.detection.FootprintSet`.
379 """
380 # Subtract the local background from the smoothed image. Since we
381 # never use the smoothed again we don't need to worry about adding
382 # it back in.
383 bg = self.tempLocalBackground.fitBackground(exposure.getMaskedImage())
384 bgImage = bg.getImageF(self.tempLocalBackground.config.algorithm,
385 self.tempLocalBackground.config.undersampleStyle)
386 middle -= bgImage.Factory(bgImage, middle.getBBox())
387 if self.config.thresholdPolarity != "negative":
388 results.positiveThreshold = self.makeThreshold(middle, "positive")
389 self.updatePeaks(results.positive, middle, results.positiveThreshold)
390 if self.config.thresholdPolarity != "positive":
391 results.negativeThreshold = self.makeThreshold(middle, "negative")
392 self.updatePeaks(results.negative, middle, results.negativeThreshold)
394 def clearMask(self, mask):
395 """Clear the DETECTED and DETECTED_NEGATIVE mask planes.
397 Removes any previous detection mask in preparation for a new
398 detection pass.
400 Parameters
401 ----------
402 mask : `lsst.afw.image.Mask`
403 Mask to be cleared.
404 """
405 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
407 def calculateKernelSize(self, sigma):
408 """Calculate the size of the smoothing kernel.
410 Uses the ``nSigmaForKernel`` configuration parameter. Note
411 that that is the full width of the kernel bounding box
412 (so a value of 7 means 3.5 sigma on either side of center).
413 The value will be rounded up to the nearest odd integer.
415 Parameters
416 ----------
417 sigma : `float`
418 Gaussian sigma of smoothing kernel.
420 Returns
421 -------
422 size : `int`
423 Size of the smoothing kernel.
424 """
425 return (int(sigma * self.config.nSigmaForKernel + 0.5)//2)*2 + 1 # make sure it is odd
427 def getPsf(self, exposure, sigma=None):
428 """Create a single Gaussian PSF for an exposure.
430 If ``sigma`` is provided, we make a `~lsst.afw.detection.GaussianPsf`
431 with that, otherwise use the sigma from the psf of the ``exposure`` to
432 make the `~lsst.afw.detection.GaussianPsf`.
434 Parameters
435 ----------
436 exposure : `lsst.afw.image.Exposure`
437 Exposure from which to retrieve the PSF.
438 sigma : `float`, optional
439 Gaussian sigma to use if provided.
441 Returns
442 -------
443 psf : `lsst.afw.detection.GaussianPsf`
444 PSF to use for detection.
446 Raises
447 ------
448 RuntimeError
449 Raised if ``sigma`` is not provided and ``exposure`` does not
450 contain a ``Psf`` object.
451 """
452 if sigma is None:
453 psf = exposure.getPsf()
454 if psf is None:
455 raise RuntimeError("Unable to determine PSF to use for detection: no sigma provided")
456 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
457 size = self.calculateKernelSize(sigma)
458 psf = afwDet.GaussianPsf(size, size, sigma)
459 return psf
461 def convolveImage(self, maskedImage, psf, doSmooth=True):
462 """Convolve the image with the PSF.
464 We convolve the image with a Gaussian approximation to the PSF,
465 because this is separable and therefore fast. It's technically a
466 correlation rather than a convolution, but since we use a symmetric
467 Gaussian there's no difference.
469 The convolution can be disabled with ``doSmooth=False``. If we do
470 convolve, we mask the edges as ``EDGE`` and return the convolved image
471 with the edges removed. This is because we can't convolve the edges
472 because the kernel would extend off the image.
474 Parameters
475 ----------
476 maskedImage : `lsst.afw.image.MaskedImage`
477 Image to convolve.
478 psf : `lsst.afw.detection.Psf`
479 PSF to convolve with (actually with a Gaussian approximation
480 to it).
481 doSmooth : `bool`
482 Actually do the convolution? Set to False when running on
483 e.g. a pre-convolved image, or a mask plane.
485 Returns
486 -------
487 results : `lsst.pipe.base.Struct`
488 The `~lsst.pipe.base.Struct` contains:
490 ``middle``
491 Convolved image, without the edges. (`lsst.afw.image.MaskedImage`)
492 ``sigma``
493 Gaussian sigma used for the convolution. (`float`)
494 """
495 self.metadata["doSmooth"] = doSmooth
496 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius()
497 self.metadata["sigma"] = sigma
499 if not doSmooth:
500 middle = maskedImage.Factory(maskedImage, deep=True)
501 return pipeBase.Struct(middle=middle, sigma=sigma)
503 # Smooth using a Gaussian (which is separable, hence fast) of width sigma
504 # Make a SingleGaussian (separable) kernel with the 'sigma'
505 kWidth = self.calculateKernelSize(sigma)
506 self.metadata["smoothingKernelWidth"] = kWidth
507 gaussFunc = afwMath.GaussianFunction1D(sigma)
508 gaussKernel = afwMath.SeparableKernel(kWidth, kWidth, gaussFunc, gaussFunc)
510 convolvedImage = maskedImage.Factory(maskedImage.getBBox())
512 afwMath.convolve(convolvedImage, maskedImage, gaussKernel, afwMath.ConvolutionControl())
514 # Only search psf-smoothed part of frame
515 goodBBox = gaussKernel.shrinkBBox(convolvedImage.getBBox())
516 middle = convolvedImage.Factory(convolvedImage, goodBBox, afwImage.PARENT, False)
518 # Mark the parts of the image outside goodBBox as EDGE
519 self.setEdgeBits(maskedImage, goodBBox, maskedImage.getMask().getPlaneBitMask("EDGE"))
521 return pipeBase.Struct(middle=middle, sigma=sigma)
523 def applyThreshold(self, middle, bbox, factor=1.0, factorNeg=None):
524 r"""Apply thresholds to the convolved image
526 Identifies `~lsst.afw.detection.Footprint`\s, both positive and negative.
527 The threshold can be modified by the provided multiplication
528 ``factor``.
530 Parameters
531 ----------
532 middle : `lsst.afw.image.MaskedImage`
533 Convolved image to threshold.
534 bbox : `lsst.geom.Box2I`
535 Bounding box of unconvolved image.
536 factor : `float`
537 Multiplier for the configured threshold.
538 factorNeg : `float` or `None`
539 Multiplier for the configured threshold for negative detection polarity.
540 If `None`, will be set equal to ``factor`` (i.e. equal to the factor used
541 for positive detection polarity).
543 Returns
544 -------
545 results : `lsst.pipe.base.Struct`
546 The `~lsst.pipe.base.Struct` contains:
548 ``positive``
549 Positive detection footprints, if configured.
550 (`lsst.afw.detection.FootprintSet` or `None`)
551 ``negative``
552 Negative detection footprints, if configured.
553 (`lsst.afw.detection.FootprintSet` or `None`)
554 ``factor``
555 Multiplier for the configured threshold.
556 (`float`)
557 ``factorNeg``
558 Multiplier for the configured threshold for negative detection polarity.
559 (`float`)
560 """
561 if factorNeg is None:
562 factorNeg = factor
563 self.log.info("Setting factor for negative detections equal to that for positive "
564 "detections: %f", factor)
565 results = pipeBase.Struct(positive=None, negative=None, factor=factor, factorNeg=factorNeg,
566 positiveThreshold=None, negativeThreshold=None)
567 # Detect the Footprints (peaks may be replaced if doTempLocalBackground)
568 if self.config.reEstimateBackground or self.config.thresholdPolarity != "negative":
569 results.positiveThreshold = self.makeThreshold(middle, "positive", factor=factor)
570 results.positive = afwDet.FootprintSet(
571 middle,
572 results.positiveThreshold,
573 "DETECTED",
574 self.config.minPixels
575 )
576 results.positive.setRegion(bbox)
577 if self.config.reEstimateBackground or self.config.thresholdPolarity != "positive":
578 results.negativeThreshold = self.makeThreshold(middle, "negative", factor=factorNeg)
579 results.negative = afwDet.FootprintSet(
580 middle,
581 results.negativeThreshold,
582 "DETECTED_NEGATIVE",
583 self.config.minPixels
584 )
585 results.negative.setRegion(bbox)
587 return results
589 def finalizeFootprints(self, mask, results, sigma, factor=1.0, factorNeg=None):
590 """Finalize the detected footprints.
592 Grow the footprints, set the ``DETECTED`` and ``DETECTED_NEGATIVE``
593 mask planes, and log the results.
595 ``numPos`` (number of positive footprints), ``numPosPeaks`` (number
596 of positive peaks), ``numNeg`` (number of negative footprints),
597 ``numNegPeaks`` (number of negative peaks) entries are added to the
598 ``results`` struct.
600 Parameters
601 ----------
602 mask : `lsst.afw.image.Mask`
603 Mask image on which to flag detected pixels.
604 results : `lsst.pipe.base.Struct`
605 Struct of detection results, including ``positive`` and
606 ``negative`` entries; modified.
607 sigma : `float`
608 Gaussian sigma of PSF.
609 factor : `float`
610 Multiplier for the configured threshold. Note that this is only
611 used here for logging purposes.
612 factorNeg : `float` or `None`
613 Multiplier used for the negative detection polarity threshold.
614 If `None`, a factor equal to ``factor`` (i.e. equal to the one used
615 for positive detection polarity) is assumed. Note that this is only
616 used here for logging purposes.
617 """
618 factorNeg = factor if factorNeg is None else factorNeg
619 for polarity, maskName in (("positive", "DETECTED"), ("negative", "DETECTED_NEGATIVE")):
620 fpSet = getattr(results, polarity)
621 if fpSet is None:
622 continue
623 if self.config.nSigmaToGrow > 0:
624 nGrow = int((self.config.nSigmaToGrow * sigma) + 0.5)
625 self.metadata["nGrow"] = nGrow
626 if self.config.combinedGrow:
627 fpSet = afwDet.FootprintSet(fpSet, nGrow, self.config.isotropicGrow)
628 else:
629 stencil = (afwGeom.Stencil.CIRCLE if self.config.isotropicGrow else
630 afwGeom.Stencil.MANHATTAN)
631 for fp in fpSet:
632 fp.dilate(nGrow, stencil)
633 fpSet.setMask(mask, maskName)
634 if not self.config.returnOriginalFootprints:
635 setattr(results, polarity, fpSet)
637 results.numPos = 0
638 results.numPosPeaks = 0
639 results.numNeg = 0
640 results.numNegPeaks = 0
641 positive = ""
642 negative = ""
644 if results.positive is not None:
645 results.numPos = len(results.positive.getFootprints())
646 results.numPosPeaks = sum(len(fp.getPeaks()) for fp in results.positive.getFootprints())
647 positive = " %d positive peaks in %d footprints" % (results.numPosPeaks, results.numPos)
648 if results.negative is not None:
649 results.numNeg = len(results.negative.getFootprints())
650 results.numNegPeaks = sum(len(fp.getPeaks()) for fp in results.negative.getFootprints())
651 negative = " %d negative peaks in %d footprints" % (results.numNegPeaks, results.numNeg)
653 self.log.info("Detected%s%s%s to %g +ve and %g -ve %s",
654 positive, " and" if positive and negative else "", negative,
655 self.config.thresholdValue*self.config.includeThresholdMultiplier*factor,
656 self.config.thresholdValue*self.config.includeThresholdMultiplier*factorNeg,
657 "DN" if self.config.thresholdType == "value" else "sigma")
659 def reEstimateBackground(self, maskedImage, backgrounds):
660 """Estimate the background after detection
662 Parameters
663 ----------
664 maskedImage : `lsst.afw.image.MaskedImage`
665 Image on which to estimate the background.
666 backgrounds : `lsst.afw.math.BackgroundList`
667 List of backgrounds; modified.
669 Returns
670 -------
671 bg : `lsst.afw.math.backgroundMI`
672 Empirical background model.
673 """
674 bg = self.background.fitBackground(maskedImage)
675 if self.config.adjustBackground:
676 self.log.warning("Fiddling the background by %g", self.config.adjustBackground)
677 bg += self.config.adjustBackground
678 self.log.info("Resubtracting the background after object detection")
679 maskedImage -= bg.getImageF(self.background.config.algorithm,
680 self.background.config.undersampleStyle)
682 actrl = bg.getBackgroundControl().getApproximateControl()
683 backgrounds.append((bg, getattr(afwMath.Interpolate, self.background.config.algorithm),
684 bg.getAsUsedUndersampleStyle(), actrl.getStyle(), actrl.getOrderX(),
685 actrl.getOrderY(), actrl.getWeighting()))
686 return bg
688 def clearUnwantedResults(self, mask, results):
689 """Clear unwanted results from the Struct of results
691 If we specifically want only positive or only negative detections,
692 drop the ones we don't want, and its associated mask plane.
694 Parameters
695 ----------
696 mask : `lsst.afw.image.Mask`
697 Mask image.
698 results : `lsst.pipe.base.Struct`
699 Detection results, with ``positive`` and ``negative`` elements;
700 modified.
701 """
702 if self.config.thresholdPolarity == "positive":
703 if self.config.reEstimateBackground:
704 mask &= ~mask.getPlaneBitMask("DETECTED_NEGATIVE")
705 results.negative = None
706 elif self.config.thresholdPolarity == "negative":
707 if self.config.reEstimateBackground:
708 mask &= ~mask.getPlaneBitMask("DETECTED")
709 results.positive = None
711 @timeMethod
712 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
713 background=None):
714 """Detect footprints on an exposure.
716 Parameters
717 ----------
718 exposure : `lsst.afw.image.Exposure`
719 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
720 set in-place.
721 doSmooth : `bool`, optional
722 If True, smooth the image before detection using a Gaussian
723 of width ``sigma``, or the measured PSF width of ``exposure``.
724 Set to False when running on e.g. a pre-convolved image, or a mask
725 plane.
726 sigma : `float`, optional
727 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
728 detections; if `None` then measure the sigma of the PSF of the
729 ``exposure``.
730 clearMask : `bool`, optional
731 Clear both DETECTED and DETECTED_NEGATIVE planes before running
732 detection.
733 expId : `dict`, optional
734 Exposure identifier; unused by this implementation, but used for
735 RNG seed by subclasses.
736 background : `lsst.afw.math.BackgroundList`, optional
737 Background that was already subtracted from the exposure; will be
738 modified in-place if ``reEstimateBackground=True``.
740 Returns
741 -------
742 results : `lsst.pipe.base.Struct`
743 A `~lsst.pipe.base.Struct` containing:
745 ``positive``
746 Positive polarity footprints.
747 (`lsst.afw.detection.FootprintSet` or `None`)
748 ``negative``
749 Negative polarity footprints.
750 (`lsst.afw.detection.FootprintSet` or `None`)
751 ``numPos``
752 Number of footprints in positive or 0 if detection polarity was
753 negative. (`int`)
754 ``numNeg``
755 Number of footprints in negative or 0 if detection polarity was
756 positive. (`int`)
757 ``background``
758 Re-estimated background. `None` or the input ``background``
759 if ``reEstimateBackground==False``.
760 (`lsst.afw.math.BackgroundList`)
761 ``factor``
762 Multiplication factor applied to the configured detection
763 threshold. (`float`)
764 """
765 maskedImage = exposure.maskedImage
767 if clearMask:
768 self.clearMask(maskedImage.getMask())
770 psf = self.getPsf(exposure, sigma=sigma)
771 with self.tempWideBackgroundContext(exposure):
772 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
773 middle = convolveResults.middle
774 sigma = convolveResults.sigma
775 self.removeBadPixels(middle)
777 results = self.applyThreshold(middle, maskedImage.getBBox())
778 results.background = background if background is not None else afwMath.BackgroundList()
780 if self.config.doTempLocalBackground:
781 self.applyTempLocalBackground(exposure, middle, results)
782 self.finalizeFootprints(maskedImage.mask, results, sigma)
784 # Compute the significance of peaks after the peaks have been
785 # finalized and after local background correction/updatePeaks, so
786 # that the significance represents the "final" detection S/N.
787 results.positive = self.setPeakSignificance(middle, results.positive, results.positiveThreshold)
788 results.negative = self.setPeakSignificance(middle, results.negative, results.negativeThreshold,
789 negative=True)
791 if self.config.reEstimateBackground:
792 self.reEstimateBackground(maskedImage, results.background)
794 self.clearUnwantedResults(maskedImage.getMask(), results)
796 self.display(exposure, results, middle)
798 return results
800 def removeBadPixels(self, middle):
801 """Set the significance of flagged pixels to zero.
803 Parameters
804 ----------
805 middle : `lsst.afw.image.ExposureF`
806 Score or maximum likelihood difference image.
807 The image plane will be modified in place.
808 """
809 badPixelMask = lsst.afw.image.Mask.getPlaneBitMask(self.config.excludeMaskPlanes)
810 badPixels = middle.mask.array & badPixelMask > 0
811 middle.image.array[badPixels] = 0
813 def setPeakSignificance(self, exposure, footprints, threshold, negative=False):
814 """Set the significance of each detected peak to the pixel value divided
815 by the appropriate standard-deviation for ``config.thresholdType``.
817 Only sets significance for "stdev" and "pixel_stdev" thresholdTypes;
818 we leave it undefined for "value" and "variance" as it does not have a
819 well-defined meaning in those cases.
821 Parameters
822 ----------
823 exposure : `lsst.afw.image.Exposure`
824 Exposure that footprints were detected on, likely the convolved,
825 local background-subtracted image.
826 footprints : `lsst.afw.detection.FootprintSet`
827 Footprints detected on the image.
828 threshold : `lsst.afw.detection.Threshold`
829 Threshold used to find footprints.
830 negative : `bool`, optional
831 Are we calculating for negative sources?
832 """
833 if footprints is None or footprints.getFootprints() == []:
834 return footprints
835 polarity = -1 if negative else 1
837 # All incoming footprints have the same schema.
838 mapper = afwTable.SchemaMapper(footprints.getFootprints()[0].peaks.schema)
839 mapper.addMinimalSchema(footprints.getFootprints()[0].peaks.schema)
840 mapper.addOutputField("significance", type=float,
841 doc="Ratio of peak value to configured standard deviation.")
843 # Copy the old peaks to the new ones with a significance field.
844 # Do this independent of the threshold type, so we always have a
845 # significance field.
846 newFootprints = afwDet.FootprintSet(footprints)
847 for old, new in zip(footprints.getFootprints(), newFootprints.getFootprints()):
848 newPeaks = afwDet.PeakCatalog(mapper.getOutputSchema())
849 newPeaks.extend(old.peaks, mapper=mapper)
850 new.getPeaks().clear()
851 new.setPeakCatalog(newPeaks)
853 # Compute the significance values.
854 if self.config.thresholdType == "pixel_stdev":
855 for footprint in newFootprints.getFootprints():
856 footprint.updatePeakSignificance(exposure.variance, polarity)
857 elif self.config.thresholdType == "stdev":
858 sigma = threshold.getValue() / self.config.thresholdValue
859 for footprint in newFootprints.getFootprints():
860 footprint.updatePeakSignificance(polarity*sigma)
861 else:
862 for footprint in newFootprints.getFootprints():
863 for peak in footprint.peaks:
864 peak["significance"] = 0
866 return newFootprints
868 def makeThreshold(self, image, thresholdParity, factor=1.0):
869 """Make an afw.detection.Threshold object corresponding to the task's
870 configuration and the statistics of the given image.
872 Parameters
873 ----------
874 image : `afw.image.MaskedImage`
875 Image to measure noise statistics from if needed.
876 thresholdParity: `str`
877 One of "positive" or "negative", to set the kind of fluctuations
878 the Threshold will detect.
879 factor : `float`
880 Factor by which to multiply the configured detection threshold.
881 This is useful for tweaking the detection threshold slightly.
883 Returns
884 -------
885 threshold : `lsst.afw.detection.Threshold`
886 Detection threshold.
887 """
888 parity = False if thresholdParity == "negative" else True
889 thresholdValue = self.config.thresholdValue
890 thresholdType = self.config.thresholdType
891 if self.config.thresholdType == 'stdev':
892 bad = image.getMask().getPlaneBitMask(self.config.statsMask)
893 sctrl = afwMath.StatisticsControl()
894 sctrl.setAndMask(bad)
895 stats = afwMath.makeStatistics(image, afwMath.STDEVCLIP, sctrl)
896 thresholdValue *= stats.getValue(afwMath.STDEVCLIP)
897 thresholdType = 'value'
899 threshold = afwDet.createThreshold(thresholdValue*factor, thresholdType, parity)
900 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier)
901 self.log.debug("Detection threshold: %s", threshold)
902 return threshold
904 def updatePeaks(self, fpSet, image, threshold):
905 """Update the Peaks in a FootprintSet by detecting new Footprints and
906 Peaks in an image and using the new Peaks instead of the old ones.
908 Parameters
909 ----------
910 fpSet : `afw.detection.FootprintSet`
911 Set of Footprints whose Peaks should be updated.
912 image : `afw.image.MaskedImage`
913 Image to detect new Footprints and Peak in.
914 threshold : `afw.detection.Threshold`
915 Threshold object for detection.
917 Input Footprints with fewer Peaks than self.config.nPeaksMaxSimple
918 are not modified, and if no new Peaks are detected in an input
919 Footprint, the brightest original Peak in that Footprint is kept.
920 """
921 for footprint in fpSet.getFootprints():
922 oldPeaks = footprint.getPeaks()
923 if len(oldPeaks) <= self.config.nPeaksMaxSimple:
924 continue
925 # We detect a new FootprintSet within each non-simple Footprint's
926 # bbox to avoid a big O(N^2) comparison between the two sets of
927 # Footprints.
928 sub = image.Factory(image, footprint.getBBox())
929 fpSetForPeaks = afwDet.FootprintSet(
930 sub,
931 threshold,
932 "", # don't set a mask plane
933 self.config.minPixels
934 )
935 newPeaks = afwDet.PeakCatalog(oldPeaks.getTable())
936 for fpForPeaks in fpSetForPeaks.getFootprints():
937 for peak in fpForPeaks.getPeaks():
938 if footprint.contains(peak.getI()):
939 newPeaks.append(peak)
940 if len(newPeaks) > 0:
941 del oldPeaks[:]
942 oldPeaks.extend(newPeaks)
943 else:
944 del oldPeaks[1:]
946 @staticmethod
947 def setEdgeBits(maskedImage, goodBBox, edgeBitmask):
948 """Set the edgeBitmask bits for all of maskedImage outside goodBBox
950 Parameters
951 ----------
952 maskedImage : `lsst.afw.image.MaskedImage`
953 Image on which to set edge bits in the mask.
954 goodBBox : `lsst.geom.Box2I`
955 Bounding box of good pixels, in ``LOCAL`` coordinates.
956 edgeBitmask : `lsst.afw.image.MaskPixel`
957 Bit mask to OR with the existing mask bits in the region
958 outside ``goodBBox``.
959 """
960 msk = maskedImage.getMask()
962 mx0, my0 = maskedImage.getXY0()
963 for x0, y0, w, h in ([0, 0,
964 msk.getWidth(), goodBBox.getBeginY() - my0],
965 [0, goodBBox.getEndY() - my0, msk.getWidth(),
966 maskedImage.getHeight() - (goodBBox.getEndY() - my0)],
967 [0, 0,
968 goodBBox.getBeginX() - mx0, msk.getHeight()],
969 [goodBBox.getEndX() - mx0, 0,
970 maskedImage.getWidth() - (goodBBox.getEndX() - mx0), msk.getHeight()],
971 ):
972 edgeMask = msk.Factory(msk, lsst.geom.BoxI(lsst.geom.PointI(x0, y0),
973 lsst.geom.ExtentI(w, h)), afwImage.LOCAL)
974 edgeMask |= edgeBitmask
976 @contextmanager
977 def tempWideBackgroundContext(self, exposure):
978 """Context manager for removing wide (large-scale) background
980 Removing a wide (large-scale) background helps to suppress the
981 detection of large footprints that may overwhelm the deblender.
982 It does, however, set a limit on the maximum scale of objects.
984 The background that we remove will be restored upon exit from
985 the context manager.
987 Parameters
988 ----------
989 exposure : `lsst.afw.image.Exposure`
990 Exposure on which to remove large-scale background.
992 Returns
993 -------
994 context : context manager
995 Context manager that will ensure the temporary wide background
996 is restored.
997 """
998 doTempWideBackground = self.config.doTempWideBackground
999 if doTempWideBackground:
1000 self.log.info("Applying temporary wide background subtraction")
1001 original = exposure.maskedImage.image.array[:].copy()
1002 self.tempWideBackground.run(exposure).background
1003 # Remove NO_DATA regions (e.g., edge of the field-of-view); these can cause detections after
1004 # subtraction because of extrapolation of the background model into areas with no constraints.
1005 image = exposure.maskedImage.image
1006 mask = exposure.maskedImage.mask
1007 noData = mask.array & mask.getPlaneBitMask("NO_DATA") > 0
1008 isGood = mask.array & mask.getPlaneBitMask(self.config.statsMask) == 0
1009 image.array[noData] = np.median(image.array[~noData & isGood])
1010 try:
1011 yield
1012 finally:
1013 if doTempWideBackground:
1014 exposure.maskedImage.image.array[:] = original
1017def addExposures(exposureList):
1018 """Add a set of exposures together.
1020 Parameters
1021 ----------
1022 exposureList : `list` of `lsst.afw.image.Exposure`
1023 Sequence of exposures to add.
1025 Returns
1026 -------
1027 addedExposure : `lsst.afw.image.Exposure`
1028 An exposure of the same size as each exposure in ``exposureList``,
1029 with the metadata from ``exposureList[0]`` and a masked image equal
1030 to the sum of all the exposure's masked images.
1031 """
1032 exposure0 = exposureList[0]
1033 image0 = exposure0.getMaskedImage()
1035 addedImage = image0.Factory(image0, True)
1036 addedImage.setXY0(image0.getXY0())
1038 for exposure in exposureList[1:]:
1039 image = exposure.getMaskedImage()
1040 addedImage += image
1042 addedExposure = exposure0.Factory(addedImage, exposure0.getWcs())
1043 return addedExposure