Coverage for python/lsst/meas/algorithms/detection.py : 15%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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
27from deprecated.sphinx import deprecated
29import numpy as np
31import lsst.geom
32import lsst.afw.display as afwDisplay
33import lsst.afw.detection as afwDet
34import lsst.afw.geom as afwGeom
35import lsst.afw.image as afwImage
36import lsst.afw.math as afwMath
37import lsst.afw.table as afwTable
38import lsst.pex.config as pexConfig
39import lsst.pipe.base as pipeBase
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)
161## @addtogroup LSST_task_documentation
162## @{
163## @page sourceDetectionTask
164## @ref SourceDetectionTask_ "SourceDetectionTask"
165## @copybrief SourceDetectionTask
166## @}
169class SourceDetectionTask(pipeBase.Task):
170 r"""!
171@anchor SourceDetectionTask_
173@brief Detect positive and negative sources on an exposure and return a new @link table.SourceCatalog@endlink.
175@section meas_algorithms_detection_Contents Contents
177 - @ref meas_algorithms_detection_Purpose
178 - @ref meas_algorithms_detection_Initialize
179 - @ref meas_algorithms_detection_Invoke
180 - @ref meas_algorithms_detection_Config
181 - @ref meas_algorithms_detection_Debug
182 - @ref meas_algorithms_detection_Example
184@section meas_algorithms_detection_Purpose Description
186@copybrief SourceDetectionTask
188@section meas_algorithms_detection_Initialize Task initialisation
190@copydoc \_\_init\_\_
192@section meas_algorithms_detection_Invoke Invoking the Task
194@copydoc run
196@section meas_algorithms_detection_Config Configuration parameters
198See @ref SourceDetectionConfig
200@section meas_algorithms_detection_Debug Debug variables
202The @link lsst.pipe.base.cmdLineTask.CmdLineTask command line task@endlink interface supports a
203flag @c -d to import @b debug.py from your @c PYTHONPATH; see @ref baseDebug for more about @b debug.py files.
205The available variables in SourceDetectionTask are:
206<DL>
207 <DT> @c display
208 <DD>
209 - If True, display the exposure on afwDisplay.Display's frame 0.
210 +ve detections in blue, -ve detections in cyan
211 - If display > 1, display the convolved exposure on frame 1
212</DL>
214@section meas_algorithms_detection_Example A complete example of using SourceDetectionTask
216This code is in @link measAlgTasks.py@endlink in the examples directory, and can be run as @em e.g.
217@code
218examples/measAlgTasks.py --doDisplay
219@endcode
220@dontinclude measAlgTasks.py
221The example also runs the SingleFrameMeasurementTask; see @ref meas_algorithms_measurement_Example for more
222explanation.
224Import the task (there are some other standard imports; read the file if you're confused)
225@skipline SourceDetectionTask
227We need to create our task before processing any data as the task constructor
228can add an extra column to the schema, but first we need an almost-empty Schema
229@skipline makeMinimalSchema
230after which we can call the constructor:
231@skip SourceDetectionTask.ConfigClass
232@until detectionTask
234We're now ready to process the data (we could loop over multiple exposures/catalogues using the same
235task objects). First create the output table:
236@skipline afwTable
238And process the image
239@skipline result
240(You may not be happy that the threshold was set in the config before creating the Task rather than being set
241separately for each exposure. You @em can reset it just before calling the run method if you must, but we
242should really implement a better solution).
244We can then unpack and use the results:
245@skip sources
246@until print
248<HR>
249To investigate the @ref meas_algorithms_detection_Debug, put something like
250@code{.py}
251 import lsstDebug
252 def DebugInfo(name):
253 di = lsstDebug.getInfo(name) # N.b. lsstDebug.Info(name) would call us recursively
254 if name == "lsst.meas.algorithms.detection":
255 di.display = 1
257 return di
259 lsstDebug.Info = DebugInfo
260@endcode
261into your debug.py file and run measAlgTasks.py with the @c --debug flag.
262 """
263 ConfigClass = SourceDetectionConfig
264 _DefaultName = "sourceDetection"
266 def __init__(self, schema=None, **kwds):
267 """!Create the detection task. Most arguments are simply passed onto pipe.base.Task.
269 @param schema An lsst::afw::table::Schema used to create the output lsst.afw.table.SourceCatalog
270 @param **kwds Keyword arguments passed to lsst.pipe.base.task.Task.__init__.
272 If schema is not None and configured for 'both' detections,
273 a 'flags.negative' field will be added to label detections made with a
274 negative threshold.
276 @note This task can add fields to the schema, so any code calling this task must ensure that
277 these columns are indeed present in the input match list; see @ref Example
278 """
279 pipeBase.Task.__init__(self, **kwds)
280 if schema is not None and self.config.thresholdPolarity == "both":
281 self.negativeFlagKey = schema.addField(
282 "flags_negative", type="Flag",
283 doc="set if source was detected as significantly negative"
284 )
285 else:
286 if self.config.thresholdPolarity == "both":
287 self.log.warn("Detection polarity set to 'both', but no flag will be "
288 "set to distinguish between positive and negative detections")
289 self.negativeFlagKey = None
290 if self.config.reEstimateBackground:
291 self.makeSubtask("background")
292 if self.config.doTempLocalBackground:
293 self.makeSubtask("tempLocalBackground")
294 if self.config.doTempWideBackground:
295 self.makeSubtask("tempWideBackground")
297 @pipeBase.timeMethod
298 def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
299 """Run source detection and create a SourceCatalog of detections.
301 Parameters
302 ----------
303 table : `lsst.afw.table.SourceTable`
304 Table object that will be used to create the SourceCatalog.
305 exposure : `lsst.afw.image.Exposure`
306 Exposure to process; DETECTED mask plane will be set in-place.
307 doSmooth : `bool`
308 If True, smooth the image before detection using a Gaussian of width
309 ``sigma``, or the measured PSF width. Set to False when running on
310 e.g. a pre-convolved image, or a mask plane.
311 sigma : `float`
312 Sigma of PSF (pixels); used for smoothing and to grow detections;
313 if None then measure the sigma of the PSF of the exposure
314 clearMask : `bool`
315 Clear DETECTED{,_NEGATIVE} planes before running detection.
316 expId : `int`
317 Exposure identifier; unused by this implementation, but used for
318 RNG seed by subclasses.
320 Returns
321 -------
322 result : `lsst.pipe.base.Struct`
323 ``sources``
324 The detected sources (`lsst.afw.table.SourceCatalog`)
325 ``fpSets``
326 The result resturned by `detectFootprints`
327 (`lsst.pipe.base.Struct`).
329 Raises
330 ------
331 ValueError
332 If flags.negative is needed, but isn't in table's schema.
333 lsst.pipe.base.TaskError
334 If sigma=None, doSmooth=True and the exposure has no PSF.
336 Notes
337 -----
338 If you want to avoid dealing with Sources and Tables, you can use
339 detectFootprints() to just get the `lsst.afw.detection.FootprintSet`s.
340 """
341 if self.negativeFlagKey is not None and self.negativeFlagKey not in table.getSchema():
342 raise ValueError("Table has incorrect Schema")
343 results = self.detectFootprints(exposure=exposure, doSmooth=doSmooth, sigma=sigma,
344 clearMask=clearMask, expId=expId)
345 sources = afwTable.SourceCatalog(table)
346 sources.reserve(results.numPos + results.numNeg)
347 if results.negative:
348 results.negative.makeSources(sources)
349 if self.negativeFlagKey:
350 for record in sources:
351 record.set(self.negativeFlagKey, True)
352 if results.positive:
353 results.positive.makeSources(sources)
354 results.fpSets = results.copy() # Backward compatibility
355 results.sources = sources
356 return results
358 @deprecated(reason="Replaced by SourceDetectionTask.run(). Will be removed after v20.",
359 category=FutureWarning)
360 def makeSourceCatalog(self, *args, **kwargs):
361 return self.run(*args, **kwargs)
363 def display(self, exposure, results, convolvedImage=None):
364 """Display detections if so configured
366 Displays the ``exposure`` in frame 0, overlays the detection peaks.
368 Requires that ``lsstDebug`` has been set up correctly, so that
369 ``lsstDebug.Info("lsst.meas.algorithms.detection")`` evaluates `True`.
371 If the ``convolvedImage`` is non-`None` and
372 ``lsstDebug.Info("lsst.meas.algorithms.detection") > 1``, the
373 ``convolvedImage`` will be displayed in frame 1.
375 Parameters
376 ----------
377 exposure : `lsst.afw.image.Exposure`
378 Exposure to display, on which will be plotted the detections.
379 results : `lsst.pipe.base.Struct`
380 Results of the 'detectFootprints' method, containing positive and
381 negative footprints (which contain the peak positions that we will
382 plot). This is a `Struct` with ``positive`` and ``negative``
383 elements that are of type `lsst.afw.detection.FootprintSet`.
384 convolvedImage : `lsst.afw.image.Image`, optional
385 Convolved image used for thresholding.
386 """
387 try:
388 import lsstDebug
389 display = lsstDebug.Info(__name__).display
390 except ImportError:
391 try:
392 display
393 except NameError:
394 display = False
395 if not display:
396 return
398 afwDisplay.setDefaultMaskTransparency(75)
400 disp0 = afwDisplay.Display(frame=0)
401 disp0.mtv(exposure, title="detection")
403 def plotPeaks(fps, ctype):
404 if fps is None:
405 return
406 with disp0.Buffering():
407 for fp in fps.getFootprints():
408 for pp in fp.getPeaks():
409 disp0.dot("+", pp.getFx(), pp.getFy(), ctype=ctype)
410 plotPeaks(results.positive, "yellow")
411 plotPeaks(results.negative, "red")
413 if convolvedImage and display > 1:
414 disp1 = afwDisplay.Display(frame=1)
415 disp1.mtv(convolvedImage, title="PSF smoothed")
417 def applyTempLocalBackground(self, exposure, middle, results):
418 """Apply a temporary local background subtraction
420 This temporary local background serves to suppress noise fluctuations
421 in the wings of bright objects.
423 Peaks in the footprints will be updated.
425 Parameters
426 ----------
427 exposure : `lsst.afw.image.Exposure`
428 Exposure for which to fit local background.
429 middle : `lsst.afw.image.MaskedImage`
430 Convolved image on which detection will be performed
431 (typically smaller than ``exposure`` because the
432 half-kernel has been removed around the edges).
433 results : `lsst.pipe.base.Struct`
434 Results of the 'detectFootprints' method, containing positive and
435 negative footprints (which contain the peak positions that we will
436 plot). This is a `Struct` with ``positive`` and ``negative``
437 elements that are of type `lsst.afw.detection.FootprintSet`.
438 """
439 # Subtract the local background from the smoothed image. Since we
440 # never use the smoothed again we don't need to worry about adding
441 # it back in.
442 bg = self.tempLocalBackground.fitBackground(exposure.getMaskedImage())
443 bgImage = bg.getImageF(self.tempLocalBackground.config.algorithm,
444 self.tempLocalBackground.config.undersampleStyle)
445 middle -= bgImage.Factory(bgImage, middle.getBBox())
446 thresholdPos = self.makeThreshold(middle, "positive")
447 thresholdNeg = self.makeThreshold(middle, "negative")
448 if self.config.thresholdPolarity != "negative":
449 self.updatePeaks(results.positive, middle, thresholdPos)
450 if self.config.thresholdPolarity != "positive":
451 self.updatePeaks(results.negative, middle, thresholdNeg)
453 def clearMask(self, mask):
454 """Clear the DETECTED and DETECTED_NEGATIVE mask planes
456 Removes any previous detection mask in preparation for a new
457 detection pass.
459 Parameters
460 ----------
461 mask : `lsst.afw.image.Mask`
462 Mask to be cleared.
463 """
464 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
466 def calculateKernelSize(self, sigma):
467 """Calculate size of smoothing kernel
469 Uses the ``nSigmaForKernel`` configuration parameter. Note
470 that that is the full width of the kernel bounding box
471 (so a value of 7 means 3.5 sigma on either side of center).
472 The value will be rounded up to the nearest odd integer.
474 Parameters
475 ----------
476 sigma : `float`
477 Gaussian sigma of smoothing kernel.
479 Returns
480 -------
481 size : `int`
482 Size of the smoothing kernel.
483 """
484 return (int(sigma * self.config.nSigmaForKernel + 0.5)//2)*2 + 1 # make sure it is odd
486 def getPsf(self, exposure, sigma=None):
487 """Retrieve the PSF for an exposure
489 If ``sigma`` is provided, we make a ``GaussianPsf`` with that,
490 otherwise use the one from the ``exposure``.
492 Parameters
493 ----------
494 exposure : `lsst.afw.image.Exposure`
495 Exposure from which to retrieve the PSF.
496 sigma : `float`, optional
497 Gaussian sigma to use if provided.
499 Returns
500 -------
501 psf : `lsst.afw.detection.Psf`
502 PSF to use for detection.
503 """
504 if sigma is None:
505 psf = exposure.getPsf()
506 if psf is None:
507 raise RuntimeError("Unable to determine PSF to use for detection: no sigma provided")
508 sigma = psf.computeShape().getDeterminantRadius()
509 size = self.calculateKernelSize(sigma)
510 psf = afwDet.GaussianPsf(size, size, sigma)
511 return psf
513 def convolveImage(self, maskedImage, psf, doSmooth=True):
514 """Convolve the image with the PSF
516 We convolve the image with a Gaussian approximation to the PSF,
517 because this is separable and therefore fast. It's technically a
518 correlation rather than a convolution, but since we use a symmetric
519 Gaussian there's no difference.
521 The convolution can be disabled with ``doSmooth=False``. If we do
522 convolve, we mask the edges as ``EDGE`` and return the convolved image
523 with the edges removed. This is because we can't convolve the edges
524 because the kernel would extend off the image.
526 Parameters
527 ----------
528 maskedImage : `lsst.afw.image.MaskedImage`
529 Image to convolve.
530 psf : `lsst.afw.detection.Psf`
531 PSF to convolve with (actually with a Gaussian approximation
532 to it).
533 doSmooth : `bool`
534 Actually do the convolution? Set to False when running on
535 e.g. a pre-convolved image, or a mask plane.
537 Return Struct contents
538 ----------------------
539 middle : `lsst.afw.image.MaskedImage`
540 Convolved image, without the edges.
541 sigma : `float`
542 Gaussian sigma used for the convolution.
543 """
544 self.metadata.set("doSmooth", doSmooth)
545 sigma = psf.computeShape().getDeterminantRadius()
546 self.metadata.set("sigma", sigma)
548 if not doSmooth:
549 middle = maskedImage.Factory(maskedImage)
550 return pipeBase.Struct(middle=middle, sigma=sigma)
552 # Smooth using a Gaussian (which is separable, hence fast) of width sigma
553 # Make a SingleGaussian (separable) kernel with the 'sigma'
554 kWidth = self.calculateKernelSize(sigma)
555 self.metadata.set("smoothingKernelWidth", kWidth)
556 gaussFunc = afwMath.GaussianFunction1D(sigma)
557 gaussKernel = afwMath.SeparableKernel(kWidth, kWidth, gaussFunc, gaussFunc)
559 convolvedImage = maskedImage.Factory(maskedImage.getBBox())
561 afwMath.convolve(convolvedImage, maskedImage, gaussKernel, afwMath.ConvolutionControl())
562 #
563 # Only search psf-smoothed part of frame
564 #
565 goodBBox = gaussKernel.shrinkBBox(convolvedImage.getBBox())
566 middle = convolvedImage.Factory(convolvedImage, goodBBox, afwImage.PARENT, False)
567 #
568 # Mark the parts of the image outside goodBBox as EDGE
569 #
570 self.setEdgeBits(maskedImage, goodBBox, maskedImage.getMask().getPlaneBitMask("EDGE"))
572 return pipeBase.Struct(middle=middle, sigma=sigma)
574 def applyThreshold(self, middle, bbox, factor=1.0):
575 """Apply thresholds to the convolved image
577 Identifies ``Footprint``s, both positive and negative.
579 The threshold can be modified by the provided multiplication
580 ``factor``.
582 Parameters
583 ----------
584 middle : `lsst.afw.image.MaskedImage`
585 Convolved image to threshold.
586 bbox : `lsst.geom.Box2I`
587 Bounding box of unconvolved image.
588 factor : `float`
589 Multiplier for the configured threshold.
591 Return Struct contents
592 ----------------------
593 positive : `lsst.afw.detection.FootprintSet` or `None`
594 Positive detection footprints, if configured.
595 negative : `lsst.afw.detection.FootprintSet` or `None`
596 Negative detection footprints, if configured.
597 factor : `float`
598 Multiplier for the configured threshold.
599 """
600 results = pipeBase.Struct(positive=None, negative=None, factor=factor)
601 # Detect the Footprints (peaks may be replaced if doTempLocalBackground)
602 if self.config.reEstimateBackground or self.config.thresholdPolarity != "negative":
603 threshold = self.makeThreshold(middle, "positive", factor=factor)
604 results.positive = afwDet.FootprintSet(
605 middle,
606 threshold,
607 "DETECTED",
608 self.config.minPixels
609 )
610 results.positive.setRegion(bbox)
611 if self.config.reEstimateBackground or self.config.thresholdPolarity != "positive":
612 threshold = self.makeThreshold(middle, "negative", factor=factor)
613 results.negative = afwDet.FootprintSet(
614 middle,
615 threshold,
616 "DETECTED_NEGATIVE",
617 self.config.minPixels
618 )
619 results.negative.setRegion(bbox)
621 return results
623 def finalizeFootprints(self, mask, results, sigma, factor=1.0):
624 """Finalize the detected footprints
626 Grows the footprints, sets the ``DETECTED`` and ``DETECTED_NEGATIVE``
627 mask planes, and logs the results.
629 ``numPos`` (number of positive footprints), ``numPosPeaks`` (number
630 of positive peaks), ``numNeg`` (number of negative footprints),
631 ``numNegPeaks`` (number of negative peaks) entries are added to the
632 detection results.
634 Parameters
635 ----------
636 mask : `lsst.afw.image.Mask`
637 Mask image on which to flag detected pixels.
638 results : `lsst.pipe.base.Struct`
639 Struct of detection results, including ``positive`` and
640 ``negative`` entries; modified.
641 sigma : `float`
642 Gaussian sigma of PSF.
643 factor : `float`
644 Multiplier for the configured threshold.
645 """
646 for polarity, maskName in (("positive", "DETECTED"), ("negative", "DETECTED_NEGATIVE")):
647 fpSet = getattr(results, polarity)
648 if fpSet is None:
649 continue
650 if self.config.nSigmaToGrow > 0:
651 nGrow = int((self.config.nSigmaToGrow * sigma) + 0.5)
652 self.metadata.set("nGrow", nGrow)
653 if self.config.combinedGrow:
654 fpSet = afwDet.FootprintSet(fpSet, nGrow, self.config.isotropicGrow)
655 else:
656 stencil = (afwGeom.Stencil.CIRCLE if self.config.isotropicGrow else
657 afwGeom.Stencil.MANHATTAN)
658 for fp in fpSet:
659 fp.dilate(nGrow, stencil)
660 fpSet.setMask(mask, maskName)
661 if not self.config.returnOriginalFootprints:
662 setattr(results, polarity, fpSet)
664 results.numPos = 0
665 results.numPosPeaks = 0
666 results.numNeg = 0
667 results.numNegPeaks = 0
668 positive = ""
669 negative = ""
671 if results.positive is not None:
672 results.numPos = len(results.positive.getFootprints())
673 results.numPosPeaks = sum(len(fp.getPeaks()) for fp in results.positive.getFootprints())
674 positive = " %d positive peaks in %d footprints" % (results.numPosPeaks, results.numPos)
675 if results.negative is not None:
676 results.numNeg = len(results.negative.getFootprints())
677 results.numNegPeaks = sum(len(fp.getPeaks()) for fp in results.negative.getFootprints())
678 negative = " %d negative peaks in %d footprints" % (results.numNegPeaks, results.numNeg)
680 self.log.info("Detected%s%s%s to %g %s" %
681 (positive, " and" if positive and negative else "", negative,
682 self.config.thresholdValue*self.config.includeThresholdMultiplier*factor,
683 "DN" if self.config.thresholdType == "value" else "sigma"))
685 def reEstimateBackground(self, maskedImage, backgrounds):
686 """Estimate the background after detection
688 Parameters
689 ----------
690 maskedImage : `lsst.afw.image.MaskedImage`
691 Image on which to estimate the background.
692 backgrounds : `lsst.afw.math.BackgroundList`
693 List of backgrounds; modified.
695 Returns
696 -------
697 bg : `lsst.afw.math.backgroundMI`
698 Empirical background model.
699 """
700 bg = self.background.fitBackground(maskedImage)
701 if self.config.adjustBackground:
702 self.log.warn("Fiddling the background by %g", self.config.adjustBackground)
703 bg += self.config.adjustBackground
704 self.log.info("Resubtracting the background after object detection")
705 maskedImage -= bg.getImageF(self.background.config.algorithm,
706 self.background.config.undersampleStyle)
708 actrl = bg.getBackgroundControl().getApproximateControl()
709 backgrounds.append((bg, getattr(afwMath.Interpolate, self.background.config.algorithm),
710 bg.getAsUsedUndersampleStyle(), actrl.getStyle(), actrl.getOrderX(),
711 actrl.getOrderY(), actrl.getWeighting()))
712 return bg
714 def clearUnwantedResults(self, mask, results):
715 """Clear unwanted results from the Struct of results
717 If we specifically want only positive or only negative detections,
718 drop the ones we don't want, and its associated mask plane.
720 Parameters
721 ----------
722 mask : `lsst.afw.image.Mask`
723 Mask image.
724 results : `lsst.pipe.base.Struct`
725 Detection results, with ``positive`` and ``negative`` elements;
726 modified.
727 """
728 if self.config.thresholdPolarity == "positive":
729 if self.config.reEstimateBackground:
730 mask &= ~mask.getPlaneBitMask("DETECTED_NEGATIVE")
731 results.negative = None
732 elif self.config.thresholdPolarity == "negative":
733 if self.config.reEstimateBackground:
734 mask &= ~mask.getPlaneBitMask("DETECTED")
735 results.positive = None
737 @pipeBase.timeMethod
738 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
739 """Detect footprints on an exposure.
741 Parameters
742 ----------
743 exposure : `lsst.afw.image.Exposure`
744 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
745 set in-place.
746 doSmooth : `bool`, optional
747 If True, smooth the image before detection using a Gaussian
748 of width ``sigma``, or the measured PSF width of ``exposure``.
749 Set to False when running on e.g. a pre-convolved image, or a mask
750 plane.
751 sigma : `float`, optional
752 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
753 detections; if `None` then measure the sigma of the PSF of the
754 ``exposure``.
755 clearMask : `bool`, optional
756 Clear both DETECTED and DETECTED_NEGATIVE planes before running
757 detection.
758 expId : `dict`, optional
759 Exposure identifier; unused by this implementation, but used for
760 RNG seed by subclasses.
762 Return Struct contents
763 ----------------------
764 positive : `lsst.afw.detection.FootprintSet`
765 Positive polarity footprints (may be `None`)
766 negative : `lsst.afw.detection.FootprintSet`
767 Negative polarity footprints (may be `None`)
768 numPos : `int`
769 Number of footprints in positive or 0 if detection polarity was
770 negative.
771 numNeg : `int`
772 Number of footprints in negative or 0 if detection polarity was
773 positive.
774 background : `lsst.afw.math.BackgroundList`
775 Re-estimated background. `None` if
776 ``reEstimateBackground==False``.
777 factor : `float`
778 Multiplication factor applied to the configured detection
779 threshold.
780 """
781 maskedImage = exposure.maskedImage
783 if clearMask:
784 self.clearMask(maskedImage.getMask())
786 psf = self.getPsf(exposure, sigma=sigma)
787 with self.tempWideBackgroundContext(exposure):
788 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
789 middle = convolveResults.middle
790 sigma = convolveResults.sigma
792 results = self.applyThreshold(middle, maskedImage.getBBox())
793 results.background = afwMath.BackgroundList()
794 if self.config.doTempLocalBackground:
795 self.applyTempLocalBackground(exposure, middle, results)
796 self.finalizeFootprints(maskedImage.mask, results, sigma)
798 if self.config.reEstimateBackground:
799 self.reEstimateBackground(maskedImage, results.background)
801 self.clearUnwantedResults(maskedImage.getMask(), results)
802 self.display(exposure, results, middle)
804 return results
806 def makeThreshold(self, image, thresholdParity, factor=1.0):
807 """Make an afw.detection.Threshold object corresponding to the task's
808 configuration and the statistics of the given image.
810 Parameters
811 ----------
812 image : `afw.image.MaskedImage`
813 Image to measure noise statistics from if needed.
814 thresholdParity: `str`
815 One of "positive" or "negative", to set the kind of fluctuations
816 the Threshold will detect.
817 factor : `float`
818 Factor by which to multiply the configured detection threshold.
819 This is useful for tweaking the detection threshold slightly.
821 Returns
822 -------
823 threshold : `lsst.afw.detection.Threshold`
824 Detection threshold.
825 """
826 parity = False if thresholdParity == "negative" else True
827 thresholdValue = self.config.thresholdValue
828 thresholdType = self.config.thresholdType
829 if self.config.thresholdType == 'stdev':
830 bad = image.getMask().getPlaneBitMask(self.config.statsMask)
831 sctrl = afwMath.StatisticsControl()
832 sctrl.setAndMask(bad)
833 stats = afwMath.makeStatistics(image, afwMath.STDEVCLIP, sctrl)
834 thresholdValue *= stats.getValue(afwMath.STDEVCLIP)
835 thresholdType = 'value'
837 threshold = afwDet.createThreshold(thresholdValue*factor, thresholdType, parity)
838 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier)
839 return threshold
841 def updatePeaks(self, fpSet, image, threshold):
842 """Update the Peaks in a FootprintSet by detecting new Footprints and
843 Peaks in an image and using the new Peaks instead of the old ones.
845 Parameters
846 ----------
847 fpSet : `afw.detection.FootprintSet`
848 Set of Footprints whose Peaks should be updated.
849 image : `afw.image.MaskedImage`
850 Image to detect new Footprints and Peak in.
851 threshold : `afw.detection.Threshold`
852 Threshold object for detection.
854 Input Footprints with fewer Peaks than self.config.nPeaksMaxSimple
855 are not modified, and if no new Peaks are detected in an input
856 Footprint, the brightest original Peak in that Footprint is kept.
857 """
858 for footprint in fpSet.getFootprints():
859 oldPeaks = footprint.getPeaks()
860 if len(oldPeaks) <= self.config.nPeaksMaxSimple:
861 continue
862 # We detect a new FootprintSet within each non-simple Footprint's
863 # bbox to avoid a big O(N^2) comparison between the two sets of
864 # Footprints.
865 sub = image.Factory(image, footprint.getBBox())
866 fpSetForPeaks = afwDet.FootprintSet(
867 sub,
868 threshold,
869 "", # don't set a mask plane
870 self.config.minPixels
871 )
872 newPeaks = afwDet.PeakCatalog(oldPeaks.getTable())
873 for fpForPeaks in fpSetForPeaks.getFootprints():
874 for peak in fpForPeaks.getPeaks():
875 if footprint.contains(peak.getI()):
876 newPeaks.append(peak)
877 if len(newPeaks) > 0:
878 del oldPeaks[:]
879 oldPeaks.extend(newPeaks)
880 else:
881 del oldPeaks[1:]
883 @staticmethod
884 def setEdgeBits(maskedImage, goodBBox, edgeBitmask):
885 """Set the edgeBitmask bits for all of maskedImage outside goodBBox
887 Parameters
888 ----------
889 maskedImage : `lsst.afw.image.MaskedImage`
890 Image on which to set edge bits in the mask.
891 goodBBox : `lsst.geom.Box2I`
892 Bounding box of good pixels, in ``LOCAL`` coordinates.
893 edgeBitmask : `lsst.afw.image.MaskPixel`
894 Bit mask to OR with the existing mask bits in the region
895 outside ``goodBBox``.
896 """
897 msk = maskedImage.getMask()
899 mx0, my0 = maskedImage.getXY0()
900 for x0, y0, w, h in ([0, 0,
901 msk.getWidth(), goodBBox.getBeginY() - my0],
902 [0, goodBBox.getEndY() - my0, msk.getWidth(),
903 maskedImage.getHeight() - (goodBBox.getEndY() - my0)],
904 [0, 0,
905 goodBBox.getBeginX() - mx0, msk.getHeight()],
906 [goodBBox.getEndX() - mx0, 0,
907 maskedImage.getWidth() - (goodBBox.getEndX() - mx0), msk.getHeight()],
908 ):
909 edgeMask = msk.Factory(msk, lsst.geom.BoxI(lsst.geom.PointI(x0, y0),
910 lsst.geom.ExtentI(w, h)), afwImage.LOCAL)
911 edgeMask |= edgeBitmask
913 @contextmanager
914 def tempWideBackgroundContext(self, exposure):
915 """Context manager for removing wide (large-scale) background
917 Removing a wide (large-scale) background helps to suppress the
918 detection of large footprints that may overwhelm the deblender.
919 It does, however, set a limit on the maximum scale of objects.
921 The background that we remove will be restored upon exit from
922 the context manager.
924 Parameters
925 ----------
926 exposure : `lsst.afw.image.Exposure`
927 Exposure on which to remove large-scale background.
929 Returns
930 -------
931 context : context manager
932 Context manager that will ensure the temporary wide background
933 is restored.
934 """
935 doTempWideBackground = self.config.doTempWideBackground
936 if doTempWideBackground:
937 self.log.info("Applying temporary wide background subtraction")
938 original = exposure.maskedImage.image.array[:].copy()
939 self.tempWideBackground.run(exposure).background
940 # Remove NO_DATA regions (e.g., edge of the field-of-view); these can cause detections after
941 # subtraction because of extrapolation of the background model into areas with no constraints.
942 image = exposure.maskedImage.image
943 mask = exposure.maskedImage.mask
944 noData = mask.array & mask.getPlaneBitMask("NO_DATA") > 0
945 isGood = mask.array & mask.getPlaneBitMask(self.config.statsMask) == 0
946 image.array[noData] = np.median(image.array[~noData & isGood])
947 try:
948 yield
949 finally:
950 if doTempWideBackground:
951 exposure.maskedImage.image.array[:] = original
954def addExposures(exposureList):
955 """Add a set of exposures together.
957 Parameters
958 ----------
959 exposureList : `list` of `lsst.afw.image.Exposure`
960 Sequence of exposures to add.
962 Returns
963 -------
964 addedExposure : `lsst.afw.image.Exposure`
965 An exposure of the same size as each exposure in ``exposureList``,
966 with the metadata from ``exposureList[0]`` and a masked image equal
967 to the sum of all the exposure's masked images.
968 """
969 exposure0 = exposureList[0]
970 image0 = exposure0.getMaskedImage()
972 addedImage = image0.Factory(image0, True)
973 addedImage.setXY0(image0.getXY0())
975 for exposure in exposureList[1:]:
976 image = exposure.getMaskedImage()
977 addedImage += image
979 addedExposure = exposure0.Factory(addedImage, exposure0.getWcs())
980 return addedExposure