Coverage for python/lsst/summit/utils/imageExaminer.py: 12%

322 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-09 14:30 +0000

1# This file is part of summit_utils. 

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__all__ = ['ImageExaminer'] 

23 

24import matplotlib.pyplot as plt 

25import numpy as np 

26from numpy.linalg import norm 

27 

28import scipy.ndimage as ndImage 

29 

30from matplotlib import cm 

31from matplotlib.ticker import LinearLocator 

32from matplotlib.colors import LogNorm 

33from matplotlib.offsetbox import AnchoredText 

34import matplotlib.patches as patches 

35 

36import lsst.geom as geom 

37from scipy.optimize import curve_fit 

38from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig 

39from lsst.summit.utils.utils import getImageStats, argMax2d, countPixels, quickSmooth 

40 

41 

42SIGMATOFWHM = 2.0*np.sqrt(2.0*np.log(2.0)) 

43 

44 

45def gauss(x, a, x0, sigma): 

46 return a*np.exp(-(x-x0)**2/(2*sigma**2)) 

47 

48 

49class ImageExaminer(): 

50 """Class for the reproducing some of the functionality of imexam. 

51 

52 For an input image create a summary plot showing: 

53 A rendering of the whole image 

54 A cutout of main source's PSF 

55 A 3d surface plot of the main star 

56 A contour plot of the main star 

57 x, y slices through the main star's centroid 

58 Radial plot of the main star 

59 Encircled energy as a function of radius 

60 A text box with assorted image statistics and measurements 

61 

62 Parameters 

63 ---------- 

64 exp : `lsst.afw.image.Exposure` 

65 The input exposure to analyze. 

66 doTweakCentroid : `bool`, optional 

67 Tweak the centroid (either the one supplied, or the one found by QFM if 

68 none supplied)? See ``tweakCentroid`` for full details of the 

69 behavior. 

70 doForceCoM : `bool`, optional 

71 Use the centre of mass inside the cutout box as the star's centroid? 

72 savePlots : `str`, optional 

73 Filename to save the plot to. Image not saved if falsey. 

74 centroid : `tuple` of `float`, optional 

75 Centroid of the star to treat as the main source. If ``None``, use 

76 ``lsst.pipe.tasks.quickFrameMeasurement.QuickFrameMeasurementTask`` to 

77 find the main source in the image. 

78 boxHalfSize : `int`, optional 

79 The half-size of the cutout to use for the star's PSF and the radius 

80 to use for the radial plots. 

81 

82 """ 

83 astroMappings = {"object": "Object name", 

84 "mjd": "MJD", 

85 "expTime": "Exp Time", 

86 "filter": "Filter", 

87 "grating": "grating", 

88 "airmass": "Airmass", 

89 "rotangle": "Rotation Angle", 

90 "az": "Azimuth (deg)", 

91 "el": "Elevation (deg)", 

92 "focus": "Focus Z (mm)"} 

93 

94 imageMappings = {"centroid": "Centroid", 

95 "maxValue": "Max pixel value", 

96 "maxPixelLocation": "Max pixel location", 

97 "multipleMaxPixels": "Multiple max pixels?", 

98 "nBadPixels": "Num bad pixels", 

99 "nSatPixels": "Num saturated pixels", 

100 "percentile99": "99th percentile", 

101 "percentile9999": "99.99th percentile", 

102 "clippedMean": "Clipped mean", 

103 "clippedStddev": "Clipped stddev"} 

104 

105 cutoutMappings = {"nStatPixInBox": "nSat in cutout", 

106 "fitAmp": "Radial fitted amp", 

107 "fitGausMean": "Radial fitted position", 

108 "fitFwhm": "Radial fitted FWHM", 

109 "eeRadius50": "50% flux radius", 

110 "eeRadius80": "80% flux radius", 

111 "eeRadius90": "90% flux radius"} 

112 

113 def __init__(self, exp, *, doTweakCentroid=True, doForceCoM=False, savePlots=None, 

114 centroid=None, boxHalfSize=50): 

115 

116 self.exp = exp 

117 self.savePlots = savePlots 

118 self.doTweakCentroid = doTweakCentroid 

119 self.doForceCoM = doForceCoM 

120 

121 self.boxHalfSize = boxHalfSize 

122 if centroid is None: 

123 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

124 qfmTask = QuickFrameMeasurementTask(config=qfmTaskConfig) 

125 result = qfmTask.run(exp) 

126 if not result.success: 

127 msg = ("Failed to automatically find source in image. " 

128 "Either provide a centroid manually or use a new image") 

129 raise RuntimeError(msg) 

130 self.centroid = result.brightestObjCentroid 

131 else: 

132 self.centroid = centroid 

133 

134 self.imStats = getImageStats(self.exp) # need the background levels now 

135 

136 self.data = self.getStarBoxData() 

137 if self.doTweakCentroid: 

138 self.tweakCentroid(self.doForceCoM) 

139 self.data = self.getStarBoxData() 

140 

141 self.xx, self.yy = self.getMeshGrid(self.data) 

142 

143 self.imStats.centroid = self.centroid 

144 self.imStats.intCentroid = self.intCoords(self.centroid) 

145 self.imStats.intCentroidRounded = self.intRoundCoords(self.centroid) 

146 self.imStats.nStatPixInBox = self.nSatPixInBox 

147 

148 self.radialAverageAndFit() 

149 

150 def intCoords(self, coords): 

151 """Get integer versions of the coordinates for dereferencing arrays. 

152 

153 Parameters are not rounded, but just cast as ints. 

154 

155 Parameters 

156 ---------- 

157 coords : `tuple` of `float` or `int` 

158 The coordinates. 

159 

160 Returns 

161 ------- 

162 intCoords : `np.array` of `int` 

163 The coordinates as integers. 

164 """ 

165 return np.asarray(coords, dtype=int) 

166 

167 def intRoundCoords(self, coords): 

168 """Get rounded integer versions of coordinates for dereferencing arrays 

169 

170 Parameters are rounded to the nearest integer value and returned. 

171 

172 Parameters 

173 ---------- 

174 coords : `tuple` of `float` or `int` 

175 The coordinates. 

176 

177 Returns 

178 ------- 

179 intCoords : `np.array` of `int` 

180 The coordinates as integers, rounded to the nearest values. 

181 """ 

182 return (int(round(coords[0])), int(round(coords[1]))) 

183 

184 def tweakCentroid(self, doForceCoM): 

185 """Tweak the source centroid. Used to deal with irregular PSFs. 

186 

187 Given the star's cutout, tweak the centroid (either the one supplied 

188 manually, or the one from QFM) as follows: 

189 

190 If ``doForceCoM`` then always use the centre of mass of the cutout box 

191 as the centroid. 

192 If the star has multiple maximum values (e.g. if it is saturated and 

193 interpolated, or otherwise) then use the centre of mass of the cutout. 

194 Otherwise, use the position of the brightest pixel in the cutout. 

195 

196 Parameters 

197 ---------- 

198 doForceCoM : `bool` 

199 Forcing using the centre of mass of the cutout as the centroid? 

200 """ 

201 peak, uniquePeak, otherPeaks = argMax2d(self.data) 

202 # saturated stars don't tend to have ambiguous max pixels 

203 # due to the bunny ears left after interpolation 

204 nSatPix = self.nSatPixInBox 

205 

206 if not uniquePeak or nSatPix or doForceCoM: 

207 print("Using CoM for centroid (because was forced to, or multiple max pixels, or saturated") 

208 self.data -= self.imStats.clippedMean 

209 peak = ndImage.center_of_mass(self.data) 

210 self.data += self.imStats.clippedMean 

211 

212 offset = np.asarray(peak) - np.array((self.boxHalfSize, self.boxHalfSize)) 

213 print(f"Centroid adjusted by {offset} pixels") 

214 x = self.centroid[0] + offset[1] # yes, really, centroid is x,y offset is y,x 

215 y = self.centroid[1] + offset[0] 

216 self.centroid = (x, y) 

217 

218 def getStats(self): 

219 """Get the image stats. 

220 

221 Returns 

222 ------- 

223 stats : `dict` 

224 A dictionary of the image statistics. 

225 """ 

226 return self.imStats 

227 

228 @staticmethod 

229 def _calcMaxBoxHalfSize(centroid, chipBbox): 

230 """Calculate the maximum size the box can be without going outside the 

231 detector's bounds. 

232 

233 Returns the smallest distance between the centroid and any of the 

234 chip's edges. 

235 

236 Parameters 

237 ---------- 

238 centroid : `tuple` of `float` 

239 The centroid. 

240 chipBbox : `lsst.geom.Box` 

241 The detector's bounding box. 

242 

243 Returns 

244 ------- 

245 maxSize : `int` 

246 The maximum size for the box. 

247 """ 

248 ll = chipBbox.getBeginX() 

249 r = chipBbox.getEndX() 

250 d = chipBbox.getBeginY() 

251 u = chipBbox.getEndY() 

252 

253 x, y = np.array(centroid, dtype=int) 

254 maxSize = np.min([(x-ll), (r-x-1), (u-y-1), (y-d)]) # extra -1 in x because [) 

255 assert maxSize >= 0, "Box calculation went wrong" 

256 return maxSize 

257 

258 def _calcBbox(self, centroid): 

259 """Get the largest valid bounding box, given the centroid and box size. 

260 

261 Parameters 

262 ---------- 

263 centroid : `tuple` of `float` 

264 The centroid 

265 

266 Returns 

267 ------- 

268 bbox : `lsst.geom.Box2I` 

269 The bounding box 

270 """ 

271 centroidPoint = geom.Point2I(centroid) 

272 extent = geom.Extent2I(1, 1) 

273 bbox = geom.Box2I(centroidPoint, extent) 

274 bbox = bbox.dilatedBy(self.boxHalfSize) 

275 bbox = bbox.clippedTo(self.exp.getBBox()) 

276 if bbox.getDimensions()[0] != bbox.getDimensions()[1]: 

277 # TODO: one day support clipped, nonsquare regions 

278 # but it's nontrivial due to all the plotting options 

279 

280 maxsize = self._calcMaxBoxHalfSize(centroid, self.exp.getBBox()) 

281 msg = (f"With centroid at {centroid} and boxHalfSize {self.boxHalfSize} " 

282 "the selection runs off the edge of the chip. Boxsize has been " 

283 f"automatically shrunk to {maxsize} (only square selections are " 

284 "currently supported)") 

285 print(msg) 

286 self.boxHalfSize = maxsize 

287 return self._calcBbox(centroid) 

288 

289 return bbox 

290 

291 def getStarBoxData(self): 

292 """Get the image data for the star. 

293 

294 Calculates the maximum valid box, and uses that to return the image 

295 data, setting self.starBbox and self.nSatPixInBox as this method 

296 changes the bbox. 

297 

298 Returns 

299 ------- 

300 data : `np.array` 

301 The image data 

302 """ 

303 bbox = self._calcBbox(self.centroid) 

304 self.starBbox = bbox # needed elsewhere, so always set when calculated 

305 self.nSatPixInBox = countPixels(self.exp.maskedImage[self.starBbox], 'SAT') 

306 return self.exp.image[bbox].array 

307 

308 def getMeshGrid(self, data): 

309 """Get the meshgrid for a data array. 

310 

311 Parameters 

312 ---------- 

313 data : `np.array` 

314 The image data array. 

315 

316 Returns 

317 ------- 

318 xxyy : `tuple` of `np.array` 

319 The xx, yy as calculated by np.meshgrid 

320 """ 

321 xlen, ylen = data.shape 

322 xx = np.arange(-1*xlen/2, xlen/2, 1) 

323 yy = np.arange(-1*ylen/2, ylen/2, 1) 

324 xx, yy = np.meshgrid(xx, yy) 

325 return xx, yy 

326 

327 def radialAverageAndFit(self): 

328 """Calculate flux vs radius from the star's centroid and fit the width. 

329 

330 Calculate the flux vs distance from the star's centroid and fit 

331 a Gaussian to get a measurement of the width. 

332 

333 Also calculates the various encircled energy metrics. 

334 

335 Notes 

336 ----- 

337 Nothing is returned, but sets many value in the class. 

338 """ 

339 xlen, ylen = self.data.shape 

340 center = np.array([xlen/2, ylen/2]) 

341 # TODO: add option to move centroid to max pixel for radial (argmax 2d) 

342 

343 distances = [] 

344 values = [] 

345 

346 # could be much faster, but the array is tiny so its fine 

347 for i in range(xlen): 

348 for j in range(ylen): 

349 value = self.data[i, j] 

350 dist = norm((i, j) - center) 

351 if dist > xlen//2: 

352 continue # clip to box size, we don't need a factor of sqrt(2) extra 

353 values.append(value) 

354 distances.append(dist) 

355 

356 peakPos = 0 

357 amplitude = np.max(values) 

358 width = 10 

359 

360 bounds = ((0, 0, 0), (np.inf, np.inf, np.inf)) 

361 

362 try: 

363 pars, pCov = curve_fit(gauss, distances, values, [amplitude, peakPos, width], bounds=bounds) 

364 pars[0] = np.abs(pars[0]) 

365 pars[2] = np.abs(pars[2]) 

366 except RuntimeError: 

367 pars = None 

368 self.imStats.fitAmp = np.nan 

369 self.imStats.fitGausMean = np.nan 

370 self.imStats.fitFwhm = np.nan 

371 

372 if pars is not None: 

373 self.imStats.fitAmp = pars[0] 

374 self.imStats.fitGausMean = pars[1] 

375 self.imStats.fitFwhm = pars[2] * SIGMATOFWHM 

376 

377 self.radialDistances = distances 

378 self.radialValues = values 

379 

380 # calculate encircled energy metric too 

381 # sort distances and values in step by distance 

382 d = np.array([(r, v) for (r, v) in sorted(zip(self.radialDistances, self.radialValues))]) 

383 self.radii = d[:, 0] 

384 values = d[:, 1] 

385 self.cumFluxes = np.cumsum(values) 

386 self.cumFluxesNorm = self.cumFluxes/np.max(self.cumFluxes) 

387 

388 self.imStats.eeRadius50 = self.getEncircledEnergyRadius(50) 

389 self.imStats.eeRadius80 = self.getEncircledEnergyRadius(80) 

390 self.imStats.eeRadius90 = self.getEncircledEnergyRadius(90) 

391 

392 return 

393 

394 def getEncircledEnergyRadius(self, percentage): 

395 """Radius in pixels with the given percentage of encircled energy. 

396 

397 100% is at the boxHalfWidth dy definition. 

398 

399 Parameters 

400 ---------- 

401 percentage : `float` or `int` 

402 The percentage threshold to return. 

403 

404 Returns 

405 ------- 

406 radius : `float` 

407 The radius at which the ``percentage`` threshold is crossed. 

408 """ 

409 return self.radii[np.argmin(np.abs((percentage/100)-self.cumFluxesNorm))] 

410 

411 def plotRadialAverage(self, ax=None): 

412 """Make the radial average plot. 

413 

414 Parameters 

415 ---------- 

416 ax : `maplotlib.axes`, optional 

417 If ``None`` a new figure is created. Supply axes if including this 

418 as a subplot. 

419 """ 

420 plotDirect = False 

421 if not ax: 

422 ax = plt.subplot(111) 

423 plotDirect = True 

424 

425 distances = self.radialDistances 

426 values = self.radialValues 

427 pars = (self.imStats.fitAmp, 

428 self.imStats.fitGausMean, 

429 self.imStats.fitFwhm / SIGMATOFWHM) 

430 

431 fitFailed = np.isnan(pars).any() 

432 

433 ax.plot(distances, values, 'x', label='Radial average') 

434 if not fitFailed: 

435 fitline = gauss(distances, *pars) 

436 ax.plot(distances, fitline, label="Gaussian fit") 

437 

438 ax.set_ylabel('Flux (ADU)') 

439 ax.set_xlabel('Radius (pix)') 

440 ax.set_aspect(1.0/ax.get_data_ratio(), adjustable='box') # equal aspect for non-images 

441 ax.legend() 

442 

443 if plotDirect: 

444 plt.show() 

445 

446 def plotContours(self, ax=None, nContours=10): 

447 """Make the contour plot. 

448 

449 Parameters 

450 ---------- 

451 ax : `maplotlib.axes`, optional 

452 If ``None`` a new figure is created. Supply axes if including this 

453 as a subplot. 

454 nContours : `int`, optional 

455 The number of contours to use. 

456 """ 

457 plotDirect = False 

458 if not ax: 

459 fig = plt.figure(figsize=(8, 8)) # noqa F841 

460 ax = plt.subplot(111) 

461 plotDirect = True 

462 

463 vmin = np.percentile(self.data, 0.1) 

464 vmax = np.percentile(self.data, 99.9) 

465 lvls = np.linspace(vmin, vmax, nContours) 

466 intervalSize = (lvls[1]-lvls[0]) 

467 contourPlot = ax.contour(self.xx, self.yy, self.data, levels=lvls) # noqa F841 

468 print(f"Contoured from {vmin:,.0f} to {vmax:,.0f} using {nContours} contours of {intervalSize:.1f}") 

469 

470 ax.tick_params(which="both", direction="in", top=True, right=True, labelsize=8) 

471 ax.set_aspect("equal") 

472 

473 if plotDirect: 

474 plt.show() 

475 

476 def plotSurface(self, ax=None, useColor=True): 

477 """Make the surface plot. 

478 

479 Parameters 

480 ---------- 

481 ax : `maplotlib.axes`, optional 

482 If ``None`` a new figure is created. Supply axes if including this 

483 as a subplot. 

484 useColor : `bool`, optional 

485 Plot at as a surface if ``True``, else plot as a wireframe. 

486 """ 

487 plotDirect = False 

488 if not ax: 

489 fig, ax = plt.subplots(subplot_kw={"projection": "3d"}, figsize=(10, 10)) 

490 plotDirect = True 

491 

492 if useColor: 

493 surf = ax.plot_surface(self.xx, self.yy, self.data, cmap=cm.plasma, 

494 linewidth=1, antialiased=True, color='k', alpha=0.9) 

495 else: 

496 surf = ax.plot_wireframe(self.xx, self.yy, self.data, cmap=cm.gray, # noqa F841 

497 linewidth=1, antialiased=True, color='k') 

498 

499 ax.zaxis.set_major_locator(LinearLocator(10)) 

500 ax.zaxis.set_major_formatter('{x:,.0f}') 

501 

502 if plotDirect: 

503 plt.show() 

504 

505 def plotStar(self, ax=None, logScale=False): 

506 """Make the PSF cutout plot. 

507 

508 Parameters 

509 ---------- 

510 ax : `maplotlib.axes`, optional 

511 If ``None`` a new figure is created. Supply axes if including this 

512 as a subplot. 

513 logScale : `bool`, optional 

514 Use a log scale? 

515 """ 

516 # TODO: display centroid in use 

517 plotDirect = False 

518 if not ax: 

519 ax = plt.subplot(111) 

520 plotDirect = True 

521 

522 interp = 'none' 

523 if logScale: 

524 ax.imshow(self.data, norm=LogNorm(), origin='lower', interpolation=interp) 

525 else: 

526 ax.imshow(self.data, origin='lower', interpolation=interp) 

527 ax.tick_params(which="major", direction="in", top=True, right=True, labelsize=8) 

528 

529 xlen, ylen = self.data.shape 

530 center = np.array([xlen/2, ylen/2]) 

531 ax.plot(*center, 'r+', markersize=10) 

532 ax.plot(*center, 'rx', markersize=10) 

533 

534 if plotDirect: 

535 plt.show() 

536 

537 def plotFullExp(self, ax=None): 

538 """Make the full image cutout plot. 

539 

540 Parameters 

541 ---------- 

542 ax : `maplotlib.axes`, optional 

543 If ``None`` a new figure is created. Supply axes if including this 

544 as a subplot. 

545 """ 

546 plotDirect = False 

547 if not ax: 

548 fig = plt.figure(figsize=(10, 10)) 

549 ax = fig.add_subplot(111) 

550 plotDirect = True 

551 

552 imData = quickSmooth(self.exp.image.array, 2.5) 

553 vmin = np.percentile(imData, 10) 

554 vmax = np.percentile(imData, 99.9) 

555 ax.imshow(imData, norm=LogNorm(vmin=vmin, vmax=vmax), 

556 origin='lower', cmap='gray_r', interpolation='bicubic') 

557 ax.tick_params(which="major", direction="in", top=True, right=True, labelsize=8) 

558 

559 xy0 = self.starBbox.getCorners()[0].x, self.starBbox.getCorners()[0].y 

560 width, height = self.starBbox.getWidth(), self.starBbox.getHeight() 

561 rect = patches.Rectangle(xy0, width, height, linewidth=1, edgecolor='r', facecolor='none') 

562 ax.add_patch(rect) 

563 

564 if plotDirect: 

565 plt.show() 

566 

567 def plotRowColSlices(self, ax=None, logScale=False): 

568 """Make the row and column slice plot. 

569 

570 Parameters 

571 ---------- 

572 ax : `maplotlib.axes`, optional 

573 If ``None`` a new figure is created. Supply axes if including this 

574 as a subplot. 

575 logScale : `bool`, optional 

576 Use a log scale? 

577 """ 

578 # TODO: display centroid in use 

579 

580 # slice through self.boxHalfSize because it's always the point being 

581 # used by definition 

582 rowSlice = self.data[self.boxHalfSize, :] 

583 colSlice = self.data[:, self.boxHalfSize] 

584 

585 plotDirect = False 

586 if not ax: 

587 ax = plt.subplot(111) 

588 plotDirect = True 

589 

590 xs = range(-1*self.boxHalfSize, self.boxHalfSize+1) 

591 ax.plot(xs, rowSlice, label='Row plot') 

592 ax.plot(xs, colSlice, label='Column plot') 

593 if logScale: 

594 pass 

595 # TODO: set yscale as log here also protect against negatives 

596 

597 ax.set_ylabel('Flux (ADU)') 

598 ax.set_xlabel('Radius (pix)') 

599 ax.set_aspect(1.0/ax.get_data_ratio(), adjustable='box') # equal aspect for non-images 

600 

601 ax.legend() 

602 if plotDirect: 

603 plt.show() 

604 

605 def plotStats(self, ax, lines): 

606 """Make the stats box 'plot'. 

607 

608 Parameters 

609 ---------- 

610 ax : `maplotlib.axes` 

611 Axes to use. 

612 lines : `list` of `str` 

613 The data to include in the text box 

614 """ 

615 text = "\n".join([line for line in lines]) 

616 

617 stats_text = AnchoredText(text, loc="center", pad=0.5, 

618 prop=dict(size=14, ma="left", backgroundcolor="white", 

619 color="black", family='monospace')) 

620 ax.add_artist(stats_text) 

621 ax.axis('off') 

622 

623 def plotCurveOfGrowth(self, ax=None): 

624 """Make the encircled energy plot. 

625 

626 Parameters 

627 ---------- 

628 ax : `maplotlib.axes`, optional 

629 If ``None`` a new figure is created. Supply axes if including this 

630 as a subplot. 

631 """ 

632 plotDirect = False 

633 if not ax: 

634 ax = plt.subplot(111) 

635 plotDirect = True 

636 

637 ax.plot(self.radii, self.cumFluxesNorm, markersize=10) 

638 ax.set_ylabel('Encircled flux (%)') 

639 ax.set_xlabel('Radius (pix)') 

640 

641 ax.set_aspect(1.0/ax.get_data_ratio(), adjustable='box') # equal aspect for non-images 

642 

643 if plotDirect: 

644 plt.show() 

645 

646 def plot(self): 

647 """Plot all the subplots together, including the stats box. 

648 

649 Image is saved if ``savefig`` was set. 

650 """ 

651 figsize = 6 

652 fig = plt.figure(figsize=(figsize*3, figsize*2)) 

653 

654 ax1 = fig.add_subplot(331) 

655 ax2 = fig.add_subplot(332) 

656 ax3 = fig.add_subplot(333) 

657 ax4 = fig.add_subplot(334, projection='3d') 

658 ax5 = fig.add_subplot(335) 

659 ax6 = fig.add_subplot(336) 

660 ax7 = fig.add_subplot(337) 

661 ax8 = fig.add_subplot(338) 

662 ax9 = fig.add_subplot(339) 

663 

664 axExp = ax1 

665 axStar = ax2 

666 axStats1 = ax3 # noqa F841 - overwritten 

667 axSurf = ax4 

668 axCont = ax5 

669 axStats2 = ax6 # noqa F841 - overwritten 

670 axSlices = ax7 

671 axRadial = ax8 

672 axCoG = ax9 # noqa F841 - overwritten 

673 

674 self.plotFullExp(axExp) 

675 self.plotStar(axStar) 

676 self.plotSurface(axSurf) 

677 self.plotContours(axCont) 

678 self.plotRowColSlices(axSlices) 

679 self.plotRadialAverage(axRadial) 

680 

681 # overwrite three axes with this one spanning 3 rows 

682 axStats = plt.subplot2grid((3, 3), (0, 2), rowspan=2) 

683 

684 lines = [] 

685 lines.append(" ---- Astro ----") 

686 lines.extend(self.translateStats(self.imStats, self.astroMappings)) 

687 lines.append("\n ---- Image ----") 

688 lines.extend(self.translateStats(self.imStats, self.imageMappings)) 

689 lines.append("\n ---- Cutout ----") 

690 lines.extend(self.translateStats(self.imStats, self.cutoutMappings)) 

691 self.plotStats(axStats, lines) 

692 

693 self.plotCurveOfGrowth(axCoG) 

694 

695 plt.tight_layout() 

696 if self.savePlots: 

697 print(f'Plot saved to {self.savePlots}') 

698 fig.savefig(self.savePlots) 

699 plt.show() 

700 plt.close('all') 

701 

702 @staticmethod 

703 def translateStats(imStats, mappingDict): 

704 """Create the text for the stats box from the stats themselves. 

705 

706 Parameters 

707 ---------- 

708 imStats : `lsst.pipe.base.Struct` 

709 A container with attributes containing measurements and statistics 

710 for the image. 

711 mappingDict : `dict` of `str` 

712 A mapping from attribute name to name for rendereding as text. 

713 

714 Returns 

715 ------- 

716 lines : `list` of `str` 

717 The translated lines of text. 

718 """ 

719 lines = [] 

720 for k, v in mappingDict.items(): 

721 try: 

722 value = getattr(imStats, k) 

723 except Exception: 

724 lines.append("") 

725 continue 

726 

727 if type(value) == float or isinstance(value, np.floating): 

728 value = f"{value:,.3f}" 

729 if k == 'centroid': # special case the only tuple 

730 value = f"{value[0]:.1f}, {value[1]:.1f}" 

731 lines.append(f"{v} = {value}") 

732 return lines 

733 

734 def plotAll(self): 

735 """Make each of the plots, individually. 

736 

737 Makes all the plots, full size, one by one, as opposed to plot() which 

738 creates a single image containing all the plots. 

739 """ 

740 self.plotStar() 

741 self.plotRadialAverage() 

742 self.plotContours() 

743 self.plotSurface() 

744 self.plotStar() 

745 self.plotRowColSlices()