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

322 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-13 04:54 -0700

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.patches as patches 

25import matplotlib.pyplot as plt 

26import numpy as np 

27import scipy.ndimage as ndImage 

28from matplotlib import cm 

29from matplotlib.colors import LogNorm 

30from matplotlib.offsetbox import AnchoredText 

31from matplotlib.ticker import LinearLocator 

32from numpy.linalg import norm 

33from scipy.optimize import curve_fit 

34 

35import lsst.geom as geom 

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

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

38 

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

40 

41 

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

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

44 

45 

46class ImageExaminer: 

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

48 

49 For an input image create a summary plot showing: 

50 A rendering of the whole image 

51 A cutout of main source's PSF 

52 A 3d surface plot of the main star 

53 A contour plot of the main star 

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

55 Radial plot of the main star 

56 Encircled energy as a function of radius 

57 A text box with assorted image statistics and measurements 

58 

59 Parameters 

60 ---------- 

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

62 The input exposure to analyze. 

63 doTweakCentroid : `bool`, optional 

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

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

66 behavior. 

67 doForceCoM : `bool`, optional 

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

69 savePlots : `str`, optional 

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

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

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

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

74 find the main source in the image. 

75 boxHalfSize : `int`, optional 

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

77 to use for the radial plots. 

78 

79 """ 

80 

81 astroMappings = { 

82 "object": "Object name", 

83 "mjd": "MJD", 

84 "expTime": "Exp Time", 

85 "filter": "Filter", 

86 "grating": "grating", 

87 "airmass": "Airmass", 

88 "rotangle": "Rotation Angle", 

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

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

91 "focus": "Focus Z (mm)", 

92 } 

93 

94 imageMappings = { 

95 "centroid": "Centroid", 

96 "maxValue": "Max pixel value", 

97 "maxPixelLocation": "Max pixel location", 

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

99 "nBadPixels": "Num bad pixels", 

100 "nSatPixels": "Num saturated pixels", 

101 "percentile99": "99th percentile", 

102 "percentile9999": "99.99th percentile", 

103 "clippedMean": "Clipped mean", 

104 "clippedStddev": "Clipped stddev", 

105 } 

106 

107 cutoutMappings = { 

108 "nStatPixInBox": "nSat in cutout", 

109 "fitAmp": "Radial fitted amp", 

110 "fitGausMean": "Radial fitted position", 

111 "fitFwhm": "Radial fitted FWHM", 

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

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

114 "eeRadius90": "90% flux radius", 

115 } 

116 

117 def __init__( 

118 self, exp, *, doTweakCentroid=True, doForceCoM=False, savePlots=None, centroid=None, boxHalfSize=50 

119 ): 

120 self.exp = exp 

121 self.savePlots = savePlots 

122 self.doTweakCentroid = doTweakCentroid 

123 self.doForceCoM = doForceCoM 

124 

125 self.boxHalfSize = boxHalfSize 

126 if centroid is None: 

127 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

128 qfmTask = QuickFrameMeasurementTask(config=qfmTaskConfig) 

129 result = qfmTask.run(exp) 

130 if not result.success: 

131 msg = ( 

132 "Failed to automatically find source in image. " 

133 "Either provide a centroid manually or use a new image" 

134 ) 

135 raise RuntimeError(msg) 

136 self.centroid = result.brightestObjCentroid 

137 else: 

138 self.centroid = centroid 

139 

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

141 

142 self.data = self.getStarBoxData() 

143 if self.doTweakCentroid: 

144 self.tweakCentroid(self.doForceCoM) 

145 self.data = self.getStarBoxData() 

146 

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

148 

149 self.imStats.centroid = self.centroid 

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

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

152 self.imStats.nStatPixInBox = self.nSatPixInBox 

153 

154 self.radialAverageAndFit() 

155 

156 def intCoords(self, coords): 

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

158 

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

160 

161 Parameters 

162 ---------- 

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

164 The coordinates. 

165 

166 Returns 

167 ------- 

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

169 The coordinates as integers. 

170 """ 

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

172 

173 def intRoundCoords(self, coords): 

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

175 

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

177 

178 Parameters 

179 ---------- 

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

181 The coordinates. 

182 

183 Returns 

184 ------- 

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

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

187 """ 

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

189 

190 def tweakCentroid(self, doForceCoM): 

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

192 

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

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

195 

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

197 as the centroid. 

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

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

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

201 

202 Parameters 

203 ---------- 

204 doForceCoM : `bool` 

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

206 """ 

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

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

209 # due to the bunny ears left after interpolation 

210 nSatPix = self.nSatPixInBox 

211 

212 if not uniquePeak or nSatPix or doForceCoM: 

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

214 self.data -= self.imStats.clippedMean 

215 peak = ndImage.center_of_mass(self.data) 

216 self.data += self.imStats.clippedMean 

217 

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

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

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

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

222 self.centroid = (x, y) 

223 

224 def getStats(self): 

225 """Get the image stats. 

226 

227 Returns 

228 ------- 

229 stats : `dict` 

230 A dictionary of the image statistics. 

231 """ 

232 return self.imStats 

233 

234 @staticmethod 

235 def _calcMaxBoxHalfSize(centroid, chipBbox): 

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

237 detector's bounds. 

238 

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

240 chip's edges. 

241 

242 Parameters 

243 ---------- 

244 centroid : `tuple` of `float` 

245 The centroid. 

246 chipBbox : `lsst.geom.Box` 

247 The detector's bounding box. 

248 

249 Returns 

250 ------- 

251 maxSize : `int` 

252 The maximum size for the box. 

253 """ 

254 ll = chipBbox.getBeginX() 

255 r = chipBbox.getEndX() 

256 d = chipBbox.getBeginY() 

257 u = chipBbox.getEndY() 

258 

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

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

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

262 return maxSize 

263 

264 def _calcBbox(self, centroid): 

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

266 

267 Parameters 

268 ---------- 

269 centroid : `tuple` of `float` 

270 The centroid 

271 

272 Returns 

273 ------- 

274 bbox : `lsst.geom.Box2I` 

275 The bounding box 

276 """ 

277 centroidPoint = geom.Point2I(centroid) 

278 extent = geom.Extent2I(1, 1) 

279 bbox = geom.Box2I(centroidPoint, extent) 

280 bbox = bbox.dilatedBy(self.boxHalfSize) 

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

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

283 # TODO: one day support clipped, nonsquare regions 

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

285 

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

287 msg = ( 

288 f"With centroid at {centroid} and boxHalfSize {self.boxHalfSize} " 

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

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

291 "currently supported)" 

292 ) 

293 print(msg) 

294 self.boxHalfSize = maxsize 

295 return self._calcBbox(centroid) 

296 

297 return bbox 

298 

299 def getStarBoxData(self): 

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

301 

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

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

304 changes the bbox. 

305 

306 Returns 

307 ------- 

308 data : `np.array` 

309 The image data 

310 """ 

311 bbox = self._calcBbox(self.centroid) 

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

313 self.nSatPixInBox = countPixels(self.exp.maskedImage[self.starBbox], "SAT") 

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

315 

316 def getMeshGrid(self, data): 

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

318 

319 Parameters 

320 ---------- 

321 data : `np.array` 

322 The image data array. 

323 

324 Returns 

325 ------- 

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

327 The xx, yy as calculated by np.meshgrid 

328 """ 

329 xlen, ylen = data.shape 

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

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

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

333 return xx, yy 

334 

335 def radialAverageAndFit(self): 

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

337 

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

339 a Gaussian to get a measurement of the width. 

340 

341 Also calculates the various encircled energy metrics. 

342 

343 Notes 

344 ----- 

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

346 """ 

347 xlen, ylen = self.data.shape 

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

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

350 

351 distances = [] 

352 values = [] 

353 

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

355 for i in range(xlen): 

356 for j in range(ylen): 

357 value = self.data[i, j] 

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

359 if dist > xlen // 2: 

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

361 values.append(value) 

362 distances.append(dist) 

363 

364 peakPos = 0 

365 amplitude = np.max(values) 

366 width = 10 

367 

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

369 

370 try: 

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

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

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

374 except RuntimeError: 

375 pars = None 

376 self.imStats.fitAmp = np.nan 

377 self.imStats.fitGausMean = np.nan 

378 self.imStats.fitFwhm = np.nan 

379 

380 if pars is not None: 

381 self.imStats.fitAmp = pars[0] 

382 self.imStats.fitGausMean = pars[1] 

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

384 

385 self.radialDistances = distances 

386 self.radialValues = values 

387 

388 # calculate encircled energy metric too 

389 # sort distances and values in step by distance 

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

391 self.radii = d[:, 0] 

392 values = d[:, 1] 

393 self.cumFluxes = np.cumsum(values) 

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

395 

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

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

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

399 

400 return 

401 

402 def getEncircledEnergyRadius(self, percentage): 

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

404 

405 100% is at the boxHalfWidth dy definition. 

406 

407 Parameters 

408 ---------- 

409 percentage : `float` or `int` 

410 The percentage threshold to return. 

411 

412 Returns 

413 ------- 

414 radius : `float` 

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

416 """ 

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

418 

419 def plotRadialAverage(self, ax=None): 

420 """Make the radial average plot. 

421 

422 Parameters 

423 ---------- 

424 ax : `maplotlib.axes`, optional 

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

426 as a subplot. 

427 """ 

428 plotDirect = False 

429 if not ax: 

430 ax = plt.subplot(111) 

431 plotDirect = True 

432 

433 distances = self.radialDistances 

434 values = self.radialValues 

435 pars = (self.imStats.fitAmp, self.imStats.fitGausMean, self.imStats.fitFwhm / SIGMATOFWHM) 

436 

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

438 

439 ax.plot(distances, values, "x", label="Radial average") 

440 if not fitFailed: 

441 fitline = gauss(distances, *pars) 

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

443 

444 ax.set_ylabel("Flux (ADU)") 

445 ax.set_xlabel("Radius (pix)") 

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

447 ax.legend() 

448 

449 if plotDirect: 

450 plt.show() 

451 

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

453 """Make the contour plot. 

454 

455 Parameters 

456 ---------- 

457 ax : `maplotlib.axes`, optional 

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

459 as a subplot. 

460 nContours : `int`, optional 

461 The number of contours to use. 

462 """ 

463 plotDirect = False 

464 if not ax: 

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

466 ax = plt.subplot(111) 

467 plotDirect = True 

468 

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

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

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

472 intervalSize = lvls[1] - lvls[0] 

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

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

475 

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

477 ax.set_aspect("equal") 

478 

479 if plotDirect: 

480 plt.show() 

481 

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

483 """Make the surface plot. 

484 

485 Parameters 

486 ---------- 

487 ax : `maplotlib.axes`, optional 

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

489 as a subplot. 

490 useColor : `bool`, optional 

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

492 """ 

493 plotDirect = False 

494 if not ax: 

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

496 plotDirect = True 

497 

498 if useColor: 

499 surf = ax.plot_surface( # noqa: F841 

500 self.xx, 

501 self.yy, 

502 self.data, 

503 cmap=cm.plasma, 

504 linewidth=1, 

505 antialiased=True, 

506 color="k", 

507 alpha=0.9, 

508 ) 

509 else: 

510 surf = ax.plot_wireframe( # noqa: F841 

511 self.xx, 

512 self.yy, 

513 self.data, 

514 cmap=cm.gray, # noqa F841 

515 linewidth=1, 

516 antialiased=True, 

517 color="k", 

518 ) 

519 

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

521 ax.zaxis.set_major_formatter("{x:,.0f}") 

522 

523 if plotDirect: 

524 plt.show() 

525 

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

527 """Make the PSF cutout plot. 

528 

529 Parameters 

530 ---------- 

531 ax : `maplotlib.axes`, optional 

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

533 as a subplot. 

534 logScale : `bool`, optional 

535 Use a log scale? 

536 """ 

537 # TODO: display centroid in use 

538 plotDirect = False 

539 if not ax: 

540 ax = plt.subplot(111) 

541 plotDirect = True 

542 

543 interp = "none" 

544 if logScale: 

545 ax.imshow(self.data, norm=LogNorm(), origin="lower", interpolation=interp) 

546 else: 

547 ax.imshow(self.data, origin="lower", interpolation=interp) 

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

549 

550 xlen, ylen = self.data.shape 

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

552 ax.plot(*center, "r+", markersize=10) 

553 ax.plot(*center, "rx", markersize=10) 

554 

555 if plotDirect: 

556 plt.show() 

557 

558 def plotFullExp(self, ax=None): 

559 """Make the full image cutout plot. 

560 

561 Parameters 

562 ---------- 

563 ax : `maplotlib.axes`, optional 

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

565 as a subplot. 

566 """ 

567 plotDirect = False 

568 if not ax: 

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

570 ax = fig.add_subplot(111) 

571 plotDirect = True 

572 

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

574 vmin = np.percentile(imData, 10) 

575 vmax = np.percentile(imData, 99.9) 

576 ax.imshow( 

577 imData, norm=LogNorm(vmin=vmin, vmax=vmax), origin="lower", cmap="gray_r", interpolation="bicubic" 

578 ) 

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

580 

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

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

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

584 ax.add_patch(rect) 

585 

586 if plotDirect: 

587 plt.show() 

588 

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

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

591 

592 Parameters 

593 ---------- 

594 ax : `maplotlib.axes`, optional 

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

596 as a subplot. 

597 logScale : `bool`, optional 

598 Use a log scale? 

599 """ 

600 # TODO: display centroid in use 

601 

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

603 # used by definition 

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

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

606 

607 plotDirect = False 

608 if not ax: 

609 ax = plt.subplot(111) 

610 plotDirect = True 

611 

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

613 ax.plot(xs, rowSlice, label="Row plot") 

614 ax.plot(xs, colSlice, label="Column plot") 

615 if logScale: 

616 pass 

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

618 

619 ax.set_ylabel("Flux (ADU)") 

620 ax.set_xlabel("Radius (pix)") 

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

622 

623 ax.legend() 

624 if plotDirect: 

625 plt.show() 

626 

627 def plotStats(self, ax, lines): 

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

629 

630 Parameters 

631 ---------- 

632 ax : `maplotlib.axes` 

633 Axes to use. 

634 lines : `list` of `str` 

635 The data to include in the text box 

636 """ 

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

638 

639 stats_text = AnchoredText( 

640 text, 

641 loc="center", 

642 pad=0.5, 

643 prop=dict(size=14, ma="left", backgroundcolor="white", color="black", family="monospace"), 

644 ) 

645 ax.add_artist(stats_text) 

646 ax.axis("off") 

647 

648 def plotCurveOfGrowth(self, ax=None): 

649 """Make the encircled energy plot. 

650 

651 Parameters 

652 ---------- 

653 ax : `maplotlib.axes`, optional 

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

655 as a subplot. 

656 """ 

657 plotDirect = False 

658 if not ax: 

659 ax = plt.subplot(111) 

660 plotDirect = True 

661 

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

663 ax.set_ylabel("Encircled flux (%)") 

664 ax.set_xlabel("Radius (pix)") 

665 

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

667 

668 if plotDirect: 

669 plt.show() 

670 

671 def plot(self): 

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

673 

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

675 """ 

676 figsize = 6 

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

678 

679 ax1 = fig.add_subplot(331) 

680 ax2 = fig.add_subplot(332) 

681 ax3 = fig.add_subplot(333) 

682 ax4 = fig.add_subplot(334, projection="3d") 

683 ax5 = fig.add_subplot(335) 

684 ax6 = fig.add_subplot(336) 

685 ax7 = fig.add_subplot(337) 

686 ax8 = fig.add_subplot(338) 

687 ax9 = fig.add_subplot(339) 

688 

689 axExp = ax1 

690 axStar = ax2 

691 axStats1 = ax3 # noqa F841 - overwritten 

692 axSurf = ax4 

693 axCont = ax5 

694 axStats2 = ax6 # noqa F841 - overwritten 

695 axSlices = ax7 

696 axRadial = ax8 

697 axCoG = ax9 # noqa F841 - overwritten 

698 

699 self.plotFullExp(axExp) 

700 self.plotStar(axStar) 

701 self.plotSurface(axSurf) 

702 self.plotContours(axCont) 

703 self.plotRowColSlices(axSlices) 

704 self.plotRadialAverage(axRadial) 

705 

706 # overwrite three axes with this one spanning 3 rows 

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

708 

709 lines = [] 

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

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

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

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

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

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

716 self.plotStats(axStats, lines) 

717 

718 self.plotCurveOfGrowth(axCoG) 

719 

720 plt.tight_layout() 

721 if self.savePlots: 

722 print(f"Plot saved to {self.savePlots}") 

723 fig.savefig(self.savePlots) 

724 plt.show() 

725 plt.close("all") 

726 

727 @staticmethod 

728 def translateStats(imStats, mappingDict): 

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

730 

731 Parameters 

732 ---------- 

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

734 A container with attributes containing measurements and statistics 

735 for the image. 

736 mappingDict : `dict` of `str` 

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

738 

739 Returns 

740 ------- 

741 lines : `list` of `str` 

742 The translated lines of text. 

743 """ 

744 lines = [] 

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

746 try: 

747 value = getattr(imStats, k) 

748 except Exception: 

749 lines.append("") 

750 continue 

751 

752 # native floats are not np.floating so must check both 

753 if isinstance(value, float) or isinstance(value, np.floating): 

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

755 if k == "centroid": # special case the only tuple 

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

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

758 return lines 

759 

760 def plotAll(self): 

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

762 

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

764 creates a single image containing all the plots. 

765 """ 

766 self.plotStar() 

767 self.plotRadialAverage() 

768 self.plotContours() 

769 self.plotSurface() 

770 self.plotStar() 

771 self.plotRowColSlices()