Coverage for python/lsst/meas/algorithms/dynamicDetection.py: 18%

117 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-09 03:48 -0800

1 

2__all__ = ["DynamicDetectionConfig", "DynamicDetectionTask"] 

3 

4import numpy as np 

5 

6from lsst.pex.config import Field, ConfigurableField 

7from lsst.pipe.base import Struct 

8 

9from .detection import SourceDetectionConfig, SourceDetectionTask 

10from .skyObjects import SkyObjectsTask 

11 

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 

16 

17import lsst.afw.image 

18import lsst.afw.math 

19import lsst.geom as geom 

20 

21 

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.") 

33 

34 def setDefaults(self): 

35 SourceDetectionConfig.setDefaults(self) 

36 self.skyObjects.nSources = 1000 # For good statistics 

37 

38 

39class DynamicDetectionTask(SourceDetectionTask): 

40 """Detection of sources on an image with a dynamic threshold 

41 

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. 

48 

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" 

56 

57 def __init__(self, *args, **kwargs): 

58 

59 SourceDetectionTask.__init__(self, *args, **kwargs) 

60 self.makeSubtask("skyObjects") 

61 

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) 

72 

73 def calculateThreshold(self, exposure, seed, sigma=None): 

74 """Calculate new threshold 

75 

76 This is the main functional addition to the vanilla 

77 `SourceDetectionTask`. 

78 

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. 

83 

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. 

93 

94 Returns 

95 ------- 

96 result : `lsst.pipe.base.Struct` 

97 Result struct with components: 

98 

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()) 

125 

126 # Forced photometry on sky objects 

127 self.skyMeasurement.run(catalog, exposure, catalog, exposure.getWcs()) 

128 

129 # Calculate new threshold 

130 fluxes = catalog["base_PsfFlux_instFlux"] 

131 area = catalog["base_PsfFlux_area"] 

132 bg = catalog["base_LocalBackground_instFlux"] 

133 

134 good = (~catalog["base_PsfFlux_flag"] & ~catalog["base_LocalBackground_flag"] 

135 & np.isfinite(fluxes) & np.isfinite(area) & np.isfinite(bg)) 

136 

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) 

141 

142 bgMedian = np.median((fluxes/area)[good]) 

143 

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) 

150 

151 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None): 

152 """Detect footprints with a dynamic threshold 

153 

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. 

158 

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. 

177 

178 Returns 

179 ------- 

180 resutls : `lsst.pipe.base.Struct` 

181 The results `~lsst.pipe.base.Struct` contains: 

182 

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 

207 

208 if clearMask: 

209 self.clearMask(maskedImage.mask) 

210 else: 

211 oldDetected = maskedImage.mask.array & maskedImage.mask.getPlaneBitMask(["DETECTED", 

212 "DETECTED_NEGATIVE"]) 

213 

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) 

224 

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) 

232 

233 # Blow away preliminary (low threshold) detection mask 

234 self.clearMask(maskedImage.mask) 

235 if not clearMask: 

236 maskedImage.mask.array |= oldDetected 

237 

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) 

245 

246 self.clearUnwantedResults(maskedImage.mask, results) 

247 

248 if self.config.reEstimateBackground: 

249 self.reEstimateBackground(maskedImage, results.background) 

250 

251 self.display(exposure, results, middle) 

252 

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) 

272 

273 return results 

274 

275 def tweakBackground(self, exposure, bgLevel, bgList=None): 

276 """Modify the background by a constant value 

277 

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. 

286 

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