lsst.meas.algorithms  19.0.0-5-gd943061d
detection.py
Go to the documentation of this file.
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 #
23 
24 __all__ = ("SourceDetectionConfig", "SourceDetectionTask", "addExposures")
25 
26 from contextlib import contextmanager
27 from deprecated.sphinx import deprecated
28 
29 import numpy as np
30 
31 import lsst.geom
32 import lsst.afw.display as afwDisplay
33 import lsst.afw.detection as afwDet
34 import lsst.afw.geom as afwGeom
35 import lsst.afw.image as afwImage
36 import lsst.afw.math as afwMath
37 import lsst.afw.table as afwTable
38 import lsst.pex.config as pexConfig
39 import lsst.pipe.base as pipeBase
40 from .subtractBackground import SubtractBackgroundTask
41 
42 
43 class 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 
167 
168 
169 class SourceDetectionTask(pipeBase.Task):
170  r"""!
171 @anchor SourceDetectionTask_
172 
173 @brief Detect positive and negative sources on an exposure and return a new @link table.SourceCatalog@endlink.
174 
175 @section meas_algorithms_detection_Contents Contents
176 
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
183 
184 @section meas_algorithms_detection_Purpose Description
185 
186 @copybrief SourceDetectionTask
187 
188 @section meas_algorithms_detection_Initialize Task initialisation
189 
190 @copydoc \_\_init\_\_
191 
192 @section meas_algorithms_detection_Invoke Invoking the Task
193 
194 @copydoc run
195 
196 @section meas_algorithms_detection_Config Configuration parameters
197 
198 See @ref SourceDetectionConfig
199 
200 @section meas_algorithms_detection_Debug Debug variables
201 
202 The @link lsst.pipe.base.cmdLineTask.CmdLineTask command line task@endlink interface supports a
203 flag @c -d to import @b debug.py from your @c PYTHONPATH; see @ref baseDebug for more about @b debug.py files.
204 
205 The 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>
213 
214 @section meas_algorithms_detection_Example A complete example of using SourceDetectionTask
215 
216 This code is in @link measAlgTasks.py@endlink in the examples directory, and can be run as @em e.g.
217 @code
218 examples/measAlgTasks.py --doDisplay
219 @endcode
220 @dontinclude measAlgTasks.py
221 The example also runs the SingleFrameMeasurementTask; see @ref meas_algorithms_measurement_Example for more
222 explanation.
223 
224 Import the task (there are some other standard imports; read the file if you're confused)
225 @skipline SourceDetectionTask
226 
227 We need to create our task before processing any data as the task constructor
228 can add an extra column to the schema, but first we need an almost-empty Schema
229 @skipline makeMinimalSchema
230 after which we can call the constructor:
231 @skip SourceDetectionTask.ConfigClass
232 @until detectionTask
233 
234 We're now ready to process the data (we could loop over multiple exposures/catalogues using the same
235 task objects). First create the output table:
236 @skipline afwTable
237 
238 And 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
241 separately for each exposure. You @em can reset it just before calling the run method if you must, but we
242 should really implement a better solution).
243 
244 We can then unpack and use the results:
245 @skip sources
246 @until print
247 
248 <HR>
249 To 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
256 
257  return di
258 
259  lsstDebug.Info = DebugInfo
260 @endcode
261 into your debug.py file and run measAlgTasks.py with the @c --debug flag.
262  """
263  ConfigClass = SourceDetectionConfig
264  _DefaultName = "sourceDetection"
265 
266  def __init__(self, schema=None, **kwds):
267  """!Create the detection task. Most arguments are simply passed onto pipe.base.Task.
268 
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__.
271 
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.
275 
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")
296 
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.
300 
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.
319 
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`).
328 
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.
335 
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
357 
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)
362 
363  def display(self, exposure, results, convolvedImage=None):
364  """Display detections if so configured
365 
366  Displays the ``exposure`` in frame 0, overlays the detection peaks.
367 
368  Requires that ``lsstDebug`` has been set up correctly, so that
369  ``lsstDebug.Info("lsst.meas.algorithms.detection")`` evaluates `True`.
370 
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.
374 
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
397 
398  afwDisplay.setDefaultMaskTransparency(75)
399 
400  disp0 = afwDisplay.Display(frame=0)
401  disp0.mtv(exposure, title="detection")
402 
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")
412 
413  if convolvedImage and display > 1:
414  disp1 = afwDisplay.Display(frame=1)
415  disp1.mtv(convolvedImage, title="PSF smoothed")
416 
417  def applyTempLocalBackground(self, exposure, middle, results):
418  """Apply a temporary local background subtraction
419 
420  This temporary local background serves to suppress noise fluctuations
421  in the wings of bright objects.
422 
423  Peaks in the footprints will be updated.
424 
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()
444  middle -= bgImage.Factory(bgImage, middle.getBBox())
445  thresholdPos = self.makeThreshold(middle, "positive")
446  thresholdNeg = self.makeThreshold(middle, "negative")
447  if self.config.thresholdPolarity != "negative":
448  self.updatePeaks(results.positive, middle, thresholdPos)
449  if self.config.thresholdPolarity != "positive":
450  self.updatePeaks(results.negative, middle, thresholdNeg)
451 
452  def clearMask(self, mask):
453  """Clear the DETECTED and DETECTED_NEGATIVE mask planes
454 
455  Removes any previous detection mask in preparation for a new
456  detection pass.
457 
458  Parameters
459  ----------
460  mask : `lsst.afw.image.Mask`
461  Mask to be cleared.
462  """
463  mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE"))
464 
465  def calculateKernelSize(self, sigma):
466  """Calculate size of smoothing kernel
467 
468  Uses the ``nSigmaForKernel`` configuration parameter. Note
469  that that is the full width of the kernel bounding box
470  (so a value of 7 means 3.5 sigma on either side of center).
471  The value will be rounded up to the nearest odd integer.
472 
473  Parameters
474  ----------
475  sigma : `float`
476  Gaussian sigma of smoothing kernel.
477 
478  Returns
479  -------
480  size : `int`
481  Size of the smoothing kernel.
482  """
483  return (int(sigma * self.config.nSigmaForKernel + 0.5)//2)*2 + 1 # make sure it is odd
484 
485  def getPsf(self, exposure, sigma=None):
486  """Retrieve the PSF for an exposure
487 
488  If ``sigma`` is provided, we make a ``GaussianPsf`` with that,
489  otherwise use the one from the ``exposure``.
490 
491  Parameters
492  ----------
493  exposure : `lsst.afw.image.Exposure`
494  Exposure from which to retrieve the PSF.
495  sigma : `float`, optional
496  Gaussian sigma to use if provided.
497 
498  Returns
499  -------
500  psf : `lsst.afw.detection.Psf`
501  PSF to use for detection.
502  """
503  if sigma is None:
504  psf = exposure.getPsf()
505  if psf is None:
506  raise RuntimeError("Unable to determine PSF to use for detection: no sigma provided")
507  sigma = psf.computeShape().getDeterminantRadius()
508  size = self.calculateKernelSize(sigma)
509  psf = afwDet.GaussianPsf(size, size, sigma)
510  return psf
511 
512  def convolveImage(self, maskedImage, psf, doSmooth=True):
513  """Convolve the image with the PSF
514 
515  We convolve the image with a Gaussian approximation to the PSF,
516  because this is separable and therefore fast. It's technically a
517  correlation rather than a convolution, but since we use a symmetric
518  Gaussian there's no difference.
519 
520  The convolution can be disabled with ``doSmooth=False``. If we do
521  convolve, we mask the edges as ``EDGE`` and return the convolved image
522  with the edges removed. This is because we can't convolve the edges
523  because the kernel would extend off the image.
524 
525  Parameters
526  ----------
527  maskedImage : `lsst.afw.image.MaskedImage`
528  Image to convolve.
529  psf : `lsst.afw.detection.Psf`
530  PSF to convolve with (actually with a Gaussian approximation
531  to it).
532  doSmooth : `bool`
533  Actually do the convolution? Set to False when running on
534  e.g. a pre-convolved image, or a mask plane.
535 
536  Return Struct contents
537  ----------------------
538  middle : `lsst.afw.image.MaskedImage`
539  Convolved image, without the edges.
540  sigma : `float`
541  Gaussian sigma used for the convolution.
542  """
543  self.metadata.set("doSmooth", doSmooth)
544  sigma = psf.computeShape().getDeterminantRadius()
545  self.metadata.set("sigma", sigma)
546 
547  if not doSmooth:
548  middle = maskedImage.Factory(maskedImage)
549  return pipeBase.Struct(middle=middle, sigma=sigma)
550 
551  # Smooth using a Gaussian (which is separable, hence fast) of width sigma
552  # Make a SingleGaussian (separable) kernel with the 'sigma'
553  kWidth = self.calculateKernelSize(sigma)
554  self.metadata.set("smoothingKernelWidth", kWidth)
555  gaussFunc = afwMath.GaussianFunction1D(sigma)
556  gaussKernel = afwMath.SeparableKernel(kWidth, kWidth, gaussFunc, gaussFunc)
557 
558  convolvedImage = maskedImage.Factory(maskedImage.getBBox())
559 
560  afwMath.convolve(convolvedImage, maskedImage, gaussKernel, afwMath.ConvolutionControl())
561  #
562  # Only search psf-smoothed part of frame
563  #
564  goodBBox = gaussKernel.shrinkBBox(convolvedImage.getBBox())
565  middle = convolvedImage.Factory(convolvedImage, goodBBox, afwImage.PARENT, False)
566  #
567  # Mark the parts of the image outside goodBBox as EDGE
568  #
569  self.setEdgeBits(maskedImage, goodBBox, maskedImage.getMask().getPlaneBitMask("EDGE"))
570 
571  return pipeBase.Struct(middle=middle, sigma=sigma)
572 
573  def applyThreshold(self, middle, bbox, factor=1.0):
574  """Apply thresholds to the convolved image
575 
576  Identifies ``Footprint``s, both positive and negative.
577 
578  The threshold can be modified by the provided multiplication
579  ``factor``.
580 
581  Parameters
582  ----------
583  middle : `lsst.afw.image.MaskedImage`
584  Convolved image to threshold.
585  bbox : `lsst.geom.Box2I`
586  Bounding box of unconvolved image.
587  factor : `float`
588  Multiplier for the configured threshold.
589 
590  Return Struct contents
591  ----------------------
592  positive : `lsst.afw.detection.FootprintSet` or `None`
593  Positive detection footprints, if configured.
594  negative : `lsst.afw.detection.FootprintSet` or `None`
595  Negative detection footprints, if configured.
596  factor : `float`
597  Multiplier for the configured threshold.
598  """
599  results = pipeBase.Struct(positive=None, negative=None, factor=factor)
600  # Detect the Footprints (peaks may be replaced if doTempLocalBackground)
601  if self.config.reEstimateBackground or self.config.thresholdPolarity != "negative":
602  threshold = self.makeThreshold(middle, "positive", factor=factor)
603  results.positive = afwDet.FootprintSet(
604  middle,
605  threshold,
606  "DETECTED",
607  self.config.minPixels
608  )
609  results.positive.setRegion(bbox)
610  if self.config.reEstimateBackground or self.config.thresholdPolarity != "positive":
611  threshold = self.makeThreshold(middle, "negative", factor=factor)
612  results.negative = afwDet.FootprintSet(
613  middle,
614  threshold,
615  "DETECTED_NEGATIVE",
616  self.config.minPixels
617  )
618  results.negative.setRegion(bbox)
619 
620  return results
621 
622  def finalizeFootprints(self, mask, results, sigma, factor=1.0):
623  """Finalize the detected footprints
624 
625  Grows the footprints, sets the ``DETECTED`` and ``DETECTED_NEGATIVE``
626  mask planes, and logs the results.
627 
628  ``numPos`` (number of positive footprints), ``numPosPeaks`` (number
629  of positive peaks), ``numNeg`` (number of negative footprints),
630  ``numNegPeaks`` (number of negative peaks) entries are added to the
631  detection results.
632 
633  Parameters
634  ----------
635  mask : `lsst.afw.image.Mask`
636  Mask image on which to flag detected pixels.
637  results : `lsst.pipe.base.Struct`
638  Struct of detection results, including ``positive`` and
639  ``negative`` entries; modified.
640  sigma : `float`
641  Gaussian sigma of PSF.
642  factor : `float`
643  Multiplier for the configured threshold.
644  """
645  for polarity, maskName in (("positive", "DETECTED"), ("negative", "DETECTED_NEGATIVE")):
646  fpSet = getattr(results, polarity)
647  if fpSet is None:
648  continue
649  if self.config.nSigmaToGrow > 0:
650  nGrow = int((self.config.nSigmaToGrow * sigma) + 0.5)
651  self.metadata.set("nGrow", nGrow)
652  if self.config.combinedGrow:
653  fpSet = afwDet.FootprintSet(fpSet, nGrow, self.config.isotropicGrow)
654  else:
655  stencil = (afwGeom.Stencil.CIRCLE if self.config.isotropicGrow else
656  afwGeom.Stencil.MANHATTAN)
657  for fp in fpSet:
658  fp.dilate(nGrow, stencil)
659  fpSet.setMask(mask, maskName)
660  if not self.config.returnOriginalFootprints:
661  setattr(results, polarity, fpSet)
662 
663  results.numPos = 0
664  results.numPosPeaks = 0
665  results.numNeg = 0
666  results.numNegPeaks = 0
667  positive = ""
668  negative = ""
669 
670  if results.positive is not None:
671  results.numPos = len(results.positive.getFootprints())
672  results.numPosPeaks = sum(len(fp.getPeaks()) for fp in results.positive.getFootprints())
673  positive = " %d positive peaks in %d footprints" % (results.numPosPeaks, results.numPos)
674  if results.negative is not None:
675  results.numNeg = len(results.negative.getFootprints())
676  results.numNegPeaks = sum(len(fp.getPeaks()) for fp in results.negative.getFootprints())
677  negative = " %d negative peaks in %d footprints" % (results.numNegPeaks, results.numNeg)
678 
679  self.log.info("Detected%s%s%s to %g %s" %
680  (positive, " and" if positive and negative else "", negative,
681  self.config.thresholdValue*self.config.includeThresholdMultiplier*factor,
682  "DN" if self.config.thresholdType == "value" else "sigma"))
683 
684  def reEstimateBackground(self, maskedImage, backgrounds):
685  """Estimate the background after detection
686 
687  Parameters
688  ----------
689  maskedImage : `lsst.afw.image.MaskedImage`
690  Image on which to estimate the background.
691  backgrounds : `lsst.afw.math.BackgroundList`
692  List of backgrounds; modified.
693 
694  Returns
695  -------
696  bg : `lsst.afw.math.backgroundMI`
697  Empirical background model.
698  """
699  bg = self.background.fitBackground(maskedImage)
700  if self.config.adjustBackground:
701  self.log.warn("Fiddling the background by %g", self.config.adjustBackground)
702  bg += self.config.adjustBackground
703  self.log.info("Resubtracting the background after object detection")
704  maskedImage -= bg.getImageF()
705  backgrounds.append(bg)
706  return bg
707 
708  def clearUnwantedResults(self, mask, results):
709  """Clear unwanted results from the Struct of results
710 
711  If we specifically want only positive or only negative detections,
712  drop the ones we don't want, and its associated mask plane.
713 
714  Parameters
715  ----------
716  mask : `lsst.afw.image.Mask`
717  Mask image.
718  results : `lsst.pipe.base.Struct`
719  Detection results, with ``positive`` and ``negative`` elements;
720  modified.
721  """
722  if self.config.thresholdPolarity == "positive":
723  if self.config.reEstimateBackground:
724  mask &= ~mask.getPlaneBitMask("DETECTED_NEGATIVE")
725  results.negative = None
726  elif self.config.thresholdPolarity == "negative":
727  if self.config.reEstimateBackground:
728  mask &= ~mask.getPlaneBitMask("DETECTED")
729  results.positive = None
730 
731  @pipeBase.timeMethod
732  def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
733  """Detect footprints on an exposure.
734 
735  Parameters
736  ----------
737  exposure : `lsst.afw.image.Exposure`
738  Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
739  set in-place.
740  doSmooth : `bool`, optional
741  If True, smooth the image before detection using a Gaussian
742  of width ``sigma``, or the measured PSF width of ``exposure``.
743  Set to False when running on e.g. a pre-convolved image, or a mask
744  plane.
745  sigma : `float`, optional
746  Gaussian Sigma of PSF (pixels); used for smoothing and to grow
747  detections; if `None` then measure the sigma of the PSF of the
748  ``exposure``.
749  clearMask : `bool`, optional
750  Clear both DETECTED and DETECTED_NEGATIVE planes before running
751  detection.
752  expId : `dict`, optional
753  Exposure identifier; unused by this implementation, but used for
754  RNG seed by subclasses.
755 
756  Return Struct contents
757  ----------------------
758  positive : `lsst.afw.detection.FootprintSet`
759  Positive polarity footprints (may be `None`)
760  negative : `lsst.afw.detection.FootprintSet`
761  Negative polarity footprints (may be `None`)
762  numPos : `int`
763  Number of footprints in positive or 0 if detection polarity was
764  negative.
765  numNeg : `int`
766  Number of footprints in negative or 0 if detection polarity was
767  positive.
768  background : `lsst.afw.math.BackgroundList`
769  Re-estimated background. `None` if
770  ``reEstimateBackground==False``.
771  factor : `float`
772  Multiplication factor applied to the configured detection
773  threshold.
774  """
775  maskedImage = exposure.maskedImage
776 
777  if clearMask:
778  self.clearMask(maskedImage.getMask())
779 
780  psf = self.getPsf(exposure, sigma=sigma)
781  with self.tempWideBackgroundContext(exposure):
782  convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
783  middle = convolveResults.middle
784  sigma = convolveResults.sigma
785 
786  results = self.applyThreshold(middle, maskedImage.getBBox())
787  results.background = afwMath.BackgroundList()
788  if self.config.doTempLocalBackground:
789  self.applyTempLocalBackground(exposure, middle, results)
790  self.finalizeFootprints(maskedImage.mask, results, sigma)
791 
792  if self.config.reEstimateBackground:
793  self.reEstimateBackground(maskedImage, results.background)
794 
795  self.clearUnwantedResults(maskedImage.getMask(), results)
796  self.display(exposure, results, middle)
797 
798  return results
799 
800  def makeThreshold(self, image, thresholdParity, factor=1.0):
801  """Make an afw.detection.Threshold object corresponding to the task's
802  configuration and the statistics of the given image.
803 
804  Parameters
805  ----------
806  image : `afw.image.MaskedImage`
807  Image to measure noise statistics from if needed.
808  thresholdParity: `str`
809  One of "positive" or "negative", to set the kind of fluctuations
810  the Threshold will detect.
811  factor : `float`
812  Factor by which to multiply the configured detection threshold.
813  This is useful for tweaking the detection threshold slightly.
814 
815  Returns
816  -------
817  threshold : `lsst.afw.detection.Threshold`
818  Detection threshold.
819  """
820  parity = False if thresholdParity == "negative" else True
821  thresholdValue = self.config.thresholdValue
822  thresholdType = self.config.thresholdType
823  if self.config.thresholdType == 'stdev':
824  bad = image.getMask().getPlaneBitMask(self.config.statsMask)
825  sctrl = afwMath.StatisticsControl()
826  sctrl.setAndMask(bad)
827  stats = afwMath.makeStatistics(image, afwMath.STDEVCLIP, sctrl)
828  thresholdValue *= stats.getValue(afwMath.STDEVCLIP)
829  thresholdType = 'value'
830 
831  threshold = afwDet.createThreshold(thresholdValue*factor, thresholdType, parity)
832  threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier)
833  return threshold
834 
835  def updatePeaks(self, fpSet, image, threshold):
836  """Update the Peaks in a FootprintSet by detecting new Footprints and
837  Peaks in an image and using the new Peaks instead of the old ones.
838 
839  Parameters
840  ----------
841  fpSet : `afw.detection.FootprintSet`
842  Set of Footprints whose Peaks should be updated.
843  image : `afw.image.MaskedImage`
844  Image to detect new Footprints and Peak in.
845  threshold : `afw.detection.Threshold`
846  Threshold object for detection.
847 
848  Input Footprints with fewer Peaks than self.config.nPeaksMaxSimple
849  are not modified, and if no new Peaks are detected in an input
850  Footprint, the brightest original Peak in that Footprint is kept.
851  """
852  for footprint in fpSet.getFootprints():
853  oldPeaks = footprint.getPeaks()
854  if len(oldPeaks) <= self.config.nPeaksMaxSimple:
855  continue
856  # We detect a new FootprintSet within each non-simple Footprint's
857  # bbox to avoid a big O(N^2) comparison between the two sets of
858  # Footprints.
859  sub = image.Factory(image, footprint.getBBox())
860  fpSetForPeaks = afwDet.FootprintSet(
861  sub,
862  threshold,
863  "", # don't set a mask plane
864  self.config.minPixels
865  )
866  newPeaks = afwDet.PeakCatalog(oldPeaks.getTable())
867  for fpForPeaks in fpSetForPeaks.getFootprints():
868  for peak in fpForPeaks.getPeaks():
869  if footprint.contains(peak.getI()):
870  newPeaks.append(peak)
871  if len(newPeaks) > 0:
872  del oldPeaks[:]
873  oldPeaks.extend(newPeaks)
874  else:
875  del oldPeaks[1:]
876 
877  @staticmethod
878  def setEdgeBits(maskedImage, goodBBox, edgeBitmask):
879  """Set the edgeBitmask bits for all of maskedImage outside goodBBox
880 
881  Parameters
882  ----------
883  maskedImage : `lsst.afw.image.MaskedImage`
884  Image on which to set edge bits in the mask.
885  goodBBox : `lsst.geom.Box2I`
886  Bounding box of good pixels, in ``LOCAL`` coordinates.
887  edgeBitmask : `lsst.afw.image.MaskPixel`
888  Bit mask to OR with the existing mask bits in the region
889  outside ``goodBBox``.
890  """
891  msk = maskedImage.getMask()
892 
893  mx0, my0 = maskedImage.getXY0()
894  for x0, y0, w, h in ([0, 0,
895  msk.getWidth(), goodBBox.getBeginY() - my0],
896  [0, goodBBox.getEndY() - my0, msk.getWidth(),
897  maskedImage.getHeight() - (goodBBox.getEndY() - my0)],
898  [0, 0,
899  goodBBox.getBeginX() - mx0, msk.getHeight()],
900  [goodBBox.getEndX() - mx0, 0,
901  maskedImage.getWidth() - (goodBBox.getEndX() - mx0), msk.getHeight()],
902  ):
903  edgeMask = msk.Factory(msk, lsst.geom.BoxI(lsst.geom.PointI(x0, y0),
904  lsst.geom.ExtentI(w, h)), afwImage.LOCAL)
905  edgeMask |= edgeBitmask
906 
907  @contextmanager
908  def tempWideBackgroundContext(self, exposure):
909  """Context manager for removing wide (large-scale) background
910 
911  Removing a wide (large-scale) background helps to suppress the
912  detection of large footprints that may overwhelm the deblender.
913  It does, however, set a limit on the maximum scale of objects.
914 
915  The background that we remove will be restored upon exit from
916  the context manager.
917 
918  Parameters
919  ----------
920  exposure : `lsst.afw.image.Exposure`
921  Exposure on which to remove large-scale background.
922 
923  Returns
924  -------
925  context : context manager
926  Context manager that will ensure the temporary wide background
927  is restored.
928  """
929  doTempWideBackground = self.config.doTempWideBackground
930  if doTempWideBackground:
931  self.log.info("Applying temporary wide background subtraction")
932  original = exposure.maskedImage.image.array[:].copy()
933  self.tempWideBackground.run(exposure).background
934  # Remove NO_DATA regions (e.g., edge of the field-of-view); these can cause detections after
935  # subtraction because of extrapolation of the background model into areas with no constraints.
936  image = exposure.maskedImage.image
937  mask = exposure.maskedImage.mask
938  noData = mask.array & mask.getPlaneBitMask("NO_DATA") > 0
939  isGood = mask.array & mask.getPlaneBitMask(self.config.statsMask) == 0
940  image.array[noData] = np.median(image.array[~noData & isGood])
941  try:
942  yield
943  finally:
944  if doTempWideBackground:
945  exposure.maskedImage.image.array[:] = original
946 
947 
948 def addExposures(exposureList):
949  """Add a set of exposures together.
950 
951  Parameters
952  ----------
953  exposureList : `list` of `lsst.afw.image.Exposure`
954  Sequence of exposures to add.
955 
956  Returns
957  -------
958  addedExposure : `lsst.afw.image.Exposure`
959  An exposure of the same size as each exposure in ``exposureList``,
960  with the metadata from ``exposureList[0]`` and a masked image equal
961  to the sum of all the exposure's masked images.
962  """
963  exposure0 = exposureList[0]
964  image0 = exposure0.getMaskedImage()
965 
966  addedImage = image0.Factory(image0, True)
967  addedImage.setXY0(image0.getXY0())
968 
969  for exposure in exposureList[1:]:
970  image = exposure.getMaskedImage()
971  addedImage += image
972 
973  addedExposure = exposure0.Factory(addedImage, exposure0.getWcs())
974  return addedExposure
def updatePeaks(self, fpSet, image, threshold)
Definition: detection.py:835
def applyThreshold(self, middle, bbox, factor=1.0)
Definition: detection.py:573
def addExposures(exposureList)
Definition: detection.py:948
def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None)
Definition: detection.py:732
def convolveImage(self, maskedImage, psf, doSmooth=True)
Definition: detection.py:512
def applyTempLocalBackground(self, exposure, middle, results)
Definition: detection.py:417
def display(self, exposure, results, convolvedImage=None)
Definition: detection.py:363
Detect positive and negative sources on an exposure and return a new table.SourceCatalog.
Definition: detection.py:169
def makeThreshold(self, image, thresholdParity, factor=1.0)
Definition: detection.py:800
Configuration parameters for the SourceDetectionTask.
Definition: detection.py:43
def getPsf(self, exposure, sigma=None)
Definition: detection.py:485
def finalizeFootprints(self, mask, results, sigma, factor=1.0)
Definition: detection.py:622
def __init__(self, schema=None, kwds)
Create the detection task.
Definition: detection.py:266
def reEstimateBackground(self, maskedImage, backgrounds)
Definition: detection.py:684
def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None)
Definition: detection.py:298
def setEdgeBits(maskedImage, goodBBox, edgeBitmask)
Definition: detection.py:878