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

324 statements  

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

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) -> matplotlib.figure.Figure: 

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

684 

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

686 

687 Return 

688 ------ 

689 fig : `matplotlib.figure.Figure` 

690 The figure object. 

691 """ 

692 figsize = 6 

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

694 

695 ax1 = fig.add_subplot(331) 

696 ax2 = fig.add_subplot(332) 

697 ax3 = fig.add_subplot(333) 

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

699 ax5 = fig.add_subplot(335) 

700 ax6 = fig.add_subplot(336) 

701 ax7 = fig.add_subplot(337) 

702 ax8 = fig.add_subplot(338) 

703 ax9 = fig.add_subplot(339) 

704 

705 axExp = ax1 

706 axStar = ax2 

707 axStats1 = ax3 # noqa F841 - overwritten 

708 axSurf = ax4 

709 axCont = ax5 

710 axStats2 = ax6 # noqa F841 - overwritten 

711 axSlices = ax7 

712 axRadial = ax8 

713 axCoG = ax9 # noqa F841 - overwritten 

714 

715 self.plotFullExp(axExp) 

716 self.plotStar(axStar) 

717 self.plotSurface(axSurf) 

718 self.plotContours(axCont) 

719 self.plotRowColSlices(axSlices) 

720 self.plotRadialAverage(axRadial) 

721 

722 # overwrite three axes with this one spanning 3 rows 

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

724 

725 lines = [] 

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

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

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

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

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

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

732 self.plotStats(axStats, lines) 

733 

734 self.plotCurveOfGrowth(axCoG) 

735 

736 plt.tight_layout() 

737 if self.savePlots: 

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

739 fig.savefig(self.savePlots) 

740 return fig 

741 

742 @staticmethod 

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

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

745 

746 Parameters 

747 ---------- 

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

749 A container with attributes containing measurements and statistics 

750 for the image. 

751 mappingDict : `dict` of `str` 

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

753 

754 Returns 

755 ------- 

756 lines : `list` of `str` 

757 The translated lines of text. 

758 """ 

759 lines = [] 

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

761 try: 

762 value = getattr(imStats, k) 

763 except Exception: 

764 lines.append("") 

765 continue 

766 

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

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

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

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

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

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

773 return lines 

774 

775 def plotAll(self) -> None: 

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

777 

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

779 creates a single image containing all the plots. 

780 """ 

781 self.plotStar() 

782 self.plotRadialAverage() 

783 self.plotContours() 

784 self.plotSurface() 

785 self.plotStar() 

786 self.plotRowColSlices()