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