Coverage for python/lsst/ip/diffim/utils.py: 5%

809 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-22 12:11 +0000

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 

22"""Support utilities for Measuring sources""" 

23 

24# Export DipoleTestImage to expose fake image generating funcs 

25__all__ = ["DipoleTestImage", "evaluateMeanPsfFwhm", "getPsfFwhm"] 

26 

27import itertools 

28import numpy as np 

29import lsst.geom as geom 

30import lsst.afw.detection as afwDet 

31import lsst.afw.display as afwDisplay 

32import lsst.afw.geom as afwGeom 

33import lsst.afw.image as afwImage 

34import lsst.afw.math as afwMath 

35import lsst.afw.table as afwTable 

36import lsst.meas.algorithms as measAlg 

37import lsst.meas.base as measBase 

38from lsst.meas.algorithms.testUtils import plantSources 

39from lsst.pex.exceptions import InvalidParameterError 

40from lsst.utils.logging import getLogger 

41from .dipoleFitTask import DipoleFitAlgorithm 

42from . import diffimLib 

43from . import diffimTools 

44 

45afwDisplay.setDefaultMaskTransparency(75) 

46keptPlots = False # Have we arranged to keep spatial plots open? 

47 

48_LOG = getLogger(__name__) 

49 

50 

51def showSourceSet(sSet, xy0=(0, 0), frame=0, ctype=afwDisplay.GREEN, symb="+", size=2): 

52 """Draw the (XAstrom, YAstrom) positions of a set of Sources. 

53 

54 Image has the given XY0. 

55 """ 

56 disp = afwDisplay.afwDisplay(frame=frame) 

57 with disp.Buffering(): 

58 for s in sSet: 

59 xc, yc = s.getXAstrom() - xy0[0], s.getYAstrom() - xy0[1] 

60 

61 if symb == "id": 

62 disp.dot(str(s.getId()), xc, yc, ctype=ctype, size=size) 

63 else: 

64 disp.dot(symb, xc, yc, ctype=ctype, size=size) 

65 

66 

67# Kernel display utilities 

68# 

69 

70 

71def showKernelSpatialCells(maskedIm, kernelCellSet, showChi2=False, symb="o", 

72 ctype=None, ctypeUnused=None, ctypeBad=None, size=3, 

73 frame=None, title="Spatial Cells"): 

74 """Show the SpatialCells. 

75 

76 If symb is something that display.dot understands (e.g. "o"), the top 

77 nMaxPerCell candidates will be indicated with that symbol, using ctype 

78 and size. 

79 """ 

80 disp = afwDisplay.Display(frame=frame) 

81 disp.mtv(maskedIm, title=title) 

82 with disp.Buffering(): 

83 origin = [-maskedIm.getX0(), -maskedIm.getY0()] 

84 for cell in kernelCellSet.getCellList(): 

85 afwDisplay.utils.drawBBox(cell.getBBox(), origin=origin, display=disp) 

86 

87 goodies = ctypeBad is None 

88 for cand in cell.begin(goodies): 

89 xc, yc = cand.getXCenter() + origin[0], cand.getYCenter() + origin[1] 

90 if cand.getStatus() == afwMath.SpatialCellCandidate.BAD: 

91 color = ctypeBad 

92 elif cand.getStatus() == afwMath.SpatialCellCandidate.GOOD: 

93 color = ctype 

94 elif cand.getStatus() == afwMath.SpatialCellCandidate.UNKNOWN: 

95 color = ctypeUnused 

96 else: 

97 continue 

98 

99 if color: 

100 disp.dot(symb, xc, yc, ctype=color, size=size) 

101 

102 if showChi2: 

103 rchi2 = cand.getChi2() 

104 if rchi2 > 1e100: 

105 rchi2 = np.nan 

106 disp.dot("%d %.1f" % (cand.getId(), rchi2), 

107 xc - size, yc - size - 4, ctype=color, size=size) 

108 

109 

110def showDiaSources(sources, exposure, isFlagged, isDipole, frame=None): 

111 """Display Dia Sources. 

112 """ 

113 # 

114 # Show us the ccandidates 

115 # 

116 # Too many mask planes in diffims 

117 disp = afwDisplay.Display(frame=frame) 

118 for plane in ("BAD", "CR", "EDGE", "INTERPOlATED", "INTRP", "SAT", "SATURATED"): 

119 disp.setMaskPlaneColor(plane, color="ignore") 

120 

121 mos = afwDisplay.utils.Mosaic() 

122 for i in range(len(sources)): 

123 source = sources[i] 

124 badFlag = isFlagged[i] 

125 dipoleFlag = isDipole[i] 

126 bbox = source.getFootprint().getBBox() 

127 stamp = exposure.Factory(exposure, bbox, True) 

128 im = afwDisplay.utils.Mosaic(gutter=1, background=0, mode="x") 

129 im.append(stamp.getMaskedImage()) 

130 lab = "%.1f,%.1f:" % (source.getX(), source.getY()) 

131 if badFlag: 

132 ctype = afwDisplay.RED 

133 lab += "BAD" 

134 if dipoleFlag: 

135 ctype = afwDisplay.YELLOW 

136 lab += "DIPOLE" 

137 if not badFlag and not dipoleFlag: 

138 ctype = afwDisplay.GREEN 

139 lab += "OK" 

140 mos.append(im.makeMosaic(), lab, ctype) 

141 title = "Dia Sources" 

142 mosaicImage = mos.makeMosaic(display=disp, title=title) 

143 return mosaicImage 

144 

145 

146def showKernelCandidates(kernelCellSet, kernel, background, frame=None, showBadCandidates=True, 

147 resids=False, kernels=False): 

148 """Display the Kernel candidates. 

149 

150 If kernel is provided include spatial model and residuals; 

151 If chi is True, generate a plot of residuals/sqrt(variance), i.e. chi. 

152 """ 

153 # 

154 # Show us the ccandidates 

155 # 

156 if kernels: 

157 mos = afwDisplay.utils.Mosaic(gutter=5, background=0) 

158 else: 

159 mos = afwDisplay.utils.Mosaic(gutter=5, background=-1) 

160 # 

161 candidateCenters = [] 

162 candidateCentersBad = [] 

163 candidateIndex = 0 

164 for cell in kernelCellSet.getCellList(): 

165 for cand in cell.begin(False): # include bad candidates 

166 # Original difference image; if does not exist, skip candidate 

167 try: 

168 resid = cand.getDifferenceImage(diffimLib.KernelCandidateF.ORIG) 

169 except Exception: 

170 continue 

171 

172 rchi2 = cand.getChi2() 

173 if rchi2 > 1e100: 

174 rchi2 = np.nan 

175 

176 if not showBadCandidates and cand.isBad(): 

177 continue 

178 

179 im_resid = afwDisplay.utils.Mosaic(gutter=1, background=-0.5, mode="x") 

180 

181 try: 

182 im = cand.getScienceMaskedImage() 

183 im = im.Factory(im, True) 

184 im.setXY0(cand.getScienceMaskedImage().getXY0()) 

185 except Exception: 

186 continue 

187 if (not resids and not kernels): 

188 im_resid.append(im.Factory(im, True)) 

189 try: 

190 im = cand.getTemplateMaskedImage() 

191 im = im.Factory(im, True) 

192 im.setXY0(cand.getTemplateMaskedImage().getXY0()) 

193 except Exception: 

194 continue 

195 if (not resids and not kernels): 

196 im_resid.append(im.Factory(im, True)) 

197 

198 # Difference image with original basis 

199 if resids: 

200 var = resid.variance 

201 var = var.Factory(var, True) 

202 np.sqrt(var.array, var.array) # inplace sqrt 

203 resid = resid.image 

204 resid /= var 

205 bbox = kernel.shrinkBBox(resid.getBBox()) 

206 resid = resid.Factory(resid, bbox, deep=True) 

207 elif kernels: 

208 kim = cand.getKernelImage(diffimLib.KernelCandidateF.ORIG).convertF() 

209 resid = kim.Factory(kim, True) 

210 im_resid.append(resid) 

211 

212 # residuals using spatial model 

213 ski = afwImage.ImageD(kernel.getDimensions()) 

214 kernel.computeImage(ski, False, int(cand.getXCenter()), int(cand.getYCenter())) 

215 sk = afwMath.FixedKernel(ski) 

216 sbg = 0.0 

217 if background: 

218 sbg = background(int(cand.getXCenter()), int(cand.getYCenter())) 

219 sresid = cand.getDifferenceImage(sk, sbg) 

220 resid = sresid 

221 if resids: 

222 resid = sresid.image 

223 resid /= var 

224 bbox = kernel.shrinkBBox(resid.getBBox()) 

225 resid = resid.Factory(resid, bbox, deep=True) 

226 elif kernels: 

227 kim = ski.convertF() 

228 resid = kim.Factory(kim, True) 

229 im_resid.append(resid) 

230 

231 im = im_resid.makeMosaic() 

232 

233 lab = "%d chi^2 %.1f" % (cand.getId(), rchi2) 

234 ctype = afwDisplay.RED if cand.isBad() else afwDisplay.GREEN 

235 

236 mos.append(im, lab, ctype) 

237 

238 if False and np.isnan(rchi2): 

239 disp = afwDisplay.Display(frame=1) 

240 disp.mtv(cand.getScienceMaskedImage.image, title="candidate") 

241 print("rating", cand.getCandidateRating()) 

242 

243 im = cand.getScienceMaskedImage() 

244 center = (candidateIndex, cand.getXCenter() - im.getX0(), cand.getYCenter() - im.getY0()) 

245 candidateIndex += 1 

246 if cand.isBad(): 

247 candidateCentersBad.append(center) 

248 else: 

249 candidateCenters.append(center) 

250 

251 if resids: 

252 title = "chi Diffim" 

253 elif kernels: 

254 title = "Kernels" 

255 else: 

256 title = "Candidates & residuals" 

257 

258 disp = afwDisplay.Display(frame=frame) 

259 mosaicImage = mos.makeMosaic(display=disp, title=title) 

260 

261 return mosaicImage 

262 

263 

264def showKernelBasis(kernel, frame=None): 

265 """Display a Kernel's basis images. 

266 """ 

267 mos = afwDisplay.utils.Mosaic() 

268 

269 for k in kernel.getKernelList(): 

270 im = afwImage.ImageD(k.getDimensions()) 

271 k.computeImage(im, False) 

272 mos.append(im) 

273 

274 disp = afwDisplay.Display(frame=frame) 

275 mos.makeMosaic(display=disp, title="Kernel Basis Images") 

276 

277 return mos 

278 

279############### 

280 

281 

282def plotKernelSpatialModel(kernel, kernelCellSet, showBadCandidates=True, 

283 numSample=128, keepPlots=True, maxCoeff=10): 

284 """Plot the Kernel spatial model. 

285 """ 

286 try: 

287 import matplotlib.pyplot as plt 

288 import matplotlib.colors 

289 except ImportError as e: 

290 print("Unable to import numpy and matplotlib: %s" % e) 

291 return 

292 

293 x0 = kernelCellSet.getBBox().getBeginX() 

294 y0 = kernelCellSet.getBBox().getBeginY() 

295 

296 candPos = list() 

297 candFits = list() 

298 badPos = list() 

299 badFits = list() 

300 candAmps = list() 

301 badAmps = list() 

302 for cell in kernelCellSet.getCellList(): 

303 for cand in cell.begin(False): 

304 if not showBadCandidates and cand.isBad(): 

305 continue 

306 candCenter = geom.PointD(cand.getXCenter(), cand.getYCenter()) 

307 try: 

308 im = cand.getTemplateMaskedImage() 

309 except Exception: 

310 continue 

311 

312 targetFits = badFits if cand.isBad() else candFits 

313 targetPos = badPos if cand.isBad() else candPos 

314 targetAmps = badAmps if cand.isBad() else candAmps 

315 

316 # compare original and spatial kernel coefficients 

317 kp0 = np.array(cand.getKernel(diffimLib.KernelCandidateF.ORIG).getKernelParameters()) 

318 amp = cand.getCandidateRating() 

319 

320 targetFits = badFits if cand.isBad() else candFits 

321 targetPos = badPos if cand.isBad() else candPos 

322 targetAmps = badAmps if cand.isBad() else candAmps 

323 

324 targetFits.append(kp0) 

325 targetPos.append(candCenter) 

326 targetAmps.append(amp) 

327 

328 xGood = np.array([pos.getX() for pos in candPos]) - x0 

329 yGood = np.array([pos.getY() for pos in candPos]) - y0 

330 zGood = np.array(candFits) 

331 

332 xBad = np.array([pos.getX() for pos in badPos]) - x0 

333 yBad = np.array([pos.getY() for pos in badPos]) - y0 

334 zBad = np.array(badFits) 

335 numBad = len(badPos) 

336 

337 xRange = np.linspace(0, kernelCellSet.getBBox().getWidth(), num=numSample) 

338 yRange = np.linspace(0, kernelCellSet.getBBox().getHeight(), num=numSample) 

339 

340 if maxCoeff: 

341 maxCoeff = min(maxCoeff, kernel.getNKernelParameters()) 

342 else: 

343 maxCoeff = kernel.getNKernelParameters() 

344 

345 for k in range(maxCoeff): 

346 func = kernel.getSpatialFunction(k) 

347 dfGood = zGood[:, k] - np.array([func(pos.getX(), pos.getY()) for pos in candPos]) 

348 yMin = dfGood.min() 

349 yMax = dfGood.max() 

350 if numBad > 0: 

351 dfBad = zBad[:, k] - np.array([func(pos.getX(), pos.getY()) for pos in badPos]) 

352 # Can really screw up the range... 

353 yMin = min([yMin, dfBad.min()]) 

354 yMax = max([yMax, dfBad.max()]) 

355 yMin -= 0.05*(yMax - yMin) 

356 yMax += 0.05*(yMax - yMin) 

357 

358 fRange = np.ndarray((len(xRange), len(yRange))) 

359 for j, yVal in enumerate(yRange): 

360 for i, xVal in enumerate(xRange): 

361 fRange[j][i] = func(xVal, yVal) 

362 

363 fig = plt.figure(k) 

364 

365 fig.clf() 

366 try: 

367 fig.canvas._tkcanvas._root().lift() # == Tk's raise, but raise is a python reserved word 

368 except Exception: # protect against API changes 

369 pass 

370 

371 fig.suptitle('Kernel component %d' % k) 

372 

373 # LL 

374 ax = fig.add_axes((0.1, 0.05, 0.35, 0.35)) 

375 vmin = fRange.min() # - 0.05*np.fabs(fRange.min()) 

376 vmax = fRange.max() # + 0.05*np.fabs(fRange.max()) 

377 norm = matplotlib.colors.Normalize(vmin=vmin, vmax=vmax) 

378 im = ax.imshow(fRange, aspect='auto', norm=norm, 

379 extent=[0, kernelCellSet.getBBox().getWidth() - 1, 

380 0, kernelCellSet.getBBox().getHeight() - 1]) 

381 ax.set_title('Spatial polynomial') 

382 plt.colorbar(im, orientation='horizontal', ticks=[vmin, vmax]) 

383 

384 # UL 

385 ax = fig.add_axes((0.1, 0.55, 0.35, 0.35)) 

386 ax.plot(-2.5*np.log10(candAmps), zGood[:, k], 'b+') 

387 if numBad > 0: 

388 ax.plot(-2.5*np.log10(badAmps), zBad[:, k], 'r+') 

389 ax.set_title("Basis Coefficients") 

390 ax.set_xlabel("Instr mag") 

391 ax.set_ylabel("Coeff") 

392 

393 # LR 

394 ax = fig.add_axes((0.55, 0.05, 0.35, 0.35)) 

395 ax.set_autoscale_on(False) 

396 ax.set_xbound(lower=0, upper=kernelCellSet.getBBox().getHeight()) 

397 ax.set_ybound(lower=yMin, upper=yMax) 

398 ax.plot(yGood, dfGood, 'b+') 

399 if numBad > 0: 

400 ax.plot(yBad, dfBad, 'r+') 

401 ax.axhline(0.0) 

402 ax.set_title('dCoeff (indiv-spatial) vs. y') 

403 

404 # UR 

405 ax = fig.add_axes((0.55, 0.55, 0.35, 0.35)) 

406 ax.set_autoscale_on(False) 

407 ax.set_xbound(lower=0, upper=kernelCellSet.getBBox().getWidth()) 

408 ax.set_ybound(lower=yMin, upper=yMax) 

409 ax.plot(xGood, dfGood, 'b+') 

410 if numBad > 0: 

411 ax.plot(xBad, dfBad, 'r+') 

412 ax.axhline(0.0) 

413 ax.set_title('dCoeff (indiv-spatial) vs. x') 

414 

415 fig.show() 

416 

417 global keptPlots 

418 if keepPlots and not keptPlots: 

419 # Keep plots open when done 

420 def show(): 

421 print("%s: Please close plots when done." % __name__) 

422 try: 

423 plt.show() 

424 except Exception: 

425 pass 

426 print("Plots closed, exiting...") 

427 import atexit 

428 atexit.register(show) 

429 keptPlots = True 

430 

431 

432def plotKernelCoefficients(spatialKernel, kernelCellSet, showBadCandidates=False, keepPlots=True): 

433 """Plot the individual kernel candidate and the spatial kernel solution coefficients. 

434 

435 Parameters 

436 ---------- 

437 

438 spatialKernel : `lsst.afw.math.LinearCombinationKernel` 

439 The spatial spatialKernel solution model which is a spatially varying linear combination 

440 of the spatialKernel basis functions. 

441 Typically returned by `lsst.ip.diffim.SpatialKernelSolution.getSolutionPair()`. 

442 

443 kernelCellSet : `lsst.afw.math.SpatialCellSet` 

444 The spatial cells that was used for solution for the spatialKernel. They contain the 

445 local solutions of the AL kernel for the selected sources. 

446 

447 showBadCandidates : `bool`, optional 

448 If True, plot the coefficient values for kernel candidates where the solution was marked 

449 bad by the numerical algorithm. Defaults to False. 

450 

451 keepPlots: `bool`, optional 

452 If True, sets ``plt.show()`` to be called before the task terminates, so that the plots 

453 can be explored interactively. Defaults to True. 

454 

455 Notes 

456 ----- 

457 This function produces 3 figures per image subtraction operation. 

458 * A grid plot of the local solutions. Each grid cell corresponds to a proportional area in 

459 the image. In each cell, local kernel solution coefficients are plotted of kernel candidates (color) 

460 that fall into this area as a function of the kernel basis function number. 

461 * A grid plot of the spatial solution. Each grid cell corresponds to a proportional area in 

462 the image. In each cell, the spatial solution coefficients are evaluated for the center of the cell. 

463 * Histogram of the local solution coefficients. Red line marks the spatial solution value at 

464 center of the image. 

465 

466 This function is called if ``lsst.ip.diffim.psfMatch.plotKernelCoefficients==True`` in lsstDebug. This 

467 function was implemented as part of DM-17825. 

468 """ 

469 try: 

470 import matplotlib.pyplot as plt 

471 except ImportError as e: 

472 print("Unable to import matplotlib: %s" % e) 

473 return 

474 

475 # Image dimensions 

476 imgBBox = kernelCellSet.getBBox() 

477 x0 = imgBBox.getBeginX() 

478 y0 = imgBBox.getBeginY() 

479 wImage = imgBBox.getWidth() 

480 hImage = imgBBox.getHeight() 

481 imgCenterX = imgBBox.getCenterX() 

482 imgCenterY = imgBBox.getCenterY() 

483 

484 # Plot the local solutions 

485 # ---- 

486 

487 # Grid size 

488 nX = 8 

489 nY = 8 

490 wCell = wImage / nX 

491 hCell = hImage / nY 

492 

493 fig = plt.figure() 

494 fig.suptitle("Kernel candidate parameters on an image grid") 

495 arrAx = fig.subplots(nrows=nY, ncols=nX, sharex=True, sharey=True, gridspec_kw=dict( 

496 wspace=0, hspace=0)) 

497 

498 # Bottom left panel is for bottom left part of the image 

499 arrAx = arrAx[::-1, :] 

500 

501 allParams = [] 

502 for cell in kernelCellSet.getCellList(): 

503 cellBBox = geom.Box2D(cell.getBBox()) 

504 # Determine which panel this spatial cell belongs to 

505 iX = int((cellBBox.getCenterX() - x0)//wCell) 

506 iY = int((cellBBox.getCenterY() - y0)//hCell) 

507 

508 for cand in cell.begin(False): 

509 try: 

510 kernel = cand.getKernel(cand.ORIG) 

511 except Exception: 

512 continue 

513 

514 if not showBadCandidates and cand.isBad(): 

515 continue 

516 

517 nKernelParams = kernel.getNKernelParameters() 

518 kernelParams = np.array(kernel.getKernelParameters()) 

519 allParams.append(kernelParams) 

520 

521 if cand.isBad(): 

522 color = 'red' 

523 else: 

524 color = None 

525 arrAx[iY, iX].plot(np.arange(nKernelParams), kernelParams, '.-', 

526 color=color, drawstyle='steps-mid', linewidth=0.1) 

527 for ax in arrAx.ravel(): 

528 ax.grid(True, axis='y') 

529 

530 # Plot histogram of the local parameters and the global solution at the image center 

531 # ---- 

532 

533 spatialFuncs = spatialKernel.getSpatialFunctionList() 

534 nKernelParams = spatialKernel.getNKernelParameters() 

535 nX = 8 

536 fig = plt.figure() 

537 fig.suptitle("Hist. of parameters marked with spatial solution at img center") 

538 arrAx = fig.subplots(nrows=int(nKernelParams//nX)+1, ncols=nX) 

539 arrAx = arrAx[::-1, :] 

540 allParams = np.array(allParams) 

541 for k in range(nKernelParams): 

542 ax = arrAx.ravel()[k] 

543 ax.hist(allParams[:, k], bins=20, edgecolor='black') 

544 ax.set_xlabel('P{}'.format(k)) 

545 valueParam = spatialFuncs[k](imgCenterX, imgCenterY) 

546 ax.axvline(x=valueParam, color='red') 

547 ax.text(0.1, 0.9, '{:.1f}'.format(valueParam), 

548 transform=ax.transAxes, backgroundcolor='lightsteelblue') 

549 

550 # Plot grid of the spatial solution 

551 # ---- 

552 

553 nX = 8 

554 nY = 8 

555 wCell = wImage / nX 

556 hCell = hImage / nY 

557 x0 += wCell / 2 

558 y0 += hCell / 2 

559 

560 fig = plt.figure() 

561 fig.suptitle("Spatial solution of kernel parameters on an image grid") 

562 arrAx = fig.subplots(nrows=nY, ncols=nX, sharex=True, sharey=True, gridspec_kw=dict( 

563 wspace=0, hspace=0)) 

564 arrAx = arrAx[::-1, :] 

565 kernelParams = np.zeros(nKernelParams, dtype=float) 

566 

567 for iX in range(nX): 

568 for iY in range(nY): 

569 x = x0 + iX * wCell 

570 y = y0 + iY * hCell 

571 # Evaluate the spatial solution functions for this x,y location 

572 kernelParams = [f(x, y) for f in spatialFuncs] 

573 arrAx[iY, iX].plot(np.arange(nKernelParams), kernelParams, '.-', drawstyle='steps-mid') 

574 arrAx[iY, iX].grid(True, axis='y') 

575 

576 global keptPlots 

577 if keepPlots and not keptPlots: 

578 # Keep plots open when done 

579 def show(): 

580 print("%s: Please close plots when done." % __name__) 

581 try: 

582 plt.show() 

583 except Exception: 

584 pass 

585 print("Plots closed, exiting...") 

586 import atexit 

587 atexit.register(show) 

588 keptPlots = True 

589 

590 

591def showKernelMosaic(bbox, kernel, nx=7, ny=None, frame=None, title=None, 

592 showCenter=True, showEllipticity=True): 

593 """Show a mosaic of Kernel images. 

594 """ 

595 mos = afwDisplay.utils.Mosaic() 

596 

597 x0 = bbox.getBeginX() 

598 y0 = bbox.getBeginY() 

599 width = bbox.getWidth() 

600 height = bbox.getHeight() 

601 

602 if not ny: 

603 ny = int(nx*float(height)/width + 0.5) 

604 if not ny: 

605 ny = 1 

606 

607 schema = afwTable.SourceTable.makeMinimalSchema() 

608 centroidName = "base_SdssCentroid" 

609 shapeName = "base_SdssShape" 

610 control = measBase.SdssCentroidControl() 

611 schema.getAliasMap().set("slot_Centroid", centroidName) 

612 schema.getAliasMap().set("slot_Centroid_flag", centroidName + "_flag") 

613 centroider = measBase.SdssCentroidAlgorithm(control, centroidName, schema) 

614 sdssShape = measBase.SdssShapeControl() 

615 shaper = measBase.SdssShapeAlgorithm(sdssShape, shapeName, schema) 

616 table = afwTable.SourceTable.make(schema) 

617 table.defineCentroid(centroidName) 

618 table.defineShape(shapeName) 

619 

620 centers = [] 

621 shapes = [] 

622 for iy in range(ny): 

623 for ix in range(nx): 

624 x = int(ix*(width - 1)/(nx - 1)) + x0 

625 y = int(iy*(height - 1)/(ny - 1)) + y0 

626 

627 im = afwImage.ImageD(kernel.getDimensions()) 

628 ksum = kernel.computeImage(im, False, x, y) 

629 lab = "Kernel(%d,%d)=%.2f" % (x, y, ksum) if False else "" 

630 mos.append(im, lab) 

631 

632 # SdssCentroidAlgorithm.measure requires an exposure of floats 

633 exp = afwImage.makeExposure(afwImage.makeMaskedImage(im.convertF())) 

634 

635 w, h = im.getWidth(), im.getHeight() 

636 centerX = im.getX0() + w//2 

637 centerY = im.getY0() + h//2 

638 src = table.makeRecord() 

639 spans = afwGeom.SpanSet(exp.getBBox()) 

640 foot = afwDet.Footprint(spans) 

641 foot.addPeak(centerX, centerY, 1) 

642 src.setFootprint(foot) 

643 

644 try: # The centroider requires a psf, so this will fail if none is attached to exp 

645 centroider.measure(src, exp) 

646 centers.append((src.getX(), src.getY())) 

647 

648 shaper.measure(src, exp) 

649 shapes.append((src.getIxx(), src.getIxy(), src.getIyy())) 

650 except Exception: 

651 pass 

652 

653 disp = afwDisplay.Display(frame=frame) 

654 mos.makeMosaic(display=disp, title=title if title else "Model Kernel", mode=nx) 

655 

656 if centers and frame is not None: 

657 disp = afwDisplay.Display(frame=frame) 

658 i = 0 

659 with disp.Buffering(): 

660 for cen, shape in zip(centers, shapes): 

661 bbox = mos.getBBox(i) 

662 i += 1 

663 xc, yc = cen[0] + bbox.getMinX(), cen[1] + bbox.getMinY() 

664 if showCenter: 

665 disp.dot("+", xc, yc, ctype=afwDisplay.BLUE) 

666 

667 if showEllipticity: 

668 ixx, ixy, iyy = shape 

669 disp.dot("@:%g,%g,%g" % (ixx, ixy, iyy), xc, yc, ctype=afwDisplay.RED) 

670 

671 return mos 

672 

673 

674def plotPixelResiduals(exposure, warpedTemplateExposure, diffExposure, kernelCellSet, 

675 kernel, background, testSources, config, 

676 origVariance=False, nptsFull=1e6, keepPlots=True, titleFs=14): 

677 """Plot diffim residuals for LOCAL and SPATIAL models. 

678 """ 

679 candidateResids = [] 

680 spatialResids = [] 

681 nonfitResids = [] 

682 

683 for cell in kernelCellSet.getCellList(): 

684 for cand in cell.begin(True): # only look at good ones 

685 # Be sure 

686 if not (cand.getStatus() == afwMath.SpatialCellCandidate.GOOD): 

687 continue 

688 

689 diffim = cand.getDifferenceImage(diffimLib.KernelCandidateF.ORIG) 

690 orig = cand.getScienceMaskedImage() 

691 

692 ski = afwImage.ImageD(kernel.getDimensions()) 

693 kernel.computeImage(ski, False, int(cand.getXCenter()), int(cand.getYCenter())) 

694 sk = afwMath.FixedKernel(ski) 

695 sbg = background(int(cand.getXCenter()), int(cand.getYCenter())) 

696 sdiffim = cand.getDifferenceImage(sk, sbg) 

697 

698 # trim edgs due to convolution 

699 bbox = kernel.shrinkBBox(diffim.getBBox()) 

700 tdiffim = diffim.Factory(diffim, bbox) 

701 torig = orig.Factory(orig, bbox) 

702 tsdiffim = sdiffim.Factory(sdiffim, bbox) 

703 

704 if origVariance: 

705 candidateResids.append(np.ravel(tdiffim.image.array 

706 / np.sqrt(torig.variance.array))) 

707 spatialResids.append(np.ravel(tsdiffim.image.array 

708 / np.sqrt(torig.variance.array))) 

709 else: 

710 candidateResids.append(np.ravel(tdiffim.image.array 

711 / np.sqrt(tdiffim.variance.array))) 

712 spatialResids.append(np.ravel(tsdiffim.image.array 

713 / np.sqrt(tsdiffim.variance.array))) 

714 

715 fullIm = diffExposure.image.array 

716 fullMask = diffExposure.mask.array 

717 if origVariance: 

718 fullVar = exposure.variance.array 

719 else: 

720 fullVar = diffExposure.variance.array 

721 

722 bitmaskBad = 0 

723 bitmaskBad |= afwImage.Mask.getPlaneBitMask('NO_DATA') 

724 bitmaskBad |= afwImage.Mask.getPlaneBitMask('SAT') 

725 idx = np.where((fullMask & bitmaskBad) == 0) 

726 stride = int(len(idx[0])//nptsFull) 

727 sidx = idx[0][::stride], idx[1][::stride] 

728 allResids = fullIm[sidx]/np.sqrt(fullVar[sidx]) 

729 

730 testFootprints = diffimTools.sourceToFootprintList(testSources, warpedTemplateExposure, 

731 exposure, config, 

732 _LOG.getChild("plotPixelResiduals")) 

733 for fp in testFootprints: 

734 subexp = diffExposure.Factory(diffExposure, fp["footprint"].getBBox()) 

735 subim = subexp.image 

736 if origVariance: 

737 subvar = afwImage.ExposureF(exposure, fp["footprint"].getBBox()).variance 

738 else: 

739 subvar = subexp.variance 

740 nonfitResids.append(np.ravel(subim.array/np.sqrt(subvar.array))) 

741 

742 candidateResids = np.ravel(np.array(candidateResids)) 

743 spatialResids = np.ravel(np.array(spatialResids)) 

744 nonfitResids = np.ravel(np.array(nonfitResids)) 

745 

746 try: 

747 import pylab 

748 from matplotlib.font_manager import FontProperties 

749 except ImportError as e: 

750 print("Unable to import pylab: %s" % e) 

751 return 

752 

753 fig = pylab.figure() 

754 fig.clf() 

755 try: 

756 fig.canvas._tkcanvas._root().lift() # == Tk's raise, but raise is a python reserved word 

757 except Exception: # protect against API changes 

758 pass 

759 if origVariance: 

760 fig.suptitle("Diffim residuals: Normalized by sqrt(input variance)", fontsize=titleFs) 

761 else: 

762 fig.suptitle("Diffim residuals: Normalized by sqrt(diffim variance)", fontsize=titleFs) 

763 

764 sp1 = pylab.subplot(221) 

765 sp2 = pylab.subplot(222, sharex=sp1, sharey=sp1) 

766 sp3 = pylab.subplot(223, sharex=sp1, sharey=sp1) 

767 sp4 = pylab.subplot(224, sharex=sp1, sharey=sp1) 

768 xs = np.arange(-5, 5.05, 0.1) 

769 ys = 1./np.sqrt(2*np.pi)*np.exp(-0.5*xs**2) 

770 

771 sp1.hist(candidateResids, bins=xs, normed=True, alpha=0.5, label="N(%.2f, %.2f)" 

772 % (np.mean(candidateResids), np.var(candidateResids))) 

773 sp1.plot(xs, ys, "r-", lw=2, label="N(0,1)") 

774 sp1.set_title("Candidates: basis fit", fontsize=titleFs - 2) 

775 sp1.legend(loc=1, fancybox=True, shadow=True, prop=FontProperties(size=titleFs - 6)) 

776 

777 sp2.hist(spatialResids, bins=xs, normed=True, alpha=0.5, label="N(%.2f, %.2f)" 

778 % (np.mean(spatialResids), np.var(spatialResids))) 

779 sp2.plot(xs, ys, "r-", lw=2, label="N(0,1)") 

780 sp2.set_title("Candidates: spatial fit", fontsize=titleFs - 2) 

781 sp2.legend(loc=1, fancybox=True, shadow=True, prop=FontProperties(size=titleFs - 6)) 

782 

783 sp3.hist(nonfitResids, bins=xs, normed=True, alpha=0.5, label="N(%.2f, %.2f)" 

784 % (np.mean(nonfitResids), np.var(nonfitResids))) 

785 sp3.plot(xs, ys, "r-", lw=2, label="N(0,1)") 

786 sp3.set_title("Control sample: spatial fit", fontsize=titleFs - 2) 

787 sp3.legend(loc=1, fancybox=True, shadow=True, prop=FontProperties(size=titleFs - 6)) 

788 

789 sp4.hist(allResids, bins=xs, normed=True, alpha=0.5, label="N(%.2f, %.2f)" 

790 % (np.mean(allResids), np.var(allResids))) 

791 sp4.plot(xs, ys, "r-", lw=2, label="N(0,1)") 

792 sp4.set_title("Full image (subsampled)", fontsize=titleFs - 2) 

793 sp4.legend(loc=1, fancybox=True, shadow=True, prop=FontProperties(size=titleFs - 6)) 

794 

795 pylab.setp(sp1.get_xticklabels() + sp1.get_yticklabels(), fontsize=titleFs - 4) 

796 pylab.setp(sp2.get_xticklabels() + sp2.get_yticklabels(), fontsize=titleFs - 4) 

797 pylab.setp(sp3.get_xticklabels() + sp3.get_yticklabels(), fontsize=titleFs - 4) 

798 pylab.setp(sp4.get_xticklabels() + sp4.get_yticklabels(), fontsize=titleFs - 4) 

799 

800 sp1.set_xlim(-5, 5) 

801 sp1.set_ylim(0, 0.5) 

802 fig.show() 

803 

804 global keptPlots 

805 if keepPlots and not keptPlots: 

806 # Keep plots open when done 

807 def show(): 

808 print("%s: Please close plots when done." % __name__) 

809 try: 

810 pylab.show() 

811 except Exception: 

812 pass 

813 print("Plots closed, exiting...") 

814 import atexit 

815 atexit.register(show) 

816 keptPlots = True 

817 

818 

819def calcCentroid(arr): 

820 """Calculate first moment of a (kernel) image. 

821 """ 

822 y, x = arr.shape 

823 sarr = arr*arr 

824 xarr = np.asarray([[el for el in range(x)] for el2 in range(y)]) 

825 yarr = np.asarray([[el2 for el in range(x)] for el2 in range(y)]) 

826 narr = xarr*sarr 

827 sarrSum = sarr.sum() 

828 centx = narr.sum()/sarrSum 

829 narr = yarr*sarr 

830 centy = narr.sum()/sarrSum 

831 return centx, centy 

832 

833 

834def calcWidth(arr, centx, centy): 

835 """Calculate second moment of a (kernel) image. 

836 """ 

837 y, x = arr.shape 

838 # Square the flux so we don't have to deal with negatives 

839 sarr = arr*arr 

840 xarr = np.asarray([[el for el in range(x)] for el2 in range(y)]) 

841 yarr = np.asarray([[el2 for el in range(x)] for el2 in range(y)]) 

842 narr = sarr*np.power((xarr - centx), 2.) 

843 sarrSum = sarr.sum() 

844 xstd = np.sqrt(narr.sum()/sarrSum) 

845 narr = sarr*np.power((yarr - centy), 2.) 

846 ystd = np.sqrt(narr.sum()/sarrSum) 

847 return xstd, ystd 

848 

849 

850def printSkyDiffs(sources, wcs): 

851 """Print differences in sky coordinates. 

852 

853 The difference is that between the source Position and its Centroid mapped 

854 through Wcs. 

855 """ 

856 for s in sources: 

857 sCentroid = s.getCentroid() 

858 sPosition = s.getCoord().getPosition(geom.degrees) 

859 dra = 3600*(sPosition.getX() - wcs.pixelToSky(sCentroid).getPosition(geom.degrees).getX())/0.2 

860 ddec = 3600*(sPosition.getY() - wcs.pixelToSky(sCentroid).getPosition(geom.degrees).getY())/0.2 

861 if np.isfinite(dra) and np.isfinite(ddec): 

862 print(dra, ddec) 

863 

864 

865def makeRegions(sources, outfilename, wcs=None): 

866 """Create regions file for display from input source list. 

867 """ 

868 fh = open(outfilename, "w") 

869 fh.write("global color=red font=\"helvetica 10 normal\" " 

870 "select=1 highlite=1 edit=1 move=1 delete=1 include=1 fixed=0 source\nfk5\n") 

871 for s in sources: 

872 if wcs: 

873 (ra, dec) = wcs.pixelToSky(s.getCentroid()).getPosition(geom.degrees) 

874 else: 

875 (ra, dec) = s.getCoord().getPosition(geom.degrees) 

876 if np.isfinite(ra) and np.isfinite(dec): 

877 fh.write("circle(%f,%f,2\")\n"%(ra, dec)) 

878 fh.flush() 

879 fh.close() 

880 

881 

882def showSourceSetSky(sSet, wcs, xy0, frame=0, ctype=afwDisplay.GREEN, symb="+", size=2): 

883 """Draw the (RA, Dec) positions of a set of Sources. Image has the XY0. 

884 """ 

885 disp = afwDisplay.Display(frame=frame) 

886 with disp.Buffering(): 

887 for s in sSet: 

888 (xc, yc) = wcs.skyToPixel(s.getCoord().getRa(), s.getCoord().getDec()) 

889 xc -= xy0[0] 

890 yc -= xy0[1] 

891 disp.dot(symb, xc, yc, ctype=ctype, size=size) 

892 

893 

894def plotWhisker(results, newWcs): 

895 """Plot whisker diagram of astromeric offsets between results.matches. 

896 """ 

897 refCoordKey = results.matches[0].first.getTable().getCoordKey() 

898 inCentroidKey = results.matches[0].second.getTable().getCentroidSlot().getMeasKey() 

899 positions = [m.first.get(refCoordKey) for m in results.matches] 

900 residuals = [m.first.get(refCoordKey).getOffsetFrom( 

901 newWcs.pixelToSky(m.second.get(inCentroidKey))) for 

902 m in results.matches] 

903 import matplotlib.pyplot as plt 

904 fig = plt.figure() 

905 sp = fig.add_subplot(1, 1, 0) 

906 xpos = [x[0].asDegrees() for x in positions] 

907 ypos = [x[1].asDegrees() for x in positions] 

908 xpos.append(0.02*(max(xpos) - min(xpos)) + min(xpos)) 

909 ypos.append(0.98*(max(ypos) - min(ypos)) + min(ypos)) 

910 xidxs = np.isfinite(xpos) 

911 yidxs = np.isfinite(ypos) 

912 X = np.asarray(xpos)[xidxs] 

913 Y = np.asarray(ypos)[yidxs] 

914 distance = [x[1].asArcseconds() for x in residuals] 

915 distance.append(0.2) 

916 distance = np.asarray(distance)[xidxs] 

917 # NOTE: This assumes that the bearing is measured positive from +RA through North. 

918 # From the documentation this is not clear. 

919 bearing = [x[0].asRadians() for x in residuals] 

920 bearing.append(0) 

921 bearing = np.asarray(bearing)[xidxs] 

922 U = (distance*np.cos(bearing)) 

923 V = (distance*np.sin(bearing)) 

924 sp.quiver(X, Y, U, V) 

925 sp.set_title("WCS Residual") 

926 plt.show() 

927 

928 

929class DipoleTestImage(object): 

930 """Utility class for dipole measurement testing. 

931 

932 Generate an image with simulated dipoles and noise; store the original 

933 "pre-subtraction" images and catalogs as well. 

934 Used to generate test data for DMTN-007 (http://dmtn-007.lsst.io). 

935 """ 

936 

937 def __init__(self, w=101, h=101, xcenPos=[27.], ycenPos=[25.], xcenNeg=[23.], ycenNeg=[25.], 

938 psfSigma=2., flux=[30000.], fluxNeg=None, noise=10., gradientParams=None): 

939 self.w = w 

940 self.h = h 

941 self.xcenPos = xcenPos 

942 self.ycenPos = ycenPos 

943 self.xcenNeg = xcenNeg 

944 self.ycenNeg = ycenNeg 

945 self.psfSigma = psfSigma 

946 self.flux = flux 

947 self.fluxNeg = fluxNeg 

948 if fluxNeg is None: 

949 self.fluxNeg = self.flux 

950 self.noise = noise 

951 self.gradientParams = gradientParams 

952 self._makeDipoleImage() 

953 

954 def _makeDipoleImage(self): 

955 """Generate an exposure and catalog with the given dipole source(s). 

956 """ 

957 # Must seed the pos/neg images with different values to ensure they get different noise realizations 

958 posImage, posCatalog = self._makeStarImage( 

959 xc=self.xcenPos, yc=self.ycenPos, flux=self.flux, randomSeed=111) 

960 

961 negImage, negCatalog = self._makeStarImage( 

962 xc=self.xcenNeg, yc=self.ycenNeg, flux=self.fluxNeg, randomSeed=222) 

963 

964 dipole = posImage.clone() 

965 di = dipole.getMaskedImage() 

966 di -= negImage.getMaskedImage() 

967 

968 self.diffim, self.posImage, self.posCatalog, self.negImage, self.negCatalog \ 

969 = dipole, posImage, posCatalog, negImage, negCatalog 

970 

971 def _makeStarImage(self, xc=[15.3], yc=[18.6], flux=[2500], schema=None, randomSeed=None): 

972 """Generate an exposure and catalog with the given stellar source(s). 

973 """ 

974 from lsst.meas.base.tests import TestDataset 

975 bbox = geom.Box2I(geom.Point2I(0, 0), geom.Point2I(self.w - 1, self.h - 1)) 

976 dataset = TestDataset(bbox, psfSigma=self.psfSigma, threshold=1.) 

977 

978 for i in range(len(xc)): 

979 dataset.addSource(instFlux=flux[i], centroid=geom.Point2D(xc[i], yc[i])) 

980 

981 if schema is None: 

982 schema = TestDataset.makeMinimalSchema() 

983 exposure, catalog = dataset.realize(noise=self.noise, schema=schema, randomSeed=randomSeed) 

984 

985 if self.gradientParams is not None: 

986 y, x = np.mgrid[:self.w, :self.h] 

987 gp = self.gradientParams 

988 gradient = gp[0] + gp[1]*x + gp[2]*y 

989 if len(self.gradientParams) > 3: # it includes a set of 2nd-order polynomial params 

990 gradient += gp[3]*x*y + gp[4]*x*x + gp[5]*y*y 

991 imgArr = exposure.image.array 

992 imgArr += gradient 

993 

994 return exposure, catalog 

995 

996 def fitDipoleSource(self, source, **kwds): 

997 alg = DipoleFitAlgorithm(self.diffim, self.posImage, self.negImage) 

998 fitResult = alg.fitDipole(source, **kwds) 

999 return fitResult 

1000 

1001 def detectDipoleSources(self, doMerge=True, diffim=None, detectSigma=5.5, grow=3, minBinSize=32): 

1002 """Utility function for detecting dipoles. 

1003 

1004 Detect pos/neg sources in the diffim, then merge them. A 

1005 bigger "grow" parameter leads to a larger footprint which 

1006 helps with dipole measurement for faint dipoles. 

1007 

1008 Parameters 

1009 ---------- 

1010 doMerge : `bool` 

1011 Whether to merge the positive and negagive detections into a single 

1012 source table. 

1013 diffim : `lsst.afw.image.exposure.exposure.ExposureF` 

1014 Difference image on which to perform detection. 

1015 detectSigma : `float` 

1016 Threshold for object detection. 

1017 grow : `int` 

1018 Number of pixels to grow the footprints before merging. 

1019 minBinSize : `int` 

1020 Minimum bin size for the background (re)estimation (only applies if 

1021 the default leads to min(nBinX, nBinY) < fit order so the default 

1022 config parameter needs to be decreased, but not to a value smaller 

1023 than ``minBinSize``, in which case the fitting algorithm will take 

1024 over and decrease the fit order appropriately.) 

1025 

1026 Returns 

1027 ------- 

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

1029 If doMerge=True, the merged source catalog is returned OR 

1030 detectTask : `lsst.meas.algorithms.SourceDetectionTask` 

1031 schema : `lsst.afw.table.Schema` 

1032 If doMerge=False, the source detection task and its schema are 

1033 returned. 

1034 """ 

1035 if diffim is None: 

1036 diffim = self.diffim 

1037 

1038 # Start with a minimal schema - only the fields all SourceCatalogs need 

1039 schema = afwTable.SourceTable.makeMinimalSchema() 

1040 

1041 # Customize the detection task a bit (optional) 

1042 detectConfig = measAlg.SourceDetectionConfig() 

1043 detectConfig.returnOriginalFootprints = False # should be the default 

1044 

1045 # code from imageDifference.py: 

1046 detectConfig.thresholdPolarity = "both" 

1047 detectConfig.thresholdValue = detectSigma 

1048 # detectConfig.nSigmaToGrow = psfSigma 

1049 detectConfig.reEstimateBackground = True # if False, will fail often for faint sources on gradients? 

1050 detectConfig.thresholdType = "pixel_stdev" 

1051 detectConfig.excludeMaskPlanes = ["EDGE"] 

1052 # Test images are often quite small, so may need to adjust background binSize 

1053 while ((min(diffim.getWidth(), diffim.getHeight()))//detectConfig.background.binSize 

1054 < detectConfig.background.approxOrderX and detectConfig.background.binSize > minBinSize): 

1055 detectConfig.background.binSize = max(minBinSize, detectConfig.background.binSize//2) 

1056 

1057 # Create the detection task. We pass the schema so the task can declare a few flag fields 

1058 detectTask = measAlg.SourceDetectionTask(schema, config=detectConfig) 

1059 

1060 table = afwTable.SourceTable.make(schema) 

1061 catalog = detectTask.run(table, diffim) 

1062 

1063 # Now do the merge. 

1064 if doMerge: 

1065 fpSet = catalog.positive 

1066 fpSet.merge(catalog.negative, grow, grow, False) 

1067 sources = afwTable.SourceCatalog(table) 

1068 fpSet.makeSources(sources) 

1069 

1070 return sources 

1071 

1072 else: 

1073 return detectTask, schema 

1074 

1075 

1076def _sliceWidth(image, threshold, peaks, axis): 

1077 vec = image.take(peaks[1 - axis], axis=axis) 

1078 low = np.interp(threshold, vec[:peaks[axis] + 1], np.arange(peaks[axis] + 1)) 

1079 high = np.interp(threshold, vec[:peaks[axis] - 1:-1], np.arange(len(vec) - 1, peaks[axis] - 1, -1)) 

1080 return high - low 

1081 

1082 

1083def getPsfFwhm(psf, average=True, position=None): 

1084 """Directly calculate the horizontal and vertical widths 

1085 of a PSF at half its maximum value. 

1086 

1087 Parameters 

1088 ---------- 

1089 psf : `~lsst.afw.detection.Psf` 

1090 Point spread function (PSF) to evaluate. 

1091 average : `bool`, optional 

1092 Set to return the average width over Y and X axes. 

1093 position : `~lsst.geom.Point2D`, optional 

1094 The position at which to evaluate the PSF. If `None`, then the 

1095 average position is used. 

1096 

1097 Returns 

1098 ------- 

1099 psfSize : `float` | `tuple` [`float`] 

1100 The FWHM of the PSF computed at its average position. 

1101 Returns the widths along the Y and X axes, 

1102 or the average of the two if `average` is set. 

1103 

1104 See Also 

1105 -------- 

1106 evaluateMeanPsfFwhm 

1107 """ 

1108 if position is None: 

1109 position = psf.getAveragePosition() 

1110 image = psf.computeKernelImage(position).array 

1111 peak = psf.computePeak(position) 

1112 peakLocs = np.unravel_index(np.argmax(image), image.shape) 

1113 width = _sliceWidth(image, peak/2., peakLocs, axis=0), _sliceWidth(image, peak/2., peakLocs, axis=1) 

1114 return np.nanmean(width) if average else width 

1115 

1116 

1117def evaluateMeanPsfFwhm(exposure: afwImage.Exposure, 

1118 fwhmExposureBuffer: float, fwhmExposureGrid: int) -> float: 

1119 """Get the mean PSF FWHM by evaluating it on a grid within an exposure. 

1120 

1121 Parameters 

1122 ---------- 

1123 exposure : `~lsst.afw.image.Exposure` 

1124 The exposure for which the mean FWHM of the PSF is to be computed. 

1125 The exposure must contain a `psf` attribute. 

1126 fwhmExposureBuffer : `float` 

1127 Fractional buffer margin to be left out of all sides of the image 

1128 during the construction of the grid to compute mean PSF FWHM in an 

1129 exposure. 

1130 fwhmExposureGrid : `int` 

1131 Grid size to compute the mean FWHM in an exposure. 

1132 

1133 Returns 

1134 ------- 

1135 meanFwhm : `float` 

1136 The mean PSF FWHM on the exposure. 

1137 

1138 Raises 

1139 ------ 

1140 ValueError 

1141 Raised if the PSF cannot be computed at any of the grid points. 

1142 

1143 See Also 

1144 -------- 

1145 `getPsfFwhm` 

1146 `computeAveragePsf` 

1147 """ 

1148 

1149 psf = exposure.psf 

1150 

1151 bbox = exposure.getBBox() 

1152 xmax, ymax = bbox.getMax() 

1153 xmin, ymin = bbox.getMin() 

1154 

1155 xbuffer = fwhmExposureBuffer*(xmax-xmin) 

1156 ybuffer = fwhmExposureBuffer*(ymax-ymin) 

1157 

1158 width = [] 

1159 for (x, y) in itertools.product(np.linspace(xmin+xbuffer, xmax-xbuffer, fwhmExposureGrid), 

1160 np.linspace(ymin+ybuffer, ymax-ybuffer, fwhmExposureGrid) 

1161 ): 

1162 pos = geom.Point2D(x, y) 

1163 try: 

1164 fwhm = getPsfFwhm(psf, average=True, position=pos) 

1165 except InvalidParameterError: 

1166 _LOG.debug("Unable to compute PSF FWHM at position (%f, %f).", x, y) 

1167 continue 

1168 

1169 width.append(fwhm) 

1170 

1171 if not width: 

1172 raise ValueError("Unable to compute PSF FWHM at any position on the exposure.") 

1173 

1174 return np.nanmean(width) 

1175 

1176 

1177def computeAveragePsf(exposure: afwImage.Exposure, 

1178 psfExposureBuffer: float, psfExposureGrid: int) -> afwImage.ImageD: 

1179 """Get the average PSF by evaluating it on a grid within an exposure. 

1180 

1181 Parameters 

1182 ---------- 

1183 exposure : `~lsst.afw.image.Exposure` 

1184 The exposure for which the average PSF is to be computed. 

1185 The exposure must contain a `psf` attribute. 

1186 psfExposureBuffer : `float` 

1187 Fractional buffer margin to be left out of all sides of the image 

1188 during the construction of the grid to compute average PSF in an 

1189 exposure. 

1190 psfExposureGrid : `int` 

1191 Grid size to compute the average PSF in an exposure. 

1192 

1193 Returns 

1194 ------- 

1195 psfImage : `~lsst.afw.image.Image` 

1196 The average PSF across the exposure. 

1197 

1198 Raises 

1199 ------ 

1200 ValueError 

1201 Raised if the PSF cannot be computed at any of the grid points. 

1202 

1203 See Also 

1204 -------- 

1205 `evaluateMeanPsfFwhm` 

1206 """ 

1207 

1208 psf = exposure.psf 

1209 

1210 bbox = exposure.getBBox() 

1211 xmax, ymax = bbox.getMax() 

1212 xmin, ymin = bbox.getMin() 

1213 

1214 xbuffer = psfExposureBuffer*(xmax-xmin) 

1215 ybuffer = psfExposureBuffer*(ymax-ymin) 

1216 

1217 nImg = 0 

1218 psfArray = None 

1219 for (x, y) in itertools.product(np.linspace(xmin+xbuffer, xmax-xbuffer, psfExposureGrid), 

1220 np.linspace(ymin+ybuffer, ymax-ybuffer, psfExposureGrid) 

1221 ): 

1222 pos = geom.Point2D(x, y) 

1223 try: 

1224 singleImage = psf.computeKernelImage(pos) 

1225 except InvalidParameterError: 

1226 _LOG.debug("Unable to compute PSF image at position (%f, %f).", x, y) 

1227 continue 

1228 

1229 if psfArray is None: 

1230 psfArray = singleImage.array 

1231 else: 

1232 psfArray += singleImage.array 

1233 nImg += 1 

1234 

1235 if psfArray is None: 

1236 raise ValueError("Unable to compute PSF image at any position on the exposure.") 

1237 

1238 psfImage = afwImage.ImageD(psfArray/nImg) 

1239 return psfImage 

1240 

1241 

1242def detectTestSources(exposure): 

1243 """Minimal source detection wrapper suitable for unit tests. 

1244 

1245 Parameters 

1246 ---------- 

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

1248 Exposure on which to run detection/measurement 

1249 The exposure is modified in place to set the 'DETECTED' mask plane. 

1250 

1251 Returns 

1252 ------- 

1253 selectSources : 

1254 Source catalog containing candidates 

1255 """ 

1256 

1257 schema = afwTable.SourceTable.makeMinimalSchema() 

1258 selectDetection = measAlg.SourceDetectionTask(schema=schema) 

1259 selectMeasurement = measBase.SingleFrameMeasurementTask(schema=schema) 

1260 table = afwTable.SourceTable.make(schema) 

1261 

1262 detRet = selectDetection.run( 

1263 table=table, 

1264 exposure=exposure, 

1265 sigma=None, # The appropriate sigma is calculated from the PSF 

1266 doSmooth=True 

1267 ) 

1268 exposure.mask.addMaskPlane("STREAK") # add empty streak mask plane in lieu of maskStreaksTask 

1269 selectSources = detRet.sources 

1270 selectMeasurement.run(measCat=selectSources, exposure=exposure) 

1271 

1272 return selectSources 

1273 

1274 

1275def makeFakeWcs(): 

1276 """Make a fake, affine Wcs. 

1277 """ 

1278 crpix = geom.Point2D(123.45, 678.9) 

1279 crval = geom.SpherePoint(0.1, 0.1, geom.degrees) 

1280 cdMatrix = np.array([[5.19513851e-05, -2.81124812e-07], 

1281 [-3.25186974e-07, -5.19112119e-05]]) 

1282 return afwGeom.makeSkyWcs(crpix, crval, cdMatrix) 

1283 

1284 

1285def makeTestImage(seed=5, nSrc=20, psfSize=2., noiseLevel=5., 

1286 noiseSeed=6, fluxLevel=500., fluxRange=2., 

1287 kernelSize=32, templateBorderSize=0, 

1288 background=None, 

1289 xSize=256, 

1290 ySize=256, 

1291 x0=12345, 

1292 y0=67890, 

1293 calibration=1., 

1294 doApplyCalibration=False, 

1295 xLoc=None, 

1296 yLoc=None, 

1297 flux=None, 

1298 clearEdgeMask=False, 

1299 ): 

1300 """Make a reproduceable PSF-convolved exposure for testing. 

1301 

1302 Parameters 

1303 ---------- 

1304 seed : `int`, optional 

1305 Seed value to initialize the random number generator for sources. 

1306 nSrc : `int`, optional 

1307 Number of sources to simulate. 

1308 psfSize : `float`, optional 

1309 Width of the PSF of the simulated sources, in pixels. 

1310 noiseLevel : `float`, optional 

1311 Standard deviation of the noise to add to each pixel. 

1312 noiseSeed : `int`, optional 

1313 Seed value to initialize the random number generator for noise. 

1314 fluxLevel : `float`, optional 

1315 Reference flux of the simulated sources. 

1316 fluxRange : `float`, optional 

1317 Range in flux amplitude of the simulated sources. 

1318 kernelSize : `int`, optional 

1319 Size in pixels of the kernel for simulating sources. 

1320 templateBorderSize : `int`, optional 

1321 Size in pixels of the image border used to pad the image. 

1322 background : `lsst.afw.math.Chebyshev1Function2D`, optional 

1323 Optional background to add to the output image. 

1324 xSize, ySize : `int`, optional 

1325 Size in pixels of the simulated image. 

1326 x0, y0 : `int`, optional 

1327 Origin of the image. 

1328 calibration : `float`, optional 

1329 Conversion factor between instFlux and nJy. 

1330 doApplyCalibration : `bool`, optional 

1331 Apply the photometric calibration and return the image in nJy? 

1332 xLoc, yLoc : `list` of `float`, optional 

1333 User-specified coordinates of the simulated sources. 

1334 If specified, must have length equal to ``nSrc`` 

1335 flux : `list` of `float`, optional 

1336 User-specified fluxes of the simulated sources. 

1337 If specified, must have length equal to ``nSrc`` 

1338 clearEdgeMask : `bool`, optional 

1339 Clear the "EDGE" mask plane after source detection. 

1340 

1341 Returns 

1342 ------- 

1343 modelExposure : `lsst.afw.image.Exposure` 

1344 The model image, with the mask and variance planes. 

1345 sourceCat : `lsst.afw.table.SourceCatalog` 

1346 Catalog of sources detected on the model image. 

1347 

1348 Raises 

1349 ------ 

1350 ValueError 

1351 If `xloc`, `yloc`, or `flux` are supplied with inconsistant lengths. 

1352 """ 

1353 # Distance from the inner edge of the bounding box to avoid placing test 

1354 # sources in the model images. 

1355 bufferSize = kernelSize/2 + templateBorderSize + 1 

1356 

1357 bbox = geom.Box2I(geom.Point2I(x0, y0), geom.Extent2I(xSize, ySize)) 

1358 if templateBorderSize > 0: 

1359 bbox.grow(templateBorderSize) 

1360 

1361 rng = np.random.RandomState(seed) 

1362 rngNoise = np.random.RandomState(noiseSeed) 

1363 x0, y0 = bbox.getBegin() 

1364 xSize, ySize = bbox.getDimensions() 

1365 if xLoc is None: 

1366 xLoc = rng.rand(nSrc)*(xSize - 2*bufferSize) + bufferSize + x0 

1367 else: 

1368 if len(xLoc) != nSrc: 

1369 raise ValueError("xLoc must have length equal to nSrc. %f supplied vs %f", len(xLoc), nSrc) 

1370 if yLoc is None: 

1371 yLoc = rng.rand(nSrc)*(ySize - 2*bufferSize) + bufferSize + y0 

1372 else: 

1373 if len(yLoc) != nSrc: 

1374 raise ValueError("yLoc must have length equal to nSrc. %f supplied vs %f", len(yLoc), nSrc) 

1375 

1376 if flux is None: 

1377 flux = (rng.rand(nSrc)*(fluxRange - 1.) + 1.)*fluxLevel 

1378 else: 

1379 if len(flux) != nSrc: 

1380 raise ValueError("flux must have length equal to nSrc. %f supplied vs %f", len(flux), nSrc) 

1381 sigmas = [psfSize for src in range(nSrc)] 

1382 coordList = list(zip(xLoc, yLoc, flux, sigmas)) 

1383 skyLevel = 0 

1384 # Don't use the built in poisson noise: it modifies the global state of numpy random 

1385 modelExposure = plantSources(bbox, kernelSize, skyLevel, coordList, addPoissonNoise=False) 

1386 modelExposure.setWcs(makeFakeWcs()) 

1387 noise = rngNoise.randn(ySize, xSize)*noiseLevel 

1388 noise -= np.mean(noise) 

1389 modelExposure.variance.array = np.sqrt(np.abs(modelExposure.image.array)) + noiseLevel**2 

1390 modelExposure.image.array += noise 

1391 

1392 # Run source detection to set up the mask plane 

1393 sourceCat = detectTestSources(modelExposure) 

1394 if clearEdgeMask: 

1395 modelExposure.mask &= ~modelExposure.mask.getPlaneBitMask("EDGE") 

1396 modelExposure.setPhotoCalib(afwImage.PhotoCalib(calibration, 0., bbox)) 

1397 if background is not None: 

1398 modelExposure.image += background 

1399 modelExposure.maskedImage /= calibration 

1400 modelExposure.info.setId(seed) 

1401 if doApplyCalibration: 

1402 modelExposure.maskedImage = modelExposure.photoCalib.calibrateImage(modelExposure.maskedImage) 

1403 

1404 return modelExposure, sourceCat 

1405 

1406 

1407def makeStats(badMaskPlanes=None): 

1408 """Create a statistics control for configuring calculations on images. 

1409 

1410 Parameters 

1411 ---------- 

1412 badMaskPlanes : `list` of `str`, optional 

1413 List of mask planes to exclude from calculations. 

1414 

1415 Returns 

1416 ------- 

1417 statsControl : ` lsst.afw.math.StatisticsControl` 

1418 Statistics control object for configuring calculations on images. 

1419 """ 

1420 if badMaskPlanes is None: 

1421 badMaskPlanes = ("INTRP", "EDGE", "DETECTED", "SAT", "CR", 

1422 "BAD", "NO_DATA", "DETECTED_NEGATIVE") 

1423 statsControl = afwMath.StatisticsControl() 

1424 statsControl.setNumSigmaClip(3.) 

1425 statsControl.setNumIter(3) 

1426 statsControl.setAndMask(afwImage.Mask.getPlaneBitMask(badMaskPlanes)) 

1427 return statsControl 

1428 

1429 

1430def computeRobustStatistics(image, mask, statsCtrl, statistic=afwMath.MEANCLIP): 

1431 """Calculate a robust mean of the variance plane of an exposure. 

1432 

1433 Parameters 

1434 ---------- 

1435 image : `lsst.afw.image.Image` 

1436 Image or variance plane of an exposure to evaluate. 

1437 mask : `lsst.afw.image.Mask` 

1438 Mask plane to use for excluding pixels. 

1439 statsCtrl : `lsst.afw.math.StatisticsControl` 

1440 Statistics control object for configuring the calculation. 

1441 statistic : `lsst.afw.math.Property`, optional 

1442 The type of statistic to compute. Typical values are 

1443 ``afwMath.MEANCLIP`` or ``afwMath.STDEVCLIP``. 

1444 

1445 Returns 

1446 ------- 

1447 value : `float` 

1448 The result of the statistic calculated from the unflagged pixels. 

1449 """ 

1450 statObj = afwMath.makeStatistics(image, mask, statistic, statsCtrl) 

1451 return statObj.getValue(statistic) 

1452 

1453 

1454def computePSFNoiseEquivalentArea(psf): 

1455 """Compute the noise equivalent area for an image psf 

1456 

1457 Parameters 

1458 ---------- 

1459 psf : `lsst.afw.detection.Psf` 

1460 

1461 Returns 

1462 ------- 

1463 nea : `float` 

1464 """ 

1465 psfImg = psf.computeImage(psf.getAveragePosition()) 

1466 nea = 1./np.sum(psfImg.array**2) 

1467 return nea