Coverage for python/lsst/meas/algorithms/dynamicDetection.py: 18%
117 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-16 11:14 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-16 11:14 +0000
2__all__ = ["DynamicDetectionConfig", "DynamicDetectionTask"]
4import numpy as np
6from lsst.pex.config import Field, ConfigurableField
7from lsst.pipe.base import Struct
9from .detection import SourceDetectionConfig, SourceDetectionTask
10from .skyObjects import SkyObjectsTask
12from lsst.afw.detection import FootprintSet
13from lsst.afw.geom import makeCdMatrix, makeSkyWcs
14from lsst.afw.table import SourceCatalog, SourceTable
15from lsst.meas.base import ForcedMeasurementTask
17import lsst.afw.image
18import lsst.afw.math
19import lsst.geom as geom
22class DynamicDetectionConfig(SourceDetectionConfig):
23 """Configuration for DynamicDetectionTask
24 """
25 prelimThresholdFactor = Field(dtype=float, default=0.5,
26 doc="Fraction of the threshold to use for first pass (to find sky objects)")
27 skyObjects = ConfigurableField(target=SkyObjectsTask, doc="Generate sky objects")
28 doBackgroundTweak = Field(dtype=bool, default=True,
29 doc="Tweak background level so median PSF flux of sky objects is zero?")
30 minNumSources = Field(dtype=int, default=10,
31 doc="Minimum number of sky sources in statistical sample; "
32 "if below this number, we refuse to modify the threshold.")
34 def setDefaults(self):
35 SourceDetectionConfig.setDefaults(self)
36 self.skyObjects.nSources = 1000 # For good statistics
39class DynamicDetectionTask(SourceDetectionTask):
40 """Detection of sources on an image with a dynamic threshold
42 We first detect sources using a lower threshold than normal (see config
43 parameter ``prelimThresholdFactor``) in order to identify good sky regions
44 (configurable ``skyObjects``). Then we perform forced PSF photometry on
45 those sky regions. Using those PSF flux measurements and estimated errors,
46 we set the threshold so that the stdev of the measurements matches the
47 median estimated error.
49 Besides the usual initialisation of configurables, we also set up
50 the forced measurement which is deliberately not represented in
51 this Task's configuration parameters because we're using it as
52 part of the algorithm and we don't want to allow it to be modified.
53 """
54 ConfigClass = DynamicDetectionConfig
55 _DefaultName = "dynamicDetection"
57 def __init__(self, *args, **kwargs):
59 SourceDetectionTask.__init__(self, *args, **kwargs)
60 self.makeSubtask("skyObjects")
62 # Set up forced measurement.
63 config = ForcedMeasurementTask.ConfigClass()
64 config.plugins.names = ['base_TransformedCentroid', 'base_PsfFlux', 'base_LocalBackground']
65 # We'll need the "centroid" and "psfFlux" slots
66 for slot in ("shape", "psfShape", "apFlux", "modelFlux", "gaussianFlux", "calibFlux"):
67 setattr(config.slots, slot, None)
68 config.copyColumns = {}
69 self.skySchema = SourceTable.makeMinimalSchema()
70 self.skyMeasurement = ForcedMeasurementTask(config=config, name="skyMeasurement", parentTask=self,
71 refSchema=self.skySchema)
73 def calculateThreshold(self, exposure, seed, sigma=None):
74 """Calculate new threshold
76 This is the main functional addition to the vanilla
77 `SourceDetectionTask`.
79 We identify sky objects and perform forced PSF photometry on
80 them. Using those PSF flux measurements and estimated errors,
81 we set the threshold so that the stdev of the measurements
82 matches the median estimated error.
84 Parameters
85 ----------
86 exposureOrig : `lsst.afw.image.Exposure`
87 Exposure on which we're detecting sources.
88 seed : `int`
89 RNG seed to use for finding sky objects.
90 sigma : `float`, optional
91 Gaussian sigma of smoothing kernel; if not provided,
92 will be deduced from the exposure's PSF.
94 Returns
95 -------
96 result : `lsst.pipe.base.Struct`
97 Result struct with components:
99 ``multiplicative``
100 Multiplicative factor to be applied to the
101 configured detection threshold (`float`).
102 ``additive``
103 Additive factor to be applied to the background
104 level (`float`).
105 """
106 wcsIsNone = exposure.getWcs() is None
107 if wcsIsNone: # create a dummy WCS as needed by ForcedMeasurementTask
108 self.log.info("WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
109 exposure.setWcs(makeSkyWcs(crpix=geom.Point2D(0, 0),
110 crval=geom.SpherePoint(0, 0, geom.degrees),
111 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
112 fp = self.skyObjects.run(exposure.maskedImage.mask, seed)
113 skyFootprints = FootprintSet(exposure.getBBox())
114 skyFootprints.setFootprints(fp)
115 table = SourceTable.make(self.skyMeasurement.schema)
116 catalog = SourceCatalog(table)
117 catalog.reserve(len(skyFootprints.getFootprints()))
118 skyFootprints.makeSources(catalog)
119 key = catalog.getCentroidSlot().getMeasKey()
120 for source in catalog:
121 peaks = source.getFootprint().getPeaks()
122 assert len(peaks) == 1
123 source.set(key, peaks[0].getF())
124 source.updateCoord(exposure.getWcs())
126 # Forced photometry on sky objects
127 self.skyMeasurement.run(catalog, exposure, catalog, exposure.getWcs())
129 # Calculate new threshold
130 fluxes = catalog["base_PsfFlux_instFlux"]
131 area = catalog["base_PsfFlux_area"]
132 bg = catalog["base_LocalBackground_instFlux"]
134 good = (~catalog["base_PsfFlux_flag"] & ~catalog["base_LocalBackground_flag"]
135 & np.isfinite(fluxes) & np.isfinite(area) & np.isfinite(bg))
137 if good.sum() < self.config.minNumSources:
138 self.log.warning("Insufficient good flux measurements (%d < %d) for dynamic threshold"
139 " calculation", good.sum(), self.config.minNumSources)
140 return Struct(multiplicative=1.0, additive=0.0)
142 bgMedian = np.median((fluxes/area)[good])
144 lq, uq = np.percentile((fluxes - bg*area)[good], [25.0, 75.0])
145 stdevMeas = 0.741*(uq - lq)
146 medianError = np.median(catalog["base_PsfFlux_instFluxErr"][good])
147 if wcsIsNone:
148 exposure.setWcs(None)
149 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
151 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None):
152 """Detect footprints with a dynamic threshold
154 This varies from the vanilla ``detectFootprints`` method because we
155 do detection twice: one with a low threshold so that we can find
156 sky uncontaminated by objects, then one more with the new calculated
157 threshold.
159 Parameters
160 ----------
161 exposure : `lsst.afw.image.Exposure`
162 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
163 set in-place.
164 doSmooth : `bool`, optional
165 If True, smooth the image before detection using a Gaussian
166 of width ``sigma``.
167 sigma : `float`, optional
168 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
169 detections; if `None` then measure the sigma of the PSF of the
170 ``exposure``.
171 clearMask : `bool`, optional
172 Clear both DETECTED and DETECTED_NEGATIVE planes before running
173 detection.
174 expId : `int`, optional
175 Exposure identifier, used as a seed for the random number
176 generator. If absent, the seed will be the sum of the image.
178 Returns
179 -------
180 resutls : `lsst.pipe.base.Struct`
181 The results `~lsst.pipe.base.Struct` contains:
183 ``positive``
184 Positive polarity footprints.
185 (`lsst.afw.detection.FootprintSet` or `None`)
186 ``negative``
187 Negative polarity footprints.
188 (`lsst.afw.detection.FootprintSet` or `None`)
189 ``numPos``
190 Number of footprints in positive or 0 if detection polarity was
191 negative. (`int`)
192 ``numNeg``
193 Number of footprints in negative or 0 if detection polarity was
194 positive. (`int`)
195 ``background``
196 Re-estimated background. `None` if
197 ``reEstimateBackground==False``.
198 (`lsst.afw.math.BackgroundList`)
199 ``factor``
200 Multiplication factor applied to the configured detection
201 threshold. (`float`)
202 ``prelim``
203 Results from preliminary detection pass.
204 (`lsst.pipe.base.Struct`)
205 """
206 maskedImage = exposure.maskedImage
208 if clearMask:
209 self.clearMask(maskedImage.mask)
210 else:
211 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(["DETECTED",
212 "DETECTED_NEGATIVE"])
214 with self.tempWideBackgroundContext(exposure):
215 # Could potentially smooth with a wider kernel than the PSF in
216 # order to better pick up the wings of stars and galaxies, but for
217 # now sticking with the PSF as that's more simple.
218 psf = self.getPsf(exposure, sigma=sigma)
219 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
220 middle = convolveResults.middle
221 sigma = convolveResults.sigma
222 prelim = self.applyThreshold(middle, maskedImage.getBBox(), self.config.prelimThresholdFactor)
223 self.finalizeFootprints(maskedImage.mask, prelim, sigma, self.config.prelimThresholdFactor)
225 # Calculate the proper threshold
226 # seed needs to fit in a C++ 'int' so pybind doesn't choke on it
227 seed = (expId if expId is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
228 threshResults = self.calculateThreshold(exposure, seed, sigma=sigma)
229 factor = threshResults.multiplicative
230 self.log.info("Modifying configured detection threshold by factor %f to %f",
231 factor, factor*self.config.thresholdValue)
233 # Blow away preliminary (low threshold) detection mask
234 self.clearMask(maskedImage.mask)
235 if not clearMask:
236 maskedImage.mask.array |= oldDetected
238 # Rinse and repeat thresholding with new calculated threshold
239 results = self.applyThreshold(middle, maskedImage.getBBox(), factor)
240 results.prelim = prelim
241 results.background = lsst.afw.math.BackgroundList()
242 if self.config.doTempLocalBackground:
243 self.applyTempLocalBackground(exposure, middle, results)
244 self.finalizeFootprints(maskedImage.mask, results, sigma, factor)
246 self.clearUnwantedResults(maskedImage.mask, results)
248 if self.config.reEstimateBackground:
249 self.reEstimateBackground(maskedImage, results.background)
251 self.display(exposure, results, middle)
253 if self.config.doBackgroundTweak:
254 # Re-do the background tweak after any temporary backgrounds have
255 # been restored.
256 #
257 # But we want to keep any large-scale background (e.g., scattered
258 # light from bright stars) from being selected for sky objects in
259 # the calculation, so do another detection pass without either the
260 # local or wide temporary background subtraction; the DETECTED
261 # pixels will mark the area to ignore.
262 originalMask = maskedImage.mask.array.copy()
263 try:
264 self.clearMask(exposure.mask)
265 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
266 tweakDetResults = self.applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
267 self.finalizeFootprints(maskedImage.mask, tweakDetResults, sigma, factor)
268 bgLevel = self.calculateThreshold(exposure, seed, sigma=sigma).additive
269 finally:
270 maskedImage.mask.array[:] = originalMask
271 self.tweakBackground(exposure, bgLevel, results.background)
273 return results
275 def tweakBackground(self, exposure, bgLevel, bgList=None):
276 """Modify the background by a constant value
278 Parameters
279 ----------
280 exposure : `lsst.afw.image.Exposure`
281 Exposure for which to tweak background.
282 bgLevel : `float`
283 Background level to remove
284 bgList : `lsst.afw.math.BackgroundList`, optional
285 List of backgrounds to append to.
287 Returns
288 -------
289 bg : `lsst.afw.math.BackgroundMI`
290 Constant background model.
291 """
292 self.log.info("Tweaking background by %f to match sky photometry", bgLevel)
293 exposure.image -= bgLevel
294 bgStats = lsst.afw.image.MaskedImageF(1, 1)
295 bgStats.set(bgLevel, 0, bgLevel)
296 bg = lsst.afw.math.BackgroundMI(exposure.getBBox(), bgStats)
297 bgData = (bg, lsst.afw.math.Interpolate.LINEAR, lsst.afw.math.REDUCE_INTERP_ORDER,
298 lsst.afw.math.ApproximateControl.UNKNOWN, 0, 0, False)
299 if bgList is not None:
300 bgList.append(bgData)
301 return bg