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