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