LSST Applications g00274db5b6+edbf708997,g00d0e8bbd7+edbf708997,g199a45376c+5137f08352,g1fd858c14a+1d4b6db739,g262e1987ae+f4d9505c4f,g29ae962dfc+7156fb1a53,g2cef7863aa+73c82f25e4,g35bb328faa+edbf708997,g3e17d7035e+5b3adc59f5,g3fd5ace14f+852fa6fbcb,g47891489e3+6dc8069a4c,g53246c7159+edbf708997,g64539dfbff+9f17e571f4,g67b6fd64d1+6dc8069a4c,g74acd417e5+ae494d68d9,g786e29fd12+af89c03590,g7ae74a0b1c+a25e60b391,g7aefaa3e3d+536efcc10a,g7cc15d900a+d121454f8d,g87389fa792+a4172ec7da,g89139ef638+6dc8069a4c,g8d7436a09f+28c28d8d6d,g8ea07a8fe4+db21c37724,g92c671f44c+9f17e571f4,g98df359435+b2e6376b13,g99af87f6a8+b0f4ad7b8d,gac66b60396+966efe6077,gb88ae4c679+7dec8f19df,gbaa8f7a6c5+38b34f4976,gbf99507273+edbf708997,gc24b5d6ed1+9f17e571f4,gca7fc764a6+6dc8069a4c,gcc769fe2a4+97d0256649,gd7ef33dd92+6dc8069a4c,gdab6d2f7ff+ae494d68d9,gdbb4c4dda9+9f17e571f4,ge410e46f29+6dc8069a4c,geaed405ab2+e194be0d2b,w.2025.47
LSST Data Management Base Package
Loading...
Searching...
No Matches
dynamicDetection.py
Go to the documentation of this file.
2__all__ = [
3 "DynamicDetectionConfig",
4 "DynamicDetectionTask",
5 "InsufficientSourcesError",
6 "ZeroFootprintError",
7]
8
9import numpy as np
10
11from lsst.pex.config import Field, ConfigurableField, FieldValidationError
12
13from .detection import SourceDetectionConfig, SourceDetectionTask
14from .skyObjects import SkyObjectsTask
15from .subtractBackground import TooManyMaskedPixelsError
16
17from lsst.afw.detection import FootprintSet
18from lsst.afw.geom import makeCdMatrix, makeSkyWcs, SpanSet
19from lsst.afw.table import SourceCatalog, SourceTable
20from lsst.meas.base import ForcedMeasurementTask
21from lsst.pipe.base import AlgorithmError, Struct
22
23import lsst.afw.image
24import lsst.afw.math
25import lsst.geom as geom
26
27
28class InsufficientSourcesError(AlgorithmError):
29 """Raised if an insufficient number of sky sources are found for
30 dynamic detection.
31
32 Parameters
33 ----------
34 msg : `str`
35 Error message.
36 nGoodPix : `int`
37 Number of good pixels (i.e. not NO_DATA or BAD).
38 nPix : `int`
39 Total number of pixels.
40 **kwargs : `dict`, optional
41 Additional keyword arguments to initialize the Exception base class.
42 """
43 def __init__(self, msg, nGoodPix, nPix, **kwargs):
44 self.msg = msg
45 self._metadata = kwargs
46 super().__init__(msg, **kwargs)
47 self._metadata["nGoodPix"] = int(nGoodPix)
48 self._metadata["nPix"] = int(nPix)
49
50 def __str__(self):
51 # Exception doesn't handle **kwargs, so we need a custom str.
52 return f"{self.msg}: {self.metadata}"
53
54 @property
55 def metadata(self):
56 for key, value in self._metadata.items():
57 if not isinstance(value, (int, float, str)):
58 raise TypeError(f"{key} is of type {type(value)}, but only (int, float, str) are allowed.")
59 return self._metadata
60
61
62class ZeroFootprintError(AlgorithmError):
63 """Raised if no footprints are detected in the image.
64
65 Parameters
66 ----------
67 msg : `str`
68 Error message.
69 **kwargs : `dict`, optional
70 Additional keyword arguments to initialize the Exception base class.
71 """
72 def __init__(self, msg, **kwargs):
73 self.msg = msg
74 self._metadata = kwargs
75 super().__init__(msg, **kwargs)
76
77 def __str__(self):
78 # Exception doesn't handle **kwargs, so we need a custom str.
79 return f"{self.msg}: {self.metadata}"
80
81 @property
82 def metadata(self):
83 for key, value in self._metadata.items():
84 if not isinstance(value, (int, float, str)):
85 raise TypeError(f"{key} is of type {type(value)}, but only (int, float, str) are allowed.")
86 return self._metadata
87
88
90 """Configuration for DynamicDetectionTask
91 """
92 prelimThresholdFactor = Field(dtype=float, default=0.5,
93 doc="Factor by which to multiply the main detection threshold "
94 "(thresholdValue) to use for first pass (to find sky objects).")
95 prelimNegMultiplier = Field(dtype=float, default=2.5,
96 doc="Multiplier for the negative (relative to positive) polarity "
97 "detections threshold to use for first pass (to find sky objects).")
98 skyObjects = ConfigurableField(target=SkyObjectsTask, doc="Generate sky objects.")
99 minGoodPixelFraction = Field(dtype=float, default=0.005,
100 doc="Minimum fraction of 'good' pixels required to be deemed "
101 "worthwhile for an attempt at further processing.")
102 doThresholdScaling = Field(dtype=bool, default=True,
103 doc="Scale the threshold level to get empirically measured S/N requested?")
104 minThresholdScaleFactor = Field(dtype=float, default=0.1, optional=True,
105 doc="Mininum threshold scaling allowed (i.e. it will be set to this "
106 "if the computed value is smaller than it). Set to None for no limit.")
107 maxThresholdScaleFactor = Field(dtype=float, default=10.0, optional=True,
108 doc="Maximum threshold scaling allowed (i.e. it will be set to this "
109 "if the computed value is greater than it). Set to None for no limit.")
110 doBackgroundTweak = Field(dtype=bool, default=True,
111 doc="Tweak background level so median PSF flux of sky objects is zero?")
112 minBackgroundTweak = Field(dtype=float, default=-100.0, optional=True,
113 doc="Mininum background tweak allowed (i.e. it will be set to this "
114 "if the computed value is smaller than it). Set to None for no limit.")
115 maxBackgroundTweak = Field(dtype=float, default=100.0, optional=True,
116 doc="Maximum background tweak allowed (i.e. it will be set to this "
117 "if the computed value is greater than it). Set to None for no limit.")
118 minFractionSources = Field(dtype=float, default=0.02,
119 doc="Minimum fraction of the requested number of sky sources for dynamic "
120 "detection to be considered a success. If the number of good sky sources "
121 "identified falls below this threshold, an InsufficientSourcesError error "
122 "is raised so that this dataId is no longer considered in downstream "
123 "processing.")
124 doBrightPrelimDetection = Field(dtype=bool, default=True,
125 doc="Do initial bright detection pass where footprints are grown "
126 "by brightGrowFactor?")
127 brightMultiplier = Field(dtype=float, default=2000.0,
128 doc="Multiplier to apply to the prelimThresholdFactor for the "
129 "\"bright\" detections stage (want this to be large to only "
130 "detect the brightest sources).")
131 brightNegFactor = Field(dtype=float, default=2.2,
132 doc="Factor by which to multiply the threshold for the negative polatiry "
133 "detections for the \"bright\" detections stage (this needs to be fairly "
134 "low given the nature of the negative polarity detections in the very "
135 "large positive polarity threshold).")
136 brightGrowFactor = Field(dtype=int, default=40,
137 doc="Factor by which to grow the footprints of sources detected in the "
138 "\"bright\" detections stage (want this to be large to mask wings of "
139 "bright sources).")
140 brightMaskFractionMax = Field(dtype=float, default=0.95,
141 doc="Maximum allowed fraction of masked pixes from the \"bright\" "
142 "detection stage (to mask regions unsuitable for sky sourcess). "
143 "If this fraction is exeeded, the detection threshold for this stage "
144 "will be increased by bisectFactor until the fraction of masked "
145 "pixels drops below this threshold.")
146 bisectFactor = Field(dtype=float, default=1.2,
147 doc="Factor by which to increase thresholds in brightMaskFractionMax loop.")
148 allowMaskErode = Field(dtype=bool, default=True,
149 doc="Crowded/large fill-factor fields make it difficult to find "
150 "suitable locations to lay down sky objects. To allow for best effort "
151 "sky source placement, if True, this allows for a slight erosion of "
152 "the detection masks.")
153 maxPeakToFootRatio = Field(dtype=float, default=150.0,
154 doc="Maximum ratio of peak per footprint in the detection mask. "
155 "This is to help prevent single contiguous footprints that nothing "
156 "can be done with (i.e. deblending will be skipped). If the current "
157 "detection plane does not satisfy this constraint, the detection "
158 "threshold is increased iteratively until it is. This behaviour is "
159 "intended to be an effective no-op for most \"typical\" scenes/standard "
160 "quality observations, but can avoid total meltdown in, e.g. very "
161 "crowded regions.")
162
163 def setDefaults(self):
164 SourceDetectionConfig.setDefaults(self)
165 self.skyObjects.nSources = 1000 # For good statistics
166 for maskStr in ["SAT"]:
167 if maskStr not in self.skyObjects.avoidMask:
168 self.skyObjects.avoidMask.append(maskStr)
169
170 def validate(self):
171 super().validate()
172
174 raise ValueError("DynamicDetectionTask does not support doApplyFlatBackgroundRatio.")
175
176 if self.doThresholdScaling:
179 msg = "minThresholdScaleFactor must be <= maxThresholdScaleFactor"
181 DynamicDetectionConfig.doThresholdScaling, self, msg,
182 )
183
184 if self.doBackgroundTweak:
185 if self.minBackgroundTweak and self.maxBackgroundTweak:
187 msg = "minBackgroundTweak must be <= maxBackgroundTweak"
189 DynamicDetectionConfig.doBackgroundTweak, self, msg,
190 )
191
192
194 """Detection of sources on an image with a dynamic threshold
195
196 We first detect sources using a lower threshold than normal (see config
197 parameter ``prelimThresholdFactor``) in order to identify good sky regions
198 (configurable ``skyObjects``). Then we perform forced PSF photometry on
199 those sky regions. Using those PSF flux measurements and estimated errors,
200 we set the threshold so that the stdev of the measurements matches the
201 median estimated error.
202
203 Besides the usual initialisation of configurables, we also set up
204 the forced measurement which is deliberately not represented in
205 this Task's configuration parameters because we're using it as
206 part of the algorithm and we don't want to allow it to be modified.
207 """
208 ConfigClass = DynamicDetectionConfig
209 _DefaultName = "dynamicDetection"
210
211 def __init__(self, *args, **kwargs):
212
213 SourceDetectionTask.__init__(self, *args, **kwargs)
214 self.makeSubtask("skyObjects")
215
216 # Set up forced measurement.
217 config = ForcedMeasurementTask.ConfigClass()
218 config.plugins.names = ["base_TransformedCentroid", "base_PsfFlux"]
219 # We'll need the "centroid" and "psfFlux" slots
220 for slot in ("shape", "psfShape", "apFlux", "modelFlux", "gaussianFlux", "calibFlux"):
221 setattr(config.slots, slot, None)
222 config.copyColumns = {}
223 self.skySchema = SourceTable.makeMinimalSchema()
224 self.skyMeasurement = ForcedMeasurementTask(config=config, name="skyMeasurement", parentTask=self,
225 refSchema=self.skySchema)
226
227 def calculateThreshold(self, exposure, seed, sigma=None, minFractionSourcesFactor=1.0,
228 isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10):
229 """Calculate new threshold
230
231 This is the main functional addition to the vanilla
232 `SourceDetectionTask`.
233
234 We identify sky objects and perform forced PSF photometry on
235 them. Using those PSF flux measurements and estimated errors,
236 we set the threshold so that the stdev of the measurements
237 matches the median estimated error.
238
239 Parameters
240 ----------
241 exposure : `lsst.afw.image.Exposure`
242 Exposure on which we're detecting sources.
243 seed : `int`
244 RNG seed to use for finding sky objects.
245 sigma : `float`, optional
246 Gaussian sigma of smoothing kernel; if not provided,
247 will be deduced from the exposure's PSF.
248 minFractionSourcesFactor : `float`
249 Change the fraction of required sky sources from that set in
250 ``self.config.minFractionSources`` by this factor. NOTE: this
251 is intended for use in the background tweak pass (the detection
252 threshold is much lower there, so many more pixels end up marked
253 as DETECTED or DETECTED_NEGATIVE, leaving less room for sky
254 object placement).
255 isBgTweak : `bool`
256 Set to ``True`` for the background tweak pass (for more helpful
257 log messages).
258 nPixMaskErode : `int`, optional
259 Number of pixels by which to erode the detection masks on each
260 iteration of best-effort sky object placement.
261 maxMaskErodeIter : `int`, optional
262 Maximum number of iterations for the detection mask erosion.
263
264 Returns
265 -------
266 result : `lsst.pipe.base.Struct`
267 Result struct with components:
268
269 ``multiplicative``
270 Multiplicative factor to be applied to the
271 configured detection threshold (`float`).
272 ``additive``
273 Additive factor to be applied to the background
274 level (`float`).
275
276 Raises
277 ------
278 InsufficientSourcesError
279 Raised if the number of good sky sources found is less than the
280 minimum fraction
281 (``self.config.minFractionSources``*``minFractionSourcesFactor``)
282 of the number requested (``self.skyObjects.config.nSources``).
283 """
284 wcsIsNone = exposure.getWcs() is None
285 if wcsIsNone: # create a dummy WCS as needed by ForcedMeasurementTask
286 self.log.info("WCS for exposure is None. Setting a dummy WCS for dynamic detection.")
287 exposure.setWcs(makeSkyWcs(crpix=geom.Point2D(0, 0),
288 crval=geom.SpherePoint(0, 0, geom.degrees),
289 cdMatrix=makeCdMatrix(scale=1e-5*geom.degrees)))
290 minNumSources = int(self.config.minFractionSources*self.skyObjects.config.nSources)
291 # Reduce the number of sky sources required if requested, but ensure
292 # a minumum of 3.
293 if minFractionSourcesFactor != 1.0:
294 minNumSources = max(3, int(minNumSources*minFractionSourcesFactor))
295 fp = self.skyObjects.run(exposure.maskedImage.mask, seed)
296
297 if self.config.allowMaskErode:
298 detectedMaskPlanes = ["DETECTED", "DETECTED_NEGATIVE"]
299 mask = exposure.maskedImage.mask
300 for nIter in range(maxMaskErodeIter):
301 if nIter > 0:
302 fp = self.skyObjects.run(mask, seed)
303 if len(fp) < int(2*minNumSources): # Allow for measurement failures
304 self.log.info("Current number of sky sources is below 2*minimum required "
305 "(%d < %d, allowing for some subsequent measurement failures). "
306 "Allowing erosion of detected mask planes for sky placement "
307 "nIter: %d [of %d max]",
308 len(fp), 2*minNumSources, nIter, maxMaskErodeIter)
309 if nPixMaskErode is None:
310 if len(fp) == 0:
311 nPixMaskErode = 4
312 elif len(fp) < int(0.75*minNumSources):
313 nPixMaskErode = 2
314 else:
315 nPixMaskErode = 1
316 for maskName in detectedMaskPlanes:
317 # Compute the eroded detection mask plane using SpanSet
318 detectedMaskBit = mask.getPlaneBitMask(maskName)
319 detectedMaskSpanSet = SpanSet.fromMask(mask, detectedMaskBit)
320 detectedMaskSpanSet = detectedMaskSpanSet.eroded(nPixMaskErode)
321 # Clear the detected mask plane
322 detectedMask = mask.getMaskPlane(maskName)
323 mask.clearMaskPlane(detectedMask)
324 # Set the mask plane to the eroded one
325 detectedMaskSpanSet.setMask(mask, detectedMaskBit)
326 else:
327 break
328
329 skyFootprints = FootprintSet(exposure.getBBox())
330 skyFootprints.setFootprints(fp)
331 table = SourceTable.make(self.skyMeasurement.schema)
332 catalog = SourceCatalog(table)
333 catalog.reserve(len(skyFootprints.getFootprints()))
334 skyFootprints.makeSources(catalog)
335 key = catalog.getCentroidSlot().getMeasKey()
336 for source in catalog:
337 peaks = source.getFootprint().getPeaks()
338 assert len(peaks) == 1
339 source.set(key, peaks[0].getF())
340 # Coordinate covariance is not used, so don't bother calulating it.
341 source.updateCoord(exposure.getWcs(), include_covariance=False)
342
343 # Forced photometry on sky objects
344 self.skyMeasurement.run(catalog, exposure, catalog, exposure.getWcs())
345
346 # Calculate new threshold
347 fluxes = catalog["base_PsfFlux_instFlux"]
348 area = catalog["base_PsfFlux_area"]
349 good = (~catalog["base_PsfFlux_flag"] & np.isfinite(fluxes))
350
351 if good.sum() < minNumSources:
352 if not isBgTweak:
353 msg = (f"Insufficient good sky source flux measurements ({good.sum()} < "
354 f"{minNumSources}) for dynamic threshold calculation.")
355 else:
356 msg = (f"Insufficient good sky source flux measurements ({good.sum()} < "
357 f"{minNumSources}) for background tweak calculation.")
358
359 nPix = exposure.mask.array.size
360 badPixelMask = lsst.afw.image.Mask.getPlaneBitMask(["NO_DATA", "BAD"])
361 nGoodPix = np.sum(exposure.mask.array & badPixelMask == 0)
362 if nGoodPix/nPix > 0.2:
363 detectedPixelMask = lsst.afw.image.Mask.getPlaneBitMask(["DETECTED", "DETECTED_NEGATIVE"])
364 nDetectedPix = np.sum(exposure.mask.array & detectedPixelMask != 0)
365 msg += (f" However, {nGoodPix}/{nPix} pixels are not marked NO_DATA or BAD, "
366 "so there should be sufficient area to locate suitable sky sources. "
367 f"Note that {nDetectedPix} of {nGoodPix} \"good\" pixels were marked "
368 "as DETECTED or DETECTED_NEGATIVE.")
369 raise InsufficientSourcesError(msg, nGoodPix, nPix)
370 raise InsufficientSourcesError(msg, nGoodPix, nPix)
371
372 if not isBgTweak:
373 self.log.info("Number of good sky sources used for dynamic detection: %d (of %d requested).",
374 good.sum(), self.skyObjects.config.nSources)
375 else:
376 self.log.info("Number of good sky sources used for dynamic detection background tweak:"
377 " %d (of %d requested).", good.sum(), self.skyObjects.config.nSources)
378
379 bgMedian = np.median((fluxes/area)[good])
380 lq, uq = np.percentile(fluxes[good], [25.0, 75.0])
381 stdevMeas = 0.741*(uq - lq)
382 medianError = np.median(catalog["base_PsfFlux_instFluxErr"][good])
383 if wcsIsNone:
384 exposure.setWcs(None)
385 return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian)
386
387 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None,
388 background=None, backgroundToPhotometricRatio=None):
389 """Detect footprints with a dynamic threshold
390
391 This varies from the vanilla ``detectFootprints`` method because we
392 do detection three times: first with a high threshold to detect
393 "bright" (both positive and negative, the latter to identify very
394 over-subtracted regions) sources for which we grow the DETECTED and
395 DETECTED_NEGATIVE masks significantly to account for wings. Second,
396 with a low threshold to mask all non-empty regions of the image. These
397 two masks are combined and used to identify regions of sky
398 uncontaminated by objects. A final round of detection is then done
399 with the new calculated threshold.
400
401 Parameters
402 ----------
403 exposure : `lsst.afw.image.Exposure`
404 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be
405 set in-place.
406 doSmooth : `bool`, optional
407 If True, smooth the image before detection using a Gaussian
408 of width ``sigma``.
409 sigma : `float`, optional
410 Gaussian Sigma of PSF (pixels); used for smoothing and to grow
411 detections; if `None` then measure the sigma of the PSF of the
412 ``exposure``.
413 clearMask : `bool`, optional
414 Clear both DETECTED and DETECTED_NEGATIVE planes before running
415 detection.
416 expId : `int`, optional
417 Exposure identifier, used as a seed for the random number
418 generator. If absent, the seed will be the sum of the image.
419 background : `lsst.afw.math.BackgroundList`, optional
420 Background that was already subtracted from the exposure; will be
421 modified in-place if ``reEstimateBackground=True``.
422 backgroundToPhotometricRatio : `lsst.afw.image.Image`, optional
423 Unused; if set will Raise.
424
425 Returns
426 -------
427 results : `lsst.pipe.base.Struct`
428 The results `~lsst.pipe.base.Struct` contains:
429
430 ``positive``
431 Positive polarity footprints.
432 (`lsst.afw.detection.FootprintSet` or `None`)
433 ``negative``
434 Negative polarity footprints.
435 (`lsst.afw.detection.FootprintSet` or `None`)
436 ``numPos``
437 Number of footprints in positive or 0 if detection polarity was
438 negative. (`int`)
439 ``numNeg``
440 Number of footprints in negative or 0 if detection polarity was
441 positive. (`int`)
442 ``background``
443 Re-estimated background. `None` or the input ``background``
444 if ``reEstimateBackground==False``.
445 (`lsst.afw.math.BackgroundList`)
446 ``factor``
447 Multiplication factor applied to the configured detection
448 threshold. (`float`)
449 ``prelim``
450 Results from preliminary detection pass.
451 (`lsst.pipe.base.Struct`)
452 """
453 if backgroundToPhotometricRatio is not None:
454 raise RuntimeError("DynamicDetectionTask does not support backgroundToPhotometricRatio.")
455 maskedImage = exposure.maskedImage
456
457 if clearMask:
458 self.clearMask(maskedImage.mask)
459 else:
460 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(["DETECTED",
461 "DETECTED_NEGATIVE"])
462 nPix = maskedImage.mask.array.size
463 badPixelMask = lsst.afw.image.Mask.getPlaneBitMask(["NO_DATA", "BAD"])
464 nGoodPix = np.sum(maskedImage.mask.array & badPixelMask == 0)
465 self.log.info("Number of good data pixels (i.e. not NO_DATA or BAD): {} ({:.2f}% of total)".
466 format(nGoodPix, 100*nGoodPix/nPix))
467 if nGoodPix/nPix < self.config.minGoodPixelFraction:
468 msg = (f"Image has a very low good pixel fraction ({nGoodPix} of {nPix}), so not worth further "
469 "consideration")
470 raise TooManyMaskedPixelsError(msg)
471
472 with self.tempWideBackgroundContext(exposure):
473 # Could potentially smooth with a wider kernel than the PSF in
474 # order to better pick up the wings of stars and galaxies, but for
475 # now sticking with the PSF as that's more simple.
476 psf = self.getPsf(exposure, sigma=sigma)
477 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
478
479 if self.config.doThresholdScaling:
480 if self.config.doBrightPrelimDetection:
481 brightDetectedMask = self._computeBrightDetectionMask(maskedImage, convolveResults)
482 else:
483 prelim = None
484 factor = 1.0
485
486 # seed needs to fit in a C++ 'int' so pybind doesn't choke on it
487 seed = (expId if expId is not None else int(maskedImage.image.array.sum())) % (2**31 - 1)
488
489 middle = convolveResults.middle
490 sigma = convolveResults.sigma
491 if self.config.doThresholdScaling:
492 prelim = self.applyThreshold(
493 middle, maskedImage.getBBox(), factor=self.config.prelimThresholdFactor,
494 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
495 )
497 maskedImage.mask, prelim, sigma, factor=self.config.prelimThresholdFactor,
498 factorNeg=self.config.prelimNegMultiplier*self.config.prelimThresholdFactor
499 )
500 if self.config.doBrightPrelimDetection:
501 # Combine prelim and bright detection masks for multiplier
502 # determination.
503 maskedImage.mask.array |= brightDetectedMask
504
505 # Calculate the proper threshold
506 threshResults = self.calculateThreshold(exposure, seed, sigma=sigma)
507 if (self.config.minThresholdScaleFactor
508 and threshResults.multiplicative < self.config.minThresholdScaleFactor):
509 self.log.warning("Measured threshold scaling factor (%.2f) is outside [min, max] "
510 "bounds [%.2f, %.2f]. Setting factor to lower limit: %.2f.",
511 threshResults.multiplicative, self.config.minThresholdScaleFactor,
512 self.config.maxThresholdScaleFactor, self.config.minThresholdScaleFactor)
513 factor = self.config.minThresholdScaleFactor
514 elif (self.config.maxThresholdScaleFactor
515 and threshResults.multiplicative > self.config.maxThresholdScaleFactor):
516 self.log.warning("Measured threshold scaling factor (%.2f) is outside [min, max] "
517 "bounds [%.2f, %.2f]. Setting factor to upper limit: %.2f.",
518 threshResults.multiplicative, self.config.minThresholdScaleFactor,
519 self.config.maxThresholdScaleFactor, self.config.maxThresholdScaleFactor)
520 factor = self.config.maxThresholdScaleFactor
521 else:
522 factor = threshResults.multiplicative
523 self.log.info("Modifying configured detection threshold by factor %.2f to %.2f",
524 factor, factor*self.config.thresholdValue)
525
526 growOverride = None
527 inFinalize = True
528 while inFinalize:
529 inFinalize = False
530 # Blow away preliminary (low threshold) detection mask
531 self.clearMask(maskedImage.mask)
532 if not clearMask:
533 maskedImage.mask.array |= oldDetected
534
535 # Rinse and repeat thresholding with new calculated threshold
536 results = self.applyThreshold(middle, maskedImage.getBBox(), factor)
537 results.prelim = prelim
538 results.background = background if background is not None else lsst.afw.math.BackgroundList()
539 if self.config.doTempLocalBackground:
540 self.applyTempLocalBackground(exposure, middle, results)
541 self.finalizeFootprints(maskedImage.mask, results, sigma, factor=factor,
542 growOverride=growOverride)
543 if results.numPos == 0:
544 msg = "No footprints were detected, so further processing would be moot"
545 raise ZeroFootprintError(msg)
546 else:
547 self.log.warning("nPeaks/nFootprint = %.2f (max is %.1f)",
548 results.numPosPeaks/results.numPos,
549 self.config.maxPeakToFootRatio)
550 if results.numPosPeaks/results.numPos > self.config.maxPeakToFootRatio:
551 if results.numPosPeaks/results.numPos > 3*self.config.maxPeakToFootRatio:
552 factor *= 1.4
553 else:
554 factor *= 1.2
555 if factor > 2.0:
556 if growOverride is None:
557 growOverride = 0.75*self.config.nSigmaToGrow
558 else:
559 growOverride *= 0.75
560 self.log.warning("Decreasing nSigmaToGrow to %.2f", growOverride)
561 if factor >= 5:
562 self.log.warning("New theshold value would be > 5 times the initially requested "
563 "one (%.2f > %.2f). Leaving inFinalize iteration without "
564 "getting the number of peaks per footprint below %.1f",
565 factor*self.config.thresholdValue, self.config.thresholdValue,
566 self.config.maxPeakToFootRatio)
567 inFinalize = False
568 else:
569 inFinalize = True
570 self.log.warning("numPosPeaks/numPos (%d) > maxPeakPerFootprint (%.1f). "
571 "Increasing threshold factor to %.2f and re-running,",
572 results.numPosPeaks/results.numPos,
573 self.config.maxPeakToFootRatio, factor)
574
575 self.clearUnwantedResults(maskedImage.mask, results)
576
577 if self.config.reEstimateBackground:
578 self.reEstimateBackground(maskedImage, results.background)
579
580 self.display(exposure, results, middle)
581
582 # Re-do the background tweak after any temporary backgrounds have
583 # been restored.
584 #
585 # But we want to keep any large-scale background (e.g., scattered
586 # light from bright stars) from being selected for sky objects in
587 # the calculation, so do another detection pass without either the
588 # local or wide temporary background subtraction; the DETECTED
589 # pixels will mark the area to ignore.
590
591 # The following if/else is to workaround the fact that it is
592 # currently not possible to persist an empty BackgroundList, so
593 # we instead set the value of the backround tweak to 0.0 if
594 # doBackgroundTweak is False and call the tweakBackground function
595 # regardless to get at least one background into the list (do we
596 # need a TODO here?).
597 if self.config.doBackgroundTweak:
598 originalMask = maskedImage.mask.array.copy()
599 try:
600 self.clearMask(exposure.mask)
601 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth)
602 tweakDetResults = self.applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor)
603 self.finalizeFootprints(maskedImage.mask, tweakDetResults, sigma, factor=factor)
604 bgLevel = self.calculateThreshold(exposure, seed, sigma=sigma, minFractionSourcesFactor=0.5,
605 isBgTweak=True).additive
606 if self.config.minBackgroundTweak and bgLevel < self.config.minBackgroundTweak:
607 self.log.warning("Measured background tweak (%.2f) is outside [min, max] bounds "
608 "[%.2f, %.2f]. Setting tweak to lower limit: %.2f.", bgLevel,
609 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
610 self.config.minBackgroundTweak)
611 bgLevel = self.config.minBackgroundTweak
612 if self.config.maxBackgroundTweak and bgLevel > self.config.maxBackgroundTweak:
613 self.log.warning("Measured background tweak (%.2f) is outside [min, max] bounds "
614 "[%.2f, %.2f]. Setting tweak to upper limit: %.2f.", bgLevel,
615 self.config.minBackgroundTweak, self.config.maxBackgroundTweak,
616 self.config.maxBackgroundTweak)
617 bgLevel = self.config.maxBackgroundTweak
618 finally:
619 maskedImage.mask.array[:] = originalMask
620 else:
621 bgLevel = 0.0
622 self.tweakBackground(exposure, bgLevel, results.background)
623
624 return results
625
626 def tweakBackground(self, exposure, bgLevel, bgList=None):
627 """Modify the background by a constant value
628
629 Parameters
630 ----------
631 exposure : `lsst.afw.image.Exposure`
632 Exposure for which to tweak background.
633 bgLevel : `float`
634 Background level to remove
635 bgList : `lsst.afw.math.BackgroundList`, optional
636 List of backgrounds to append to.
637
638 Returns
639 -------
640 bg : `lsst.afw.math.BackgroundMI`
641 Constant background model.
642 """
643 if bgLevel != 0.0:
644 self.log.info("Tweaking background by %f to match sky photometry", bgLevel)
645 exposure.image -= bgLevel
646 bgStats = lsst.afw.image.MaskedImageF(1, 1)
647 bgStats.set(bgLevel, 0, bgLevel)
648 bg = lsst.afw.math.BackgroundMI(exposure.getBBox(), bgStats)
649 bgData = (bg, lsst.afw.math.Interpolate.LINEAR, lsst.afw.math.REDUCE_INTERP_ORDER,
650 lsst.afw.math.ApproximateControl.UNKNOWN, 0, 0, False)
651 if bgList is not None:
652 bgList.append(bgData)
653 return bg
654
655 def _computeBrightDetectionMask(self, maskedImage, convolveResults):
656 """Perform an initial bright source detection pass.
657
658 Perform an initial bright object detection pass using a high detection
659 threshold. The footprints in this pass are grown significantly more
660 than is typical to account for wings around bright sources. The
661 negative polarity detections in this pass help in masking severely
662 over-subtracted regions.
663
664 A maximum fraction of masked pixel from this pass is ensured via
665 the config ``brightMaskFractionMax``. If the masked pixel fraction is
666 above this value, the detection thresholds here are increased by
667 ``bisectFactor`` in a while loop until the detected masked fraction
668 falls below this value.
669
670 Parameters
671 ----------
672 maskedImage : `lsst.afw.image.MaskedImage`
673 Masked image on which to run the detection.
674 convolveResults : `lsst.pipe.base.Struct`
675 The results of the self.convolveImage function with attributes:
676
677 ``middle``
678 Convolved image, without the edges
679 (`lsst.afw.image.MaskedImage`).
680 ``sigma``
681 Gaussian sigma used for the convolution (`float`).
682
683 Returns
684 -------
685 brightDetectedMask : `numpy.ndarray`
686 Boolean array representing the union of the bright detection pass
687 DETECTED and DETECTED_NEGATIVE masks.
688 """
689 # Initialize some parameters.
690 brightPosFactor = (
691 self.config.prelimThresholdFactor*self.config.brightMultiplier/self.config.bisectFactor
692 )
693 brightNegFactor = self.config.brightNegFactor/self.config.bisectFactor
694 nPix = 1
695 nPixDet = 1
696 nPixDetNeg = 1
697 brightMaskFractionMax = self.config.brightMaskFractionMax
698
699 # Loop until masked fraction is smaller than
700 # brightMaskFractionMax, increasing the thresholds by
701 # config.bisectFactor on each iteration (rarely necessary
702 # for current defaults).
703 while nPixDetNeg/nPix > brightMaskFractionMax or nPixDet/nPix > brightMaskFractionMax:
704 self.clearMask(maskedImage.mask)
705 brightPosFactor *= self.config.bisectFactor
706 brightNegFactor *= self.config.bisectFactor
707 prelimBright = self.applyThreshold(convolveResults.middle, maskedImage.getBBox(),
708 factor=brightPosFactor, factorNeg=brightNegFactor)
710 maskedImage.mask, prelimBright, convolveResults.sigma*self.config.brightGrowFactor,
711 factor=brightPosFactor, factorNeg=brightNegFactor
712 )
713 # Check that not too many pixels got masked.
714 nPix = maskedImage.mask.array.size
715 nPixDet = countMaskedPixels(maskedImage, "DETECTED")
716 self.log.info("Number (%) of bright DETECTED pix: {} ({:.1f}%)".
717 format(nPixDet, 100*nPixDet/nPix))
718 nPixDetNeg = countMaskedPixels(maskedImage, "DETECTED_NEGATIVE")
719 self.log.info("Number (%) of bright DETECTED_NEGATIVE pix: {} ({:.1f}%)".
720 format(nPixDetNeg, 100*nPixDetNeg/nPix))
721 if nPixDetNeg/nPix > brightMaskFractionMax or nPixDet/nPix > brightMaskFractionMax:
722 self.log.warn("Too high a fraction (%.1f > %.1f) of pixels were masked with current "
723 "\"bright\" detection round thresholds. Increasing by a factor of %.2f "
724 "and trying again.", max(nPixDetNeg, nPixDet)/nPix,
725 brightMaskFractionMax, self.config.bisectFactor)
726
727 # Save the mask planes from the "bright" detection round, then
728 # clear them before moving on to the "prelim" detection phase.
729 brightDetectedMask = (maskedImage.mask.array
730 & maskedImage.mask.getPlaneBitMask(["DETECTED", "DETECTED_NEGATIVE"]))
731 self.clearMask(maskedImage.mask)
732 return brightDetectedMask
733
734
735def countMaskedPixels(maskedIm, maskPlane):
736 """Count the number of pixels in a given mask plane.
737
738 Parameters
739 ----------
740 maskedIm : `lsst.afw.image.MaskedImage`
741 Masked image to examine.
742 maskPlane : `str`
743 Name of the mask plane to examine.
744
745 Returns
746 -------
747 nPixMasked : `int`
748 Number of pixels with ``maskPlane`` bit set.
749 """
750 maskBit = maskedIm.mask.getPlaneBitMask(maskPlane)
751 nPixMasked = np.sum(np.bitwise_and(maskedIm.mask.array, maskBit))/maskBit
752 return nPixMasked
A set of Footprints, associated with a MaskedImage.
static MaskPixelT getPlaneBitMask(const std::vector< std::string > &names)
Return the bitmask corresponding to a vector of plane names OR'd together.
Definition Mask.cc:376
A class to evaluate image background levels.
Definition Background.h:435
Point in an unspecified spherical coordinate system.
Definition SpherePoint.h:57
applyTempLocalBackground(self, exposure, middle, results)
Definition detection.py:399
applyThreshold(self, middle, bbox, factor=1.0, factorNeg=None)
Definition detection.py:566
convolveImage(self, maskedImage, psf, doSmooth=True)
Definition detection.py:504
display(self, exposure, results, convolvedImage=None)
Definition detection.py:342
finalizeFootprints(self, mask, results, sigma, factor=1.0, factorNeg=None, growOverride=None)
Definition detection.py:632
reEstimateBackground(self, maskedImage, backgrounds, backgroundToPhotometricRatio=None)
Definition detection.py:706
detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None, background=None, backgroundToPhotometricRatio=None)
_computeBrightDetectionMask(self, maskedImage, convolveResults)
calculateThreshold(self, exposure, seed, sigma=None, minFractionSourcesFactor=1.0, isBgTweak=False, nPixMaskErode=None, maxMaskErodeIter=10)