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

325 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-12 03:03 -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 

24 

25import matplotlib 

26import matplotlib.patches as patches 

27import matplotlib.pyplot as plt 

28import numpy as np 

29import scipy.ndimage as ndImage 

30from matplotlib import cm 

31from matplotlib.colors import LogNorm 

32from matplotlib.offsetbox import AnchoredText 

33from matplotlib.ticker import LinearLocator 

34from numpy.linalg import norm 

35from scipy.optimize import curve_fit 

36 

37import lsst.afw.image as afwImage 

38import lsst.geom as geom 

39import lsst.pipe.base as pipeBase 

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

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

42 

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

44 

45 

46def gauss(x: float, a: float, x0: float, sigma: float) -> float: 

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

48 

49 

50class ImageExaminer: 

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

52 

53 For an input image create a summary plot showing: 

54 A rendering of the whole image 

55 A cutout of main source's PSF 

56 A 3d surface plot of the main star 

57 A contour plot of the main star 

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

59 Radial plot of the main star 

60 Encircled energy as a function of radius 

61 A text box with assorted image statistics and measurements 

62 

63 Parameters 

64 ---------- 

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

66 The input exposure to analyze. 

67 doTweakCentroid : `bool`, optional 

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

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

70 behavior. 

71 doForceCoM : `bool`, optional 

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

73 savePlots : `str`, optional 

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

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

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

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

78 find the main source in the image. 

79 boxHalfSize : `int`, optional 

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

81 to use for the radial plots. 

82 

83 """ 

84 

85 astroMappings = { 

86 "object": "Object name", 

87 "mjd": "MJD", 

88 "expTime": "Exp Time", 

89 "filter": "Filter", 

90 "grating": "grating", 

91 "airmass": "Airmass", 

92 "rotangle": "Rotation Angle", 

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

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

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

96 } 

97 

98 imageMappings = { 

99 "centroid": "Centroid", 

100 "maxValue": "Max pixel value", 

101 "maxPixelLocation": "Max pixel location", 

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

103 "nBadPixels": "Num bad pixels", 

104 "nSatPixels": "Num saturated pixels", 

105 "percentile99": "99th percentile", 

106 "percentile9999": "99.99th percentile", 

107 "clippedMean": "Clipped mean", 

108 "clippedStddev": "Clipped stddev", 

109 } 

110 

111 cutoutMappings = { 

112 "nStatPixInBox": "nSat in cutout", 

113 "fitAmp": "Radial fitted amp", 

114 "fitGausMean": "Radial fitted position", 

115 "fitFwhm": "Radial fitted FWHM", 

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

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

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

119 } 

120 

121 def __init__( 

122 self, 

123 exp: afwImage.Exposure, 

124 *, 

125 doTweakCentroid: bool = True, 

126 doForceCoM: bool = False, 

127 savePlots: str | None = None, 

128 centroid: tuple[float, float] | None = None, 

129 boxHalfSize: int = 50, 

130 ): 

131 self.exp = exp 

132 self.savePlots = savePlots 

133 self.doTweakCentroid = doTweakCentroid 

134 self.doForceCoM = doForceCoM 

135 

136 self.boxHalfSize = boxHalfSize 

137 if centroid is None: 

138 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

139 qfmTask = QuickFrameMeasurementTask(config=qfmTaskConfig) 

140 result = qfmTask.run(exp) 

141 if not result.success: 

142 msg = ( 

143 "Failed to automatically find source in image. " 

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

145 ) 

146 raise RuntimeError(msg) 

147 self.centroid = result.brightestObjCentroid 

148 else: 

149 self.centroid = centroid 

150 

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

152 

153 self.data = self.getStarBoxData() 

154 if self.doTweakCentroid: 

155 self.tweakCentroid(self.doForceCoM) 

156 self.data = self.getStarBoxData() 

157 

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

159 

160 self.imStats.centroid = self.centroid 

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

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

163 self.imStats.nStatPixInBox = self.nSatPixInBox 

164 

165 self.radialAverageAndFit() 

166 

167 def intCoords(self, coords: tuple[float | int, float | int]) -> np.ndarray[int]: 

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

169 

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

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. 

181 """ 

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

183 

184 def intRoundCoords(self, coords: tuple[float | int, float | int]) -> tuple[int, int]: 

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

186 

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

188 

189 Parameters 

190 ---------- 

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

192 The coordinates. 

193 

194 Returns 

195 ------- 

196 intCoords : Tuple[int, int] 

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

198 """ 

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

200 

201 def tweakCentroid(self, doForceCoM: bool) -> None: 

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

203 

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

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

206 

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

208 as the centroid. 

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

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

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

212 

213 Parameters 

214 ---------- 

215 doForceCoM : `bool` 

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

217 """ 

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

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

220 # due to the bunny ears left after interpolation 

221 nSatPix = self.nSatPixInBox 

222 

223 if not uniquePeak or nSatPix or doForceCoM: 

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

225 self.data -= self.imStats.clippedMean 

226 peak = ndImage.center_of_mass(self.data) 

227 self.data += self.imStats.clippedMean 

228 

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

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

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

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

233 self.centroid = (x, y) 

234 

235 def getStats(self) -> dict: 

236 """Get the image stats. 

237 

238 Returns 

239 ------- 

240 stats : `dict` 

241 A dictionary of the image statistics. 

242 """ 

243 return self.imStats 

244 

245 @staticmethod 

246 def _calcMaxBoxHalfSize(centroid: tuple[float, float], chipBbox: geom.Box2I | geom.Box2D) -> int: 

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

248 detector's bounds. 

249 

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

251 chip's edges. 

252 

253 Parameters 

254 ---------- 

255 centroid : `tuple` of `float` 

256 The centroid. 

257 chipBbox : `lsst.geom.Box` 

258 The detector's bounding box. 

259 

260 Returns 

261 ------- 

262 maxSize : `int` 

263 The maximum size for the box. 

264 """ 

265 ll = chipBbox.getBeginX() 

266 r = chipBbox.getEndX() 

267 d = chipBbox.getBeginY() 

268 u = chipBbox.getEndY() 

269 

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

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

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

273 return maxSize 

274 

275 def _calcBbox(self, centroid: tuple[float, float]) -> geom.Box2I: 

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

277 

278 Parameters 

279 ---------- 

280 centroid : `tuple` of `float` 

281 The centroid 

282 

283 Returns 

284 ------- 

285 bbox : `lsst.geom.Box2I` 

286 The bounding box 

287 """ 

288 centroidPoint = geom.Point2I(centroid) 

289 extent = geom.Extent2I(1, 1) 

290 bbox = geom.Box2I(centroidPoint, extent) 

291 bbox = bbox.dilatedBy(self.boxHalfSize) 

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

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

294 # TODO: one day support clipped, nonsquare regions 

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

296 

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

298 msg = ( 

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

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

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

302 "currently supported)" 

303 ) 

304 print(msg) 

305 self.boxHalfSize = maxsize 

306 return self._calcBbox(centroid) 

307 

308 return bbox 

309 

310 def getStarBoxData(self) -> np.ndarray[float]: 

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

312 

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

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

315 changes the bbox. 

316 

317 Returns 

318 ------- 

319 data : `np.array` 

320 The image data 

321 """ 

322 bbox = self._calcBbox(self.centroid) 

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

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

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

326 

327 def getMeshGrid(self, data: np.ndarray[int]) -> tuple[np.array, np.array]: 

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

329 

330 Parameters 

331 ---------- 

332 data : `np.array` 

333 The image data array. 

334 

335 Returns 

336 ------- 

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

338 The xx, yy as calculated by np.meshgrid 

339 """ 

340 xlen, ylen = data.shape 

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

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

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

344 return xx, yy 

345 

346 def radialAverageAndFit(self) -> None: 

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

348 

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

350 a Gaussian to get a measurement of the width. 

351 

352 Also calculates the various encircled energy metrics. 

353 

354 Notes 

355 ----- 

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

357 """ 

358 xlen, ylen = self.data.shape 

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

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

361 

362 distances = [] 

363 values = [] 

364 

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

366 for i in range(xlen): 

367 for j in range(ylen): 

368 value = self.data[i, j] 

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

370 if dist > xlen // 2: 

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

372 values.append(value) 

373 distances.append(dist) 

374 

375 peakPos = 0 

376 amplitude = np.max(values) 

377 width = 10 

378 

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

380 

381 try: 

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

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

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

385 except RuntimeError: 

386 pars = None 

387 self.imStats.fitAmp = np.nan 

388 self.imStats.fitGausMean = np.nan 

389 self.imStats.fitFwhm = np.nan 

390 

391 if pars is not None: 

392 self.imStats.fitAmp = pars[0] 

393 self.imStats.fitGausMean = pars[1] 

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

395 

396 self.radialDistances = distances 

397 self.radialValues = values 

398 

399 # calculate encircled energy metric too 

400 # sort distances and values in step by distance 

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

402 self.radii = d[:, 0] 

403 values = d[:, 1] 

404 self.cumFluxes = np.cumsum(values) 

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

406 

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

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

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

410 

411 return 

412 

413 def getEncircledEnergyRadius(self, percentage: float | int) -> float: 

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

415 

416 100% is at the boxHalfWidth dy definition. 

417 

418 Parameters 

419 ---------- 

420 percentage : `float` or `int` 

421 The percentage threshold to return. 

422 

423 Returns 

424 ------- 

425 radius : `float` 

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

427 """ 

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

429 

430 def plotRadialAverage(self, ax: matplotlib.axes.Axes | None = None) -> None: 

431 """Make the radial average plot. 

432 

433 Parameters 

434 ---------- 

435 ax : `matplotlib.axes.Axes`, optional 

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

437 as a subplot. 

438 """ 

439 plotDirect = False 

440 if not ax: 

441 ax = plt.subplot(111) 

442 plotDirect = True 

443 

444 distances = self.radialDistances 

445 values = self.radialValues 

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

447 

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

449 

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

451 if not fitFailed: 

452 fitline = gauss(distances, *pars) 

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

454 

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

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

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

458 ax.legend() 

459 

460 if plotDirect: 

461 plt.show() 

462 

463 def plotContours(self, ax: matplotlib.axes.Axes | None = None, nContours: int = 10) -> None: 

464 """Make the contour plot. 

465 

466 Parameters 

467 ---------- 

468 ax : `maplotlib.axes.Axes`, optional 

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

470 as a subplot. 

471 nContours : `int`, optional 

472 The number of contours to use. 

473 """ 

474 plotDirect = False 

475 if not ax: 

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

477 ax = plt.subplot(111) 

478 plotDirect = True 

479 

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

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

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

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

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

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

486 

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

488 ax.set_aspect("equal") 

489 

490 if plotDirect: 

491 plt.show() 

492 

493 def plotSurface(self, ax: matplotlib.axes.Axes | None = None, useColor: bool = True) -> None: 

494 """Make the surface plot. 

495 

496 Parameters 

497 ---------- 

498 ax : `maplotlib.axes`, optional 

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

500 as a subplot. 

501 useColor : `bool`, optional 

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

503 """ 

504 plotDirect = False 

505 if not ax: 

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

507 plotDirect = True 

508 

509 if useColor: 

510 surf = ax.plot_surface( # noqa: F841 

511 self.xx, 

512 self.yy, 

513 self.data, 

514 cmap=cm.plasma, 

515 linewidth=1, 

516 antialiased=True, 

517 color="k", 

518 alpha=0.9, 

519 ) 

520 else: 

521 surf = ax.plot_wireframe( # noqa: F841 

522 self.xx, 

523 self.yy, 

524 self.data, 

525 cmap=cm.gray, # noqa F841 

526 linewidth=1, 

527 antialiased=True, 

528 color="k", 

529 ) 

530 

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

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

533 

534 if plotDirect: 

535 plt.show() 

536 

537 def plotStar(self, ax: matplotlib.axes.Axes | None = None, logScale: bool = False) -> None: 

538 """Make the PSF 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 logScale : `bool`, optional 

546 Use a log scale? 

547 """ 

548 # TODO: display centroid in use 

549 plotDirect = False 

550 if not ax: 

551 ax = plt.subplot(111) 

552 plotDirect = True 

553 

554 interp = "none" 

555 if logScale: 

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

557 else: 

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

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

560 

561 xlen, ylen = self.data.shape 

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

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

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

565 

566 if plotDirect: 

567 plt.show() 

568 

569 def plotFullExp(self, ax: matplotlib.axes.Axes | None = None) -> None: 

570 """Make the full image cutout plot. 

571 

572 Parameters 

573 ---------- 

574 ax : `maplotlib.axes`, optional 

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

576 as a subplot. 

577 """ 

578 plotDirect = False 

579 if not ax: 

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

581 ax = fig.add_subplot(111) 

582 plotDirect = True 

583 

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

585 vmin = np.percentile(imData, 10) 

586 vmax = np.percentile(imData, 99.9) 

587 ax.imshow( 

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

589 ) 

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

591 

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

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

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

595 ax.add_patch(rect) 

596 

597 if plotDirect: 

598 plt.show() 

599 

600 def plotRowColSlices(self, ax: matplotlib.axes.Axes | None = None, logScale: bool = False) -> None: 

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

602 

603 Parameters 

604 ---------- 

605 ax : `maplotlib.axes`, optional 

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

607 as a subplot. 

608 logScale : `bool`, optional 

609 Use a log scale? 

610 """ 

611 # TODO: display centroid in use 

612 

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

614 # used by definition 

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

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

617 

618 plotDirect = False 

619 if not ax: 

620 ax = plt.subplot(111) 

621 plotDirect = True 

622 

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

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

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

626 if logScale: 

627 pass 

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

629 

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

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

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

633 

634 ax.legend() 

635 if plotDirect: 

636 plt.show() 

637 

638 def plotStats(self, ax: matplotlib.axes.Axes, lines: list[str]) -> None: 

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

640 

641 Parameters 

642 ---------- 

643 ax : `maplotlib.axes.Axes` 

644 Axes to use. 

645 lines : `list` of `str` 

646 The data to include in the text box 

647 """ 

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

649 

650 stats_text = AnchoredText( 

651 text, 

652 loc="center", 

653 pad=0.5, 

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

655 ) 

656 ax.add_artist(stats_text) 

657 ax.axis("off") 

658 

659 def plotCurveOfGrowth(self, ax: matplotlib.axes.Axes | None = None) -> None: 

660 """Make the encircled energy plot. 

661 

662 Parameters 

663 ---------- 

664 ax : `maplotlib.axes.Axes`, optional 

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

666 as a subplot. 

667 """ 

668 plotDirect = False 

669 if not ax: 

670 ax = plt.subplot(111) 

671 plotDirect = True 

672 

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

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

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

676 

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

678 

679 if plotDirect: 

680 plt.show() 

681 

682 def plot(self) -> None: 

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

684 

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

686 """ 

687 figsize = 6 

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

689 

690 ax1 = fig.add_subplot(331) 

691 ax2 = fig.add_subplot(332) 

692 ax3 = fig.add_subplot(333) 

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

694 ax5 = fig.add_subplot(335) 

695 ax6 = fig.add_subplot(336) 

696 ax7 = fig.add_subplot(337) 

697 ax8 = fig.add_subplot(338) 

698 ax9 = fig.add_subplot(339) 

699 

700 axExp = ax1 

701 axStar = ax2 

702 axStats1 = ax3 # noqa F841 - overwritten 

703 axSurf = ax4 

704 axCont = ax5 

705 axStats2 = ax6 # noqa F841 - overwritten 

706 axSlices = ax7 

707 axRadial = ax8 

708 axCoG = ax9 # noqa F841 - overwritten 

709 

710 self.plotFullExp(axExp) 

711 self.plotStar(axStar) 

712 self.plotSurface(axSurf) 

713 self.plotContours(axCont) 

714 self.plotRowColSlices(axSlices) 

715 self.plotRadialAverage(axRadial) 

716 

717 # overwrite three axes with this one spanning 3 rows 

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

719 

720 lines = [] 

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

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

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

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

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

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

727 self.plotStats(axStats, lines) 

728 

729 self.plotCurveOfGrowth(axCoG) 

730 

731 plt.tight_layout() 

732 if self.savePlots: 

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

734 fig.savefig(self.savePlots) 

735 plt.show() 

736 plt.close("all") 

737 

738 @staticmethod 

739 def translateStats(imStats: pipeBase.Struct, mappingDict: dict[str, str]) -> list[str]: 

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

741 

742 Parameters 

743 ---------- 

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

745 A container with attributes containing measurements and statistics 

746 for the image. 

747 mappingDict : `dict` of `str` 

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

749 

750 Returns 

751 ------- 

752 lines : `list` of `str` 

753 The translated lines of text. 

754 """ 

755 lines = [] 

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

757 try: 

758 value = getattr(imStats, k) 

759 except Exception: 

760 lines.append("") 

761 continue 

762 

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

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

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

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

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

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

769 return lines 

770 

771 def plotAll(self) -> None: 

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

773 

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

775 creates a single image containing all the plots. 

776 """ 

777 self.plotStar() 

778 self.plotRadialAverage() 

779 self.plotContours() 

780 self.plotSurface() 

781 self.plotStar() 

782 self.plotRowColSlices()