lsst.meas.algorithms  15.0-5-ge02c9e7e+5
dynamicDetection.py
Go to the documentation of this file.
1 from __future__ import absolute_import, division, print_function
2 
3 __all__ = ["DynamicDetectionConfig", "DynamicDetectionTask"]
4 
5 import numpy as np
6 
7 from lsst.pex.config import Field, ConfigurableField
8 from lsst.pipe.base import Task, Struct
9 
10 from .detection import SourceDetectionConfig, SourceDetectionTask
11 from .skyObjects import SkyObjectsTask
12 
13 from lsst.afw.detection import FootprintSet
14 from lsst.afw.table import SourceCatalog, SourceTable, IdFactory
15 from lsst.meas.base import ForcedMeasurementTask
16 
17 import lsst.afw.image
18 import lsst.afw.math
19 
20 
21 class DynamicDetectionConfig(SourceDetectionConfig):
22  """Configuration for DynamicDetectionTask"""
23  prelimThresholdFactor = Field(dtype=float, default=0.5,
24  doc="Fraction of the threshold to use for first pass (to find sky objects)")
25  skyObjects = ConfigurableField(target=SkyObjectsTask, doc="Generate sky objects")
26  doBackgroundTweak = Field(dtype=bool, default=True,
27  doc="Tweak background level so median PSF flux of sky objects is zero?")
28  minNumSources = Field(dtype=int, default=10,
29  doc="Minimum number of sky sources in statistical sample; "
30  "if below this number, we refuse to modify the threshold.")
31 
32  def setDefaults(self):
33  SourceDetectionConfig.setDefaults(self)
34  self.skyObjects.nSources = 1000 # For good statistics
35 
36 
37 class DynamicDetectionTask(SourceDetectionTask):
38  """Detection of sources on an image with a dynamic threshold
39 
40  We first detect sources using a lower threshold than normal (see config
41  parameter ``prelimThresholdFactor``) in order to identify good sky regions
42  (configurable ``skyObjects``). Then we perform forced PSF photometry on
43  those sky regions. Using those PSF flux measurements and estimated errors,
44  we set the threshold so that the stdev of the measurements matches the
45  median estimated error.
46  """
47  ConfigClass = DynamicDetectionConfig
48  _DefaultName = "dynamicDetection"
49 
50  def __init__(self, *args, **kwargs):
51  """Constructor
52 
53  Besides the usual initialisation of configurables, we also set up
54  the forced measurement which is deliberately not represented in
55  this Task's configuration parameters because we're using it as part
56  of the algorithm and we don't want to allow it to be modified.
57  """
58  SourceDetectionTask.__init__(self, *args, **kwargs)
59  self.makeSubtask("skyObjects")
60 
61  # Set up forced measurement.
62  config = ForcedMeasurementTask.ConfigClass()
63  config.plugins.names = ['base_TransformedCentroid', 'base_PsfFlux']
64  # We'll need the "centroid" and "psfFlux" slots
65  for slot in ("shape", "psfShape", "apFlux", "modelFlux", "instFlux", "calibFlux"):
66  setattr(config.slots, slot, None)
67  config.copyColumns = {}
68  self.skySchema = SourceTable.makeMinimalSchema()
69  self.skyMeasurement = ForcedMeasurementTask(config=config, name="skyMeasurement", parentTask=self,
70  refSchema=self.skySchema)
71 
72  def calculateThreshold(self, exposure, seed, sigma=None):
73  """Calculate new threshold
74 
75  This is the main functional addition to the vanilla
76  `SourceDetectionTask`.
77 
78  We identify sky objects and perform forced PSF photometry on
79  them. Using those PSF flux measurements and estimated errors,
80  we set the threshold so that the stdev of the measurements
81  matches the median estimated error.
82 
83  Parameters
84  ----------
85  exposure : `lsst.afw.image.Exposure`
86  Exposure on which we're detecting sources.
87  seed : `int`
88  RNG seed to use for finding sky objects.
89  sigma : `float`, optional
90  Gaussian sigma of smoothing kernel; if not provided,
91  will be deduced from the exposure's PSF.
92 
93  Returns
94  -------
95  result : `lsst.pipe.base.Struct`
96  Result struct with components:
97 
98  - ``multiplicative``: multiplicative factor to be applied to the
99  configured detection threshold (`float`).
100  - ``additive``: additive factor to be applied to the background
101  level (`float`).
102  """
103  # Make a catalog of sky objects
104  fp = self.skyObjects.run(exposure.maskedImage.mask, seed)
105  skyFootprints = FootprintSet(exposure.getBBox())
106  skyFootprints.setFootprints(fp)
107  table = SourceTable.make(self.skyMeasurement.schema)
108  catalog = SourceCatalog(table)
109  table.preallocate(len(skyFootprints.getFootprints()))
110  skyFootprints.makeSources(catalog)
111  key = catalog.getCentroidKey()
112  for source in catalog:
113  peaks = source.getFootprint().getPeaks()
114  assert len(peaks) == 1
115  source.set(key, peaks[0].getF())
116  source.updateCoord(exposure.getWcs())
117 
118  # Forced photometry on sky objects
119  self.skyMeasurement.run(catalog, exposure, catalog, exposure.getWcs())
120 
121  # Calculate new threshold
122  fluxes = catalog["base_PsfFlux_flux"]
123  good = ~catalog["base_PsfFlux_flag"] & np.isfinite(fluxes)
124 
125  if good.sum() < self.config.minNumSources:
126  self.log.warn("Insufficient good flux measurements (%d < %d) for dynamic threshold calculation",
127  good.sum(), self.config.minNumSources)
128  return Struct(multiplicative=1.0, additive=0.0)
129 
130  bgMedian = np.median((fluxes/catalog["base_PsfFlux_area"])[good])
131 
132  lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
133  stdevMeas = 0.741*(uq - lq)
134  medianError = np.median(catalog["base_PsfFlux_fluxSigma"][good])
135  return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
136 
137  def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
138  """Detect footprints with a dynamic threshold
139 
140  This varies from the vanilla ``detectFootprints`` method because we
141  do detection twice: one with a low threshold so that we can find
142  sky uncontaminated by objects, then one more with the new calculated
143  threshold.
144 
145  Parameters
146  ----------
147  exposure : `lsst.afw.image.Exposure`
148  Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
149  set in-place.
150  doSmooth : `bool`, optional
151  If True, smooth the image before detection using a Gaussian
152  of width ``sigma``.
153  sigma : `float`, optional
154  Gaussian Sigma of PSF (pixels); used for smoothing and to grow
155  detections; if `None` then measure the sigma of the PSF of the
156  ``exposure``.
157  clearMask : `bool`, optional
158  Clear both DETECTED and DETECTED_NEGATIVE planes before running
159  detection.
160  expId : `int`, optional
161  Exposure identifier, used as a seed for the random number
162  generator. If absent, the seed will be the sum of the image.
163 
164  Return Struct contents
165  ----------------------
166  positive : `lsst.afw.detection.FootprintSet`
167  Positive polarity footprints (may be `None`)
168  negative : `lsst.afw.detection.FootprintSet`
169  Negative polarity footprints (may be `None`)
170  numPos : `int`
171  Number of footprints in positive or 0 if detection polarity was
172  negative.
173  numNeg : `int`
174  Number of footprints in negative or 0 if detection polarity was
175  positive.
176  background : `lsst.afw.math.BackgroundList`
177  Re-estimated background. `None` if
178  ``reEstimateBackground==False``.
179  factor : `float`
180  Multiplication factor applied to the configured detection
181  threshold.
182  prelim : `lsst.pipe.base.Struct`
183  Results from preliminary detection pass.
184  """
185  maskedImage = exposure.maskedImage
186 
187  if clearMask:
188  self.clearMask(maskedImage.mask)
189  else:
190  oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(["DETECTED",
191  "DETECTED_NEGATIVE"])
192 
193  with self.tempWideBackgroundContext(exposure):
194  # Could potentially smooth with a wider kernel than the PSF in order to better pick up the
195  # wings of stars and galaxies, but for now sticking with the PSF as that's more simple.
196  psf = self.getPsf(exposure, sigma=sigma)
197  convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
198  middle = convolveResults.middle
199  sigma = convolveResults.sigma
200 
201  prelim = self.applyThreshold(middle, maskedImage.getBBox(), self.config.prelimThresholdFactor)
202  self.finalizeFootprints(maskedImage.mask, prelim, sigma, self.config.prelimThresholdFactor)
203 
204  # Calculate the proper threshold
205  # seed needs to fit in a C++ 'int' so pybind doesn't choke on it
206  seed = (expId if expId is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
207  threshResults = self.calculateThreshold(exposure, seed, sigma=sigma)
208  factor = threshResults.multiplicative
209  self.log.info("Modifying configured detection threshold by factor %f to %f",
210  factor, factor*self.config.thresholdValue)
211  self.tweakBackground(exposure, threshResults.additive)
212 
213  # Blow away preliminary (low threshold) detection mask
214  self.clearMask(maskedImage.mask)
215  if not clearMask:
216  maskedImage.mask.array |= oldDetected
217 
218  # Rinse and repeat thresholding with new calculated threshold
219  results = self.applyThreshold(middle, maskedImage.getBBox(), factor)
220  results.prelim = prelim
221  results.background = lsst.afw.math.BackgroundList()
222  if self.config.doTempLocalBackground:
223  self.applyTempLocalBackground(exposure, middle, results)
224  self.finalizeFootprints(maskedImage.mask, results, sigma, factor)
225 
226  self.clearUnwantedResults(maskedImage.mask, results)
227 
228  if self.config.reEstimateBackground:
229  self.reEstimateBackground(maskedImage, results.background)
230 
231  self.display(exposure, results, middle)
232 
233  if self.config.doBackgroundTweak:
234  # Re-do the background tweak after any temporary backgrounds have been restored
235  bgLevel = self.calculateThreshold(exposure, seed, sigma=sigma).additive
236  self.tweakBackground(exposure, bgLevel, results.background)
237 
238  return results
239 
240  def tweakBackground(self, exposure, bgLevel, bgList=None):
241  """Modify the background by a constant value
242 
243  Parameters
244  ----------
245  exposure : `lsst.afw.image.Exposure`
246  Exposure for which to tweak background.
247  bgLevel : `float`
248  Background level to remove
249  bgList : `lsst.afw.math.BackgroundList`, optional
250  List of backgrounds to append to.
251 
252  Returns
253  -------
254  bg : `lsst.afw.math.BackgroundMI`
255  Constant background model.
256  """
257  self.log.info("Tweaking background by %f to match sky photometry", bgLevel)
258  exposure.image -= bgLevel
259  bgStats = lsst.afw.image.MaskedImageF(1, 1)
260  bgStats.set(0, 0, (bgLevel, 0, bgLevel))
261  bg = lsst.afw.math.BackgroundMI(exposure.getBBox(), bgStats)
262  bgData = (bg, lsst.afw.math.Interpolate.LINEAR, lsst.afw.math.REDUCE_INTERP_ORDER,
263  lsst.afw.math.ApproximateControl.UNKNOWN, 0, 0, False)
264  if bgList is not None:
265  bgList.append(bgData)
266  return bg
def tweakBackground(self, exposure, bgLevel, bgList=None)
def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None)
def calculateThreshold(self, exposure, seed, sigma=None)