Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

133

134

135

136

137

138

139

140

141

142

143

144

145

146

147

148

149

150

151

152

153

154

155

156

157

158

159

160

161

162

163

164

165

166

167

168

169

170

171

172

173

174

175

176

177

178

179

180

181

182

183

184

185

186

187

188

189

190

191

192

193

194

195

196

197

198

199

200

201

202

203

204

205

206

207

208

209

210

211

212

213

214

215

216

217

218

219

220

221

222

223

224

225

226

227

228

229

230

231

232

233

234

235

236

237

238

239

240

241

242

243

244

245

246

247

248

249

250

251

252

253

254

255

256

257

258

259

260

261

262

263

264

265

266

267

268

269

270

271

272

273

274

275

276

277

278

279

280

281

282

 

__all__ = ["DynamicDetectionConfig", "DynamicDetectionTask"] 

 

import numpy as np 

 

from lsst.pex.config import Field, ConfigurableField 

from lsst.pipe.base import Struct 

 

from .detection import SourceDetectionConfig, SourceDetectionTask 

from .skyObjects import SkyObjectsTask 

 

from lsst.afw.detection import FootprintSet 

from lsst.afw.table import SourceCatalog, SourceTable 

from lsst.meas.base import ForcedMeasurementTask 

 

import lsst.afw.image 

import lsst.afw.math 

 

 

class DynamicDetectionConfig(SourceDetectionConfig): 

"""Configuration for DynamicDetectionTask""" 

prelimThresholdFactor = Field(dtype=float, default=0.5, 

doc="Fraction of the threshold to use for first pass (to find sky objects)") 

skyObjects = ConfigurableField(target=SkyObjectsTask, doc="Generate sky objects") 

doBackgroundTweak = Field(dtype=bool, default=True, 

doc="Tweak background level so median PSF flux of sky objects is zero?") 

minNumSources = Field(dtype=int, default=10, 

doc="Minimum number of sky sources in statistical sample; " 

"if below this number, we refuse to modify the threshold.") 

 

def setDefaults(self): 

SourceDetectionConfig.setDefaults(self) 

self.skyObjects.nSources = 1000 # For good statistics 

 

 

class DynamicDetectionTask(SourceDetectionTask): 

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

 

We first detect sources using a lower threshold than normal (see config 

parameter ``prelimThresholdFactor``) in order to identify good sky regions 

(configurable ``skyObjects``). Then we perform forced PSF photometry on 

those sky regions. Using those PSF flux measurements and estimated errors, 

we set the threshold so that the stdev of the measurements matches the 

median estimated error. 

""" 

ConfigClass = DynamicDetectionConfig 

_DefaultName = "dynamicDetection" 

 

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

"""Constructor 

 

Besides the usual initialisation of configurables, we also set up 

the forced measurement which is deliberately not represented in 

this Task's configuration parameters because we're using it as part 

of the algorithm and we don't want to allow it to be modified. 

""" 

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

self.makeSubtask("skyObjects") 

 

# Set up forced measurement. 

config = ForcedMeasurementTask.ConfigClass() 

config.plugins.names = ['base_TransformedCentroid', 'base_PsfFlux', 'base_LocalBackground'] 

# We'll need the "centroid" and "psfFlux" slots 

for slot in ("shape", "psfShape", "apFlux", "modelFlux", "instFlux", "calibFlux"): 

setattr(config.slots, slot, None) 

config.copyColumns = {} 

self.skySchema = SourceTable.makeMinimalSchema() 

self.skyMeasurement = ForcedMeasurementTask(config=config, name="skyMeasurement", parentTask=self, 

refSchema=self.skySchema) 

 

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

"""Calculate new threshold 

 

This is the main functional addition to the vanilla 

`SourceDetectionTask`. 

 

We identify sky objects and perform forced PSF photometry on 

them. Using those PSF flux measurements and estimated errors, 

we set the threshold so that the stdev of the measurements 

matches the median estimated error. 

 

Parameters 

---------- 

exposure : `lsst.afw.image.Exposure` 

Exposure on which we're detecting sources. 

seed : `int` 

RNG seed to use for finding sky objects. 

sigma : `float`, optional 

Gaussian sigma of smoothing kernel; if not provided, 

will be deduced from the exposure's PSF. 

 

Returns 

------- 

result : `lsst.pipe.base.Struct` 

Result struct with components: 

 

- ``multiplicative``: multiplicative factor to be applied to the 

configured detection threshold (`float`). 

- ``additive``: additive factor to be applied to the background 

level (`float`). 

""" 

# Make a catalog of sky objects 

fp = self.skyObjects.run(exposure.maskedImage.mask, seed) 

skyFootprints = FootprintSet(exposure.getBBox()) 

skyFootprints.setFootprints(fp) 

table = SourceTable.make(self.skyMeasurement.schema) 

catalog = SourceCatalog(table) 

catalog.reserve(len(skyFootprints.getFootprints())) 

skyFootprints.makeSources(catalog) 

key = catalog.getCentroidKey() 

for source in catalog: 

peaks = source.getFootprint().getPeaks() 

assert len(peaks) == 1 

source.set(key, peaks[0].getF()) 

source.updateCoord(exposure.getWcs()) 

 

# Forced photometry on sky objects 

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

 

# Calculate new threshold 

fluxes = catalog["base_PsfFlux_flux"] 

area = catalog["base_PsfFlux_area"] 

bg = catalog["base_LocalBackground_flux"] 

 

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

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

 

if good.sum() < self.config.minNumSources: 

self.log.warn("Insufficient good flux measurements (%d < %d) for dynamic threshold calculation", 

good.sum(), self.config.minNumSources) 

return Struct(multiplicative=1.0, additive=0.0) 

 

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

 

lq, uq = np.percentile((fluxes - bg*area)[good], [25.0, 75.0]) 

stdevMeas = 0.741*(uq - lq) 

medianError = np.median(catalog["base_PsfFlux_fluxErr"][good]) 

return Struct(multiplicative=medianError/stdevMeas, additive=bgMedian) 

 

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

"""Detect footprints with a dynamic threshold 

 

This varies from the vanilla ``detectFootprints`` method because we 

do detection twice: one with a low threshold so that we can find 

sky uncontaminated by objects, then one more with the new calculated 

threshold. 

 

Parameters 

---------- 

exposure : `lsst.afw.image.Exposure` 

Exposure to process; DETECTED{,_NEGATIVE} mask plane will be 

set in-place. 

doSmooth : `bool`, optional 

If True, smooth the image before detection using a Gaussian 

of width ``sigma``. 

sigma : `float`, optional 

Gaussian Sigma of PSF (pixels); used for smoothing and to grow 

detections; if `None` then measure the sigma of the PSF of the 

``exposure``. 

clearMask : `bool`, optional 

Clear both DETECTED and DETECTED_NEGATIVE planes before running 

detection. 

expId : `int`, optional 

Exposure identifier, used as a seed for the random number 

generator. If absent, the seed will be the sum of the image. 

 

Return Struct contents 

---------------------- 

positive : `lsst.afw.detection.FootprintSet` 

Positive polarity footprints (may be `None`) 

negative : `lsst.afw.detection.FootprintSet` 

Negative polarity footprints (may be `None`) 

numPos : `int` 

Number of footprints in positive or 0 if detection polarity was 

negative. 

numNeg : `int` 

Number of footprints in negative or 0 if detection polarity was 

positive. 

background : `lsst.afw.math.BackgroundList` 

Re-estimated background. `None` if 

``reEstimateBackground==False``. 

factor : `float` 

Multiplication factor applied to the configured detection 

threshold. 

prelim : `lsst.pipe.base.Struct` 

Results from preliminary detection pass. 

""" 

maskedImage = exposure.maskedImage 

 

190 ↛ 193line 190 didn't jump to line 193, because the condition on line 190 was never false if clearMask: 

self.clearMask(maskedImage.mask) 

else: 

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

"DETECTED_NEGATIVE"]) 

 

with self.tempWideBackgroundContext(exposure): 

# Could potentially smooth with a wider kernel than the PSF in order to better pick up the 

# wings of stars and galaxies, but for now sticking with the PSF as that's more simple. 

psf = self.getPsf(exposure, sigma=sigma) 

convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth) 

middle = convolveResults.middle 

sigma = convolveResults.sigma 

prelim = self.applyThreshold(middle, maskedImage.getBBox(), self.config.prelimThresholdFactor) 

self.finalizeFootprints(maskedImage.mask, prelim, sigma, self.config.prelimThresholdFactor) 

 

# Calculate the proper threshold 

# seed needs to fit in a C++ 'int' so pybind doesn't choke on it 

seed = (expId if expId is not None else int(maskedImage.image.array.sum())) % (2**31 - 1) 

threshResults = self.calculateThreshold(exposure, seed, sigma=sigma) 

factor = threshResults.multiplicative 

self.log.info("Modifying configured detection threshold by factor %f to %f", 

factor, factor*self.config.thresholdValue) 

213 ↛ 217line 213 didn't jump to line 217, because the condition on line 213 was never false if self.config.doBackgroundTweak: 

self.tweakBackground(exposure, threshResults.additive) 

 

# Blow away preliminary (low threshold) detection mask 

self.clearMask(maskedImage.mask) 

218 ↛ 219line 218 didn't jump to line 219, because the condition on line 218 was never true if not clearMask: 

maskedImage.mask.array |= oldDetected 

 

# Rinse and repeat thresholding with new calculated threshold 

results = self.applyThreshold(middle, maskedImage.getBBox(), factor) 

results.prelim = prelim 

results.background = lsst.afw.math.BackgroundList() 

225 ↛ 227line 225 didn't jump to line 227, because the condition on line 225 was never false if self.config.doTempLocalBackground: 

self.applyTempLocalBackground(exposure, middle, results) 

self.finalizeFootprints(maskedImage.mask, results, sigma, factor) 

 

self.clearUnwantedResults(maskedImage.mask, results) 

 

231 ↛ 232line 231 didn't jump to line 232, because the condition on line 231 was never true if self.config.reEstimateBackground: 

self.reEstimateBackground(maskedImage, results.background) 

 

self.display(exposure, results, middle) 

 

236 ↛ 254line 236 didn't jump to line 254, because the condition on line 236 was never false if self.config.doBackgroundTweak: 

# Re-do the background tweak after any temporary backgrounds have been restored 

# 

# But we want to keep any large-scale background (e.g., scattered light from bright stars) 

# from being selected for sky objects in the calculation, so do another detection pass without 

# either the local or wide temporary background subtraction; the DETECTED pixels will mark 

# the area to ignore. 

originalMask = maskedImage.mask.array.copy() 

try: 

self.clearMask(exposure.mask) 

convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth) 

tweakDetResults = self.applyThreshold(convolveResults.middle, maskedImage.getBBox(), factor) 

self.finalizeFootprints(maskedImage.mask, tweakDetResults, sigma, factor) 

bgLevel = self.calculateThreshold(exposure, seed, sigma=sigma).additive 

finally: 

maskedImage.mask.array[:] = originalMask 

self.tweakBackground(exposure, bgLevel, results.background) 

 

return results 

 

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

"""Modify the background by a constant value 

 

Parameters 

---------- 

exposure : `lsst.afw.image.Exposure` 

Exposure for which to tweak background. 

bgLevel : `float` 

Background level to remove 

bgList : `lsst.afw.math.BackgroundList`, optional 

List of backgrounds to append to. 

 

Returns 

------- 

bg : `lsst.afw.math.BackgroundMI` 

Constant background model. 

""" 

self.log.info("Tweaking background by %f to match sky photometry", bgLevel) 

exposure.image -= bgLevel 

bgStats = lsst.afw.image.MaskedImageF(1, 1) 

bgStats.set(bgLevel, 0, bgLevel) 

bg = lsst.afw.math.BackgroundMI(exposure.getBBox(), bgStats) 

bgData = (bg, lsst.afw.math.Interpolate.LINEAR, lsst.afw.math.REDUCE_INTERP_ORDER, 

lsst.afw.math.ApproximateControl.UNKNOWN, 0, 0, False) 

if bgList is not None: 

bgList.append(bgData) 

return bg