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

170 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-28 03:27 -0800

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 Notes 

134 ----- 

135 The list of badFlags will be used to make a list of keys to check for measurement flags on. By 

136 default the centroid keys are added to this list 

137 

138 Description 

139 

140 This class provides a default configuration for running Source measurement on image differences. 

141 

142 .. code-block:: py 

143 

144 class DipoleMeasurementConfig(SingleFrameMeasurementConfig): 

145 "Measurement of detected diaSources as dipoles" 

146 def setDefaults(self): 

147 SingleFrameMeasurementConfig.setDefaults(self) 

148 self.plugins = ["base_CircularApertureFlux", 

149 "base_PixelFlags", 

150 "base_SkyCoord", 

151 "base_PsfFlux", 

152 "ip_diffim_NaiveDipoleCentroid", 

153 "ip_diffim_NaiveDipoleFlux", 

154 "ip_diffim_PsfDipoleFlux", 

155 "ip_diffim_ClassificationDipole", 

156 ] 

157 self.slots.calibFlux = None 

158 self.slots.modelFlux = None 

159 self.slots.instFlux = None 

160 self.slots.shape = None 

161 self.slots.centroid = "ip_diffim_NaiveDipoleCentroid" 

162 self.doReplaceWithNoise = False 

163 

164 These plugins enabled by default allow the user to test the hypothesis that the Source is a dipole. 

165 This includes a set of measurements derived from intermediate base classes 

166 DipoleCentroidAlgorithm and DipoleFluxAlgorithm. 

167 Their respective algorithm control classes are defined in 

168 DipoleCentroidControl and DipoleFluxControl. 

169 Each centroid and flux measurement will have _neg (negative) 

170 and _pos (positive lobe) fields. 

171 

172 The first set of measurements uses a "naive" alrogithm 

173 for centroid and flux measurements, implemented in 

174 NaiveDipoleCentroidControl and NaiveDipoleFluxControl. 

175 The algorithm uses a naive 3x3 weighted moment around 

176 the nominal centroids of each peak in the Source Footprint. These algorithms fill the table fields 

177 ip_diffim_NaiveDipoleCentroid* and ip_diffim_NaiveDipoleFlux* 

178 

179 The second set of measurements undertakes a joint-Psf model on the negative 

180 and positive lobe simultaneously. This fit simultaneously solves for the negative and positive 

181 lobe centroids and fluxes using non-linear least squares minimization. 

182 The fields are stored in table elements ip_diffim_PsfDipoleFlux*. 

183 

184 Because this Task is just a config for SingleFrameMeasurementTask, the same result may be acheived by 

185 manually editing the config and running SingleFrameMeasurementTask. For example: 

186 

187 .. code-block:: py 

188 

189 config = SingleFrameMeasurementConfig() 

190 config.plugins.names = ["base_PsfFlux", 

191 "ip_diffim_PsfDipoleFlux", 

192 "ip_diffim_NaiveDipoleFlux", 

193 "ip_diffim_NaiveDipoleCentroid", 

194 "ip_diffim_ClassificationDipole", 

195 "base_CircularApertureFlux", 

196 "base_SkyCoord"] 

197 

198 config.slots.calibFlux = None 

199 config.slots.modelFlux = None 

200 config.slots.instFlux = None 

201 config.slots.shape = None 

202 config.slots.centroid = "ip_diffim_NaiveDipoleCentroid" 

203 config.doReplaceWithNoise = False 

204 

205 schema = afwTable.SourceTable.makeMinimalSchema() 

206 task = SingleFrameMeasurementTask(schema, config=config)- 

207 

208 Debug variables 

209 

210 The ``pipetask`` command line interface supports a 

211 flag --debug to import @b debug.py from your PYTHONPATH. The relevant contents of debug.py 

212 for this Task include: 

213 

214 .. code-block:: py 

215 

216 import sys 

217 import lsstDebug 

218 def DebugInfo(name): 

219 di = lsstDebug.getInfo(name) 

220 if name == "lsst.ip.diffim.dipoleMeasurement": 

221 di.display = True # enable debug output 

222 di.maskTransparency = 90 # display mask transparency 

223 di.displayDiaSources = True # show exposure with dipole results 

224 return di 

225 lsstDebug.Info = DebugInfo 

226 lsstDebug.frame = 1 

227 

228 config.slots.calibFlux = None 

229 config.slots.modelFlux = None 

230 config.slots.gaussianFlux = None 

231 config.slots.shape = None 

232 config.slots.centroid = "ip_diffim_NaiveDipoleCentroid" 

233 config.doReplaceWithNoise = False 

234 

235 Start the processing by parsing the command line, where the user has the option of 

236 enabling debugging output and/or sending their own image for demonstration 

237 (in case they have not downloaded the afwdata package). 

238 

239 .. code-block:: py 

240 

241 if __name__ == "__main__": 

242 import argparse 

243 parser = argparse.ArgumentParser( 

244 description="Demonstrate the use of SourceDetectionTask and DipoleMeasurementTask") 

245 parser.add_argument('--debug', '-d', action="store_true", help="Load debug.py?", default=False) 

246 parser.add_argument("--image", "-i", help="User defined image", default=None) 

247 args = parser.parse_args() 

248 if args.debug: 

249 try: 

250 import debug 

251 debug.lsstDebug.frame = 2 

252 except ImportError as e: 

253 print(e, file=sys.stderr) 

254 run(args) 

255 

256 The processing occurs in the run function. We first extract an exposure from disk or afwdata, displaying 

257 it if requested: 

258 

259 .. code-block:: py 

260 

261 def run(args): 

262 exposure = loadData(args.image) 

263 if args.debug: 

264 afwDisplay.Display(frame=1).mtv(exposure) 

265 

266 Create a default source schema that we will append fields to as we add more algorithms: 

267 

268 .. code-block:: py 

269 

270 schema = afwTable.SourceTable.makeMinimalSchema() 

271 

272 Create the detection and measurement Tasks, with some minor tweaking of their configs: 

273 

274 .. code-block:: py 

275 

276 # Create the detection task 

277 config = SourceDetectionTask.ConfigClass() 

278 config.thresholdPolarity = "both" 

279 config.background.isNanSafe = True 

280 config.thresholdValue = 3 

281 detectionTask = SourceDetectionTask(config=config, schema=schema) 

282 # And the measurement Task 

283 config = DipoleMeasurementTask.ConfigClass() 

284 config.plugins.names.remove('base_SkyCoord') 

285 algMetadata = dafBase.PropertyList() 

286 measureTask = DipoleMeasurementTask(schema, algMetadata, config=config) 

287 

288 Having fully initialied the schema, we create a Source table from it: 

289 

290 .. code-block:: py 

291 

292 # Create the output table 

293 tab = afwTable.SourceTable.make(schema) 

294 

295 Run detection: 

296 

297 .. code-block:: py 

298 

299 # Process the data 

300 results = detectionTask.run(tab, exposure) 

301 

302 Because we are looking for dipoles, we need to merge the positive and negative detections: 

303 

304 .. code-block:: py 

305 

306 # Merge the positve and negative sources 

307 fpSet = results.fpSets.positive 

308 growFootprint = 2 

309 fpSet.merge(results.fpSets.negative, growFootprint, growFootprint, False) 

310 diaSources = afwTable.SourceCatalog(tab) 

311 fpSet.makeSources(diaSources) 

312 print("Merged %s Sources into %d diaSources (from %d +ve, %d -ve)" % (len(results.sources), 

313 len(diaSources), 

314 results.fpSets.numPos, 

315 results.fpSets.numNeg)) 

316 

317 Finally, perform measurement (both standard and dipole-specialized) on the merged sources: 

318 

319 .. code-block:: py 

320 

321 measureTask.run(diaSources, exposure) 

322 

323 Optionally display debugging information: 

324 

325 .. code-block:: py 

326 

327 # Display dipoles if debug enabled 

328 if args.debug: 

329 dpa = DipoleAnalysis() 

330 dpa.displayDipoles(exposure, diaSources) 

331 

332 """ 

333 ConfigClass = DipoleMeasurementConfig 

334 _DefaultName = "dipoleMeasurement" 

335 

336 

337######### 

338# Other Support classs 

339######### 

340 

341class SourceFlagChecker(object): 

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

343 

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

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

346 'base_PixelFlags_flag_saturatedCenter'] 

347 if badFlags is not None: 

348 for flag in badFlags: 

349 self.badFlags.append(flag) 

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

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

352 

353 def __call__(self, source): 

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

355 

356 Parameters 

357 ---------- 

358 source : 

359 Source that will be examined 

360 """ 

361 for k in self.keys: 

362 if source.get(k): 

363 return False 

364 return True 

365 

366 

367class DipoleAnalysis(object): 

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

369 

370 def __init__(self): 

371 pass 

372 

373 def __call__(self, source): 

374 """Parse information returned from dipole measurement 

375 

376 Parameters 

377 ---------- 

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

379 The source that will be examined""" 

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

381 

382 def getSn(self, source): 

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

384 

385 Parameters 

386 ---------- 

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

388 The source that will be examined""" 

389 

390 posflux = source.get("ip_diffim_PsfDipoleFlux_pos_instFlux") 

391 posfluxErr = source.get("ip_diffim_PsfDipoleFlux_pos_instFluxErr") 

392 negflux = source.get("ip_diffim_PsfDipoleFlux_neg_instFlux") 

393 negfluxErr = source.get("ip_diffim_PsfDipoleFlux_neg_instFluxErr") 

394 

395 # Not a dipole! 

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

397 return 0 

398 

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

400 

401 def getCentroid(self, source): 

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

403 

404 Parameters 

405 ---------- 

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

407 The source that will be examined""" 

408 

409 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x") 

410 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y") 

411 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x") 

412 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y") 

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

414 return None 

415 

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

417 0.5*(negCenY+posCenY)) 

418 return center 

419 

420 def getOrientation(self, source): 

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

422 

423 Parameters 

424 ---------- 

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

426 The source that will be examined""" 

427 

428 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x") 

429 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y") 

430 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x") 

431 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y") 

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

433 return None 

434 

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

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

437 return angle 

438 

439 def displayDipoles(self, exposure, sources): 

440 """Display debugging information on the detected dipoles 

441 

442 Parameters 

443 ---------- 

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

445 Image the dipoles were measured on 

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

447 The set of diaSources that were measured""" 

448 

449 import lsstDebug 

450 display = lsstDebug.Info(__name__).display 

451 displayDiaSources = lsstDebug.Info(__name__).displayDiaSources 

452 maskTransparency = lsstDebug.Info(__name__).maskTransparency 

453 if not maskTransparency: 

454 maskTransparency = 90 

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

456 disp.setMaskTransparency(maskTransparency) 

457 disp.mtv(exposure) 

458 

459 if display and displayDiaSources: 

460 with disp.Buffering(): 

461 for source in sources: 

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

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

464 cenX, cenY = source.getCentroid() 

465 

466 isdipole = source.get("ip_diffim_ClassificationDipole_value") 

467 if isdipole and np.isfinite(isdipole): 

468 # Dipole 

469 ctype = afwDisplay.GREEN 

470 else: 

471 # Not dipole 

472 ctype = afwDisplay.RED 

473 

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

475 

476 negCenX = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_x") 

477 negCenY = source.get("ip_diffim_PsfDipoleFlux_neg_centroid_y") 

478 posCenX = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_x") 

479 posCenY = source.get("ip_diffim_PsfDipoleFlux_pos_centroid_y") 

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

481 continue 

482 

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

484 

485 lsstDebug.frame += 1 

486 

487 

488class DipoleDeblender(object): 

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

490 

491 This necessarily overrides some of the functionality from 

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

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

494 multiple children sources. This directly calls the core 

495 deblending code deblendBaseline.deblend (optionally _fitPsf for 

496 debugging). 

497 

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

499 dipoleAlgorithm.py. 

500 """ 

501 

502 def __init__(self): 

503 # Set up defaults to send to deblender 

504 

505 # Always deblend as Psf 

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

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

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

509 

510 def __call__(self, source, exposure): 

511 fp = source.getFootprint() 

512 peaks = fp.getPeaks() 

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

514 fbb = fp.getBBox() 

515 fmask = afwImage.Mask(fbb) 

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

517 fp.spans.setMask(fmask, 1) 

518 

519 psf = exposure.getPsf() 

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

521 psfFwhmPix = psfSigPix * self.sigma2fwhm 

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

523 cpsf = deblendBaseline.CachingPsf(psf) 

524 

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

526 if len(peaks) < 2: 

527 return source.getTable().copyRecord(source) 

528 

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

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

531 speaks.sort() 

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

533 

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

535 peaks.clear() 

536 for peak in dpeaks: 

537 peaks.append(peak) 

538 

539 if True: 

540 # Call top-level deblend task 

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

542 log=self.log, 

543 psfChisqCut1=self.psfChisqCut1, 

544 psfChisqCut2=self.psfChisqCut2, 

545 psfChisqCut2b=self.psfChisqCut2b) 

546 else: 

547 # Call lower-level _fit_psf task 

548 

549 # Prepare results structure 

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

551 

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

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

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

555 cpsf, psfFwhmPix, 

556 subimage.getMaskedImage().getImage(), 

557 subimage.getMaskedImage().getVariance(), 

558 self.psfChisqCut1, self.psfChisqCut2, self.psfChisqCut2b) 

559 

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

561 deblendedSource.setParent(source.getId()) 

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

563 peakList.clear() 

564 

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

566 if peak.psfFitFlux > 0: 

567 suffix = "pos" 

568 else: 

569 suffix = "neg" 

570 c = peak.psfFitCenter 

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

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

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

574 suffix, peak.psfFitChisq / peak.psfFitDof) 

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

576 suffix, peak.psfFitFlux * np.sum(peak.templateImage.getArray())) 

577 peakList.append(peak.peak) 

578 return deblendedSource