Coverage for python/lsst/ip/diffim/dipoleMeasurement.py: 20%

170 statements  

« prev     ^ index     » next       coverage.py v7.4.3, created at 2024-03-14 11:41 -0700

1# This file is part of ip_diffim. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import numpy as np 

23 

24import lsst.afw.image as afwImage 

25import lsst.geom as geom 

26import lsst.pex.config as pexConfig 

27import lsst.meas.deblender.baseline as deblendBaseline 

28from lsst.meas.base.pluginRegistry import register 

29from lsst.meas.base import SingleFrameMeasurementTask, SingleFrameMeasurementConfig, \ 

30 SingleFramePluginConfig, SingleFramePlugin 

31import lsst.afw.display as afwDisplay 

32from lsst.utils.logging import getLogger 

33 

34__all__ = ("DipoleMeasurementConfig", "DipoleMeasurementTask", "DipoleAnalysis", "DipoleDeblender", 

35 "SourceFlagChecker", "ClassificationDipoleConfig", "ClassificationDipolePlugin") 

36 

37 

38class ClassificationDipoleConfig(SingleFramePluginConfig): 

39 """Configuration for classification of detected diaSources as dipole or not""" 

40 minSn = pexConfig.Field( 

41 doc="Minimum quadrature sum of positive+negative lobe S/N to be considered a dipole", 

42 dtype=float, default=np.sqrt(2) * 5.0, 

43 ) 

44 maxFluxRatio = pexConfig.Field( 

45 doc="Maximum flux ratio in either lobe to be considered a dipole", 

46 dtype=float, default=0.65 

47 ) 

48 

49 

50@register("ip_diffim_ClassificationDipole") 

51class ClassificationDipolePlugin(SingleFramePlugin): 

52 """A plugin to classify whether a diaSource is a dipole. 

53 """ 

54 

55 ConfigClass = ClassificationDipoleConfig 

56 

57 @classmethod 

58 def getExecutionOrder(cls): 

59 """ 

60 Returns 

61 ------- 

62 result : `callable` 

63 """ 

64 return cls.APCORR_ORDER 

65 

66 def __init__(self, config, name, schema, metadata): 

67 SingleFramePlugin.__init__(self, config, name, schema, metadata) 

68 self.dipoleAnalysis = DipoleAnalysis() 

69 self.keyProbability = schema.addField(name + "_value", type="D", 

70 doc="Set to 1 for dipoles, else 0.") 

71 self.keyFlag = schema.addField(name + "_flag", type="Flag", doc="Set to 1 for any fatal failure.") 

72 

73 def measure(self, measRecord, exposure): 

74 passesSn = self.dipoleAnalysis.getSn(measRecord) > self.config.minSn 

75 negFlux = np.abs(measRecord.get("ip_diffim_PsfDipoleFlux_neg_instFlux")) 

76 negFluxFlag = measRecord.get("ip_diffim_PsfDipoleFlux_neg_flag") 

77 posFlux = np.abs(measRecord.get("ip_diffim_PsfDipoleFlux_pos_instFlux")) 

78 posFluxFlag = measRecord.get("ip_diffim_PsfDipoleFlux_pos_flag") 

79 

80 if negFluxFlag or posFluxFlag: 

81 self.fail(measRecord) 

82 # continue on to classify 

83 

84 totalFlux = negFlux + posFlux 

85 

86 # If negFlux or posFlux are NaN, these evaluate to False 

87 passesFluxNeg = (negFlux / totalFlux) < self.config.maxFluxRatio 

88 passesFluxPos = (posFlux / totalFlux) < self.config.maxFluxRatio 

89 if (passesSn and passesFluxPos and passesFluxNeg): 

90 val = 1.0 

91 else: 

92 val = 0.0 

93 

94 measRecord.set(self.keyProbability, val) 

95 

96 def fail(self, measRecord, error=None): 

97 measRecord.set(self.keyFlag, True) 

98 

99 

100class DipoleMeasurementConfig(SingleFrameMeasurementConfig): 

101 """Measurement of detected diaSources as dipoles""" 

102 

103 def setDefaults(self): 

104 SingleFrameMeasurementConfig.setDefaults(self) 

105 self.plugins = ["base_CircularApertureFlux", 

106 "base_PixelFlags", 

107 "base_SkyCoord", 

108 "base_PsfFlux", 

109 "ip_diffim_NaiveDipoleCentroid", 

110 "ip_diffim_NaiveDipoleFlux", 

111 "ip_diffim_PsfDipoleFlux", 

112 "ip_diffim_ClassificationDipole", 

113 ] 

114 

115 self.slots.calibFlux = None 

116 self.slots.modelFlux = None 

117 self.slots.gaussianFlux = None 

118 self.slots.shape = None 

119 self.slots.centroid = "ip_diffim_NaiveDipoleCentroid" 

120 self.doReplaceWithNoise = False 

121 

122 

123class DipoleMeasurementTask(SingleFrameMeasurementTask): 

124 """Measurement of Sources, specifically ones from difference images, for characterization as dipoles 

125 

126 Parameters 

127 ---------- 

128 sources : 'lsst.afw.table.SourceCatalog' 

129 Sources that will be measured 

130 badFlags : `list` of `dict` 

131 A list of flags that will be used to determine if there was a measurement problem 

132 

133 """ 

134 ConfigClass = DipoleMeasurementConfig 

135 _DefaultName = "dipoleMeasurement" 

136 

137 

138######### 

139# Other Support classs 

140######### 

141 

142class SourceFlagChecker(object): 

143 """Functor class to check whether a diaSource has flags set that should cause it to be labeled bad.""" 

144 

145 def __init__(self, sources, badFlags=None): 

146 self.badFlags = ['base_PixelFlags_flag_edge', 'base_PixelFlags_flag_interpolatedCenter', 

147 'base_PixelFlags_flag_saturatedCenter'] 

148 if badFlags is not None: 

149 for flag in badFlags: 

150 self.badFlags.append(flag) 

151 self.keys = [sources.getSchema().find(name).key for name in self.badFlags] 

152 self.keys.append(sources.table.getCentroidFlagKey()) 

153 

154 def __call__(self, source): 

155 """Call the source flag checker on a single Source 

156 

157 Parameters 

158 ---------- 

159 source : 

160 Source that will be examined 

161 """ 

162 for k in self.keys: 

163 if source.get(k): 

164 return False 

165 return True 

166 

167 

168class DipoleAnalysis(object): 

169 """Functor class that provides (S/N, position, orientation) of measured dipoles""" 

170 

171 def __init__(self): 

172 pass 

173 

174 def __call__(self, source): 

175 """Parse information returned from dipole measurement 

176 

177 Parameters 

178 ---------- 

179 source : `lsst.afw.table.SourceRecord` 

180 The source that will be examined""" 

181 return self.getSn(source), self.getCentroid(source), self.getOrientation(source) 

182 

183 def getSn(self, source): 

184 """Get the total signal-to-noise of the dipole; total S/N is from positive and negative lobe 

185 

186 Parameters 

187 ---------- 

188 source : `lsst.afw.table.SourceRecord` 

189 The source that will be examined""" 

190 

191 posflux = source.get("ip_diffim_PsfDipoleFlux_pos_instFlux") 

192 posfluxErr = source.get("ip_diffim_PsfDipoleFlux_pos_instFluxErr") 

193 negflux = source.get("ip_diffim_PsfDipoleFlux_neg_instFlux") 

194 negfluxErr = source.get("ip_diffim_PsfDipoleFlux_neg_instFluxErr") 

195 

196 # Not a dipole! 

197 if (posflux < 0) is (negflux < 0): 

198 return 0 

199 

200 return np.sqrt((posflux/posfluxErr)**2 + (negflux/negfluxErr)**2) 

201 

202 def getCentroid(self, source): 

203 """Get the centroid of the dipole; average of positive and negative lobe 

204 

205 Parameters 

206 ---------- 

207 source : `lsst.afw.table.SourceRecord` 

208 The source that will be examined""" 

209 

210 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x") 

211 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y") 

212 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x") 

213 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y") 

214 if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)): 

215 return None 

216 

217 center = geom.Point2D(0.5*(negCenX+posCenX), 

218 0.5*(negCenY+posCenY)) 

219 return center 

220 

221 def getOrientation(self, source): 

222 """Calculate the orientation of dipole; vector from negative to positive lobe 

223 

224 Parameters 

225 ---------- 

226 source : `lsst.afw.table.SourceRecord` 

227 The source that will be examined""" 

228 

229 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x") 

230 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y") 

231 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x") 

232 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y") 

233 if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)): 

234 return None 

235 

236 dx, dy = posCenX-negCenX, posCenY-negCenY 

237 angle = geom.Angle(np.arctan2(dx, dy), geom.radians) 

238 return angle 

239 

240 def displayDipoles(self, exposure, sources): 

241 """Display debugging information on the detected dipoles 

242 

243 Parameters 

244 ---------- 

245 exposure : `lsst.afw.image.Exposure` 

246 Image the dipoles were measured on 

247 sources : `lsst.afw.table.SourceCatalog` 

248 The set of diaSources that were measured""" 

249 

250 import lsstDebug 

251 display = lsstDebug.Info(__name__).display 

252 displayDiaSources = lsstDebug.Info(__name__).displayDiaSources 

253 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

254 if not maskTransparency: 

255 maskTransparency = 90 

256 disp = afwDisplay.Display(frame=lsstDebug.frame) 

257 disp.setMaskTransparency(maskTransparency) 

258 disp.mtv(exposure) 

259 

260 if display and displayDiaSources: 

261 with disp.Buffering(): 

262 for source in sources: 

263 cenX, cenY = source.get("ipdiffim_DipolePsfFlux_centroid") 

264 if np.isinf(cenX) or np.isinf(cenY): 

265 cenX, cenY = source.getCentroid() 

266 

267 isdipole = source.get("ip_diffim_ClassificationDipole_value") 

268 if isdipole and np.isfinite(isdipole): 

269 # Dipole 

270 ctype = afwDisplay.GREEN 

271 else: 

272 # Not dipole 

273 ctype = afwDisplay.RED 

274 

275 disp.dot("o", cenX, cenY, size=2, ctype=ctype) 

276 

277 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x") 

278 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y") 

279 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x") 

280 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y") 

281 if (np.isinf(negCenX) or np.isinf(negCenY) or np.isinf(posCenX) or np.isinf(posCenY)): 

282 continue 

283 

284 disp.line([(negCenX, negCenY), (posCenX, posCenY)], ctype=afwDisplay.YELLOW) 

285 

286 lsstDebug.frame += 1 

287 

288 

289class DipoleDeblender(object): 

290 """Functor to deblend a source as a dipole, and return a new source with deblended footprints. 

291 

292 This necessarily overrides some of the functionality from 

293 meas_algorithms/python/lsst/meas/algorithms/deblend.py since we 

294 need a single source that contains the blended peaks, not 

295 multiple children sources. This directly calls the core 

296 deblending code deblendBaseline.deblend (optionally _fitPsf for 

297 debugging). 

298 

299 Not actively being used, but there is a unit test for it in 

300 dipoleAlgorithm.py. 

301 """ 

302 

303 def __init__(self): 

304 # Set up defaults to send to deblender 

305 

306 # Always deblend as Psf 

307 self.psfChisqCut1 = self.psfChisqCut2 = self.psfChisqCut2b = np.inf 

308 self.log = getLogger('lsst.ip.diffim.DipoleDeblender') 

309 self.sigma2fwhm = 2. * np.sqrt(2. * np.log(2.)) 

310 

311 def __call__(self, source, exposure): 

312 fp = source.getFootprint() 

313 peaks = fp.getPeaks() 

314 peaksF = [pk.getF() for pk in peaks] 

315 fbb = fp.getBBox() 

316 fmask = afwImage.Mask(fbb) 

317 fmask.setXY0(fbb.getMinX(), fbb.getMinY()) 

318 fp.spans.setMask(fmask, 1) 

319 

320 psf = exposure.getPsf() 

321 psfSigPix = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius() 

322 psfFwhmPix = psfSigPix * self.sigma2fwhm 

323 subimage = afwImage.ExposureF(exposure, bbox=fbb, deep=True) 

324 cpsf = deblendBaseline.CachingPsf(psf) 

325 

326 # if fewer than 2 peaks, just return a copy of the source 

327 if len(peaks) < 2: 

328 return source.getTable().copyRecord(source) 

329 

330 # make sure you only deblend 2 peaks; take the brighest and faintest 

331 speaks = [(p.getPeakValue(), p) for p in peaks] 

332 speaks.sort() 

333 dpeaks = [speaks[0][1], speaks[-1][1]] 

334 

335 # and only set these peaks in the footprint (peaks is mutable) 

336 peaks.clear() 

337 for peak in dpeaks: 

338 peaks.append(peak) 

339 

340 if True: 

341 # Call top-level deblend task 

342 fpres = deblendBaseline.deblend(fp, exposure.getMaskedImage(), psf, psfFwhmPix, 

343 log=self.log, 

344 psfChisqCut1=self.psfChisqCut1, 

345 psfChisqCut2=self.psfChisqCut2, 

346 psfChisqCut2b=self.psfChisqCut2b) 

347 else: 

348 # Call lower-level _fit_psf task 

349 

350 # Prepare results structure 

351 fpres = deblendBaseline.DeblenderResult(fp, exposure.getMaskedImage(), psf, psfFwhmPix, self.log) 

352 

353 for pki, (pk, pkres, pkF) in enumerate(zip(dpeaks, fpres.deblendedParents[0].peaks, peaksF)): 

354 self.log.debug('Peak %i', pki) 

355 deblendBaseline._fitPsf(fp, fmask, pk, pkF, pkres, fbb, dpeaks, peaksF, self.log, 

356 cpsf, psfFwhmPix, 

357 subimage.image, 

358 subimage.variance, 

359 self.psfChisqCut1, self.psfChisqCut2, self.psfChisqCut2b) 

360 

361 deblendedSource = source.getTable().copyRecord(source) 

362 deblendedSource.setParent(source.getId()) 

363 peakList = deblendedSource.getFootprint().getPeaks() 

364 peakList.clear() 

365 

366 for i, peak in enumerate(fpres.deblendedParents[0].peaks): 

367 if peak.psfFitFlux > 0: 

368 suffix = "pos" 

369 else: 

370 suffix = "neg" 

371 c = peak.psfFitCenter 

372 self.log.info("deblended.centroid.dipole.psf.%s %f %f", 

373 suffix, c[0], c[1]) 

374 self.log.info("deblended.chi2dof.dipole.%s %f", 

375 suffix, peak.psfFitChisq / peak.psfFitDof) 

376 self.log.info("deblended.flux.dipole.psf.%s %f", 

377 suffix, peak.psfFitFlux * np.sum(peak.templateImage.array)) 

378 peakList.append(peak.peak) 

379 return deblendedSource