Coverage for python/lsst/summit/utils/imageExaminer.py: 13%
325 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-15 03:27 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-15 03:27 -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/>.
22__all__ = ["ImageExaminer"]
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
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
43SIGMATOFWHM = 2.0 * np.sqrt(2.0 * np.log(2.0))
46def gauss(x: float, a: float, x0: float, sigma: float) -> float:
47 return a * np.exp(-((x - x0) ** 2) / (2 * sigma**2))
50class ImageExaminer:
51 """Class for the reproducing some of the functionality of imexam.
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
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.
83 """
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 }
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 }
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 }
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
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
151 self.imStats = getImageStats(self.exp) # need the background levels now
153 self.data = self.getStarBoxData()
154 if self.doTweakCentroid:
155 self.tweakCentroid(self.doForceCoM)
156 self.data = self.getStarBoxData()
158 self.xx, self.yy = self.getMeshGrid(self.data)
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
165 self.radialAverageAndFit()
167 def intCoords(self, coords: tuple[float | int, float | int]) -> np.ndarray[int]:
168 """Get integer versions of the coordinates for dereferencing arrays.
170 Parameters are not rounded, but just cast as ints.
172 Parameters
173 ----------
174 coords : `tuple` of `float` or `int`
175 The coordinates.
177 Returns
178 -------
179 intCoords : `np.array` of `int`
180 The coordinates as integers.
181 """
182 return np.asarray(coords, dtype=int)
184 def intRoundCoords(self, coords: tuple[float | int, float | int]) -> tuple[int, int]:
185 """Get rounded integer versions of coordinates for dereferencing arrays
187 Parameters are rounded to the nearest integer value and returned.
189 Parameters
190 ----------
191 coords : `tuple` of `float` or `int`
192 The coordinates.
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])))
201 def tweakCentroid(self, doForceCoM: bool) -> None:
202 """Tweak the source centroid. Used to deal with irregular PSFs.
204 Given the star's cutout, tweak the centroid (either the one supplied
205 manually, or the one from QFM) as follows:
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.
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
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
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)
235 def getStats(self) -> dict:
236 """Get the image stats.
238 Returns
239 -------
240 stats : `dict`
241 A dictionary of the image statistics.
242 """
243 return self.imStats
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.
250 Returns the smallest distance between the centroid and any of the
251 chip's edges.
253 Parameters
254 ----------
255 centroid : `tuple` of `float`
256 The centroid.
257 chipBbox : `lsst.geom.Box`
258 The detector's bounding box.
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()
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
275 def _calcBbox(self, centroid: tuple[float, float]) -> geom.Box2I:
276 """Get the largest valid bounding box, given the centroid and box size.
278 Parameters
279 ----------
280 centroid : `tuple` of `float`
281 The centroid
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
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)
308 return bbox
310 def getStarBoxData(self) -> np.ndarray[float]:
311 """Get the image data for the star.
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.
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
327 def getMeshGrid(self, data: np.ndarray[int]) -> tuple[np.array, np.array]:
328 """Get the meshgrid for a data array.
330 Parameters
331 ----------
332 data : `np.array`
333 The image data array.
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
346 def radialAverageAndFit(self) -> None:
347 """Calculate flux vs radius from the star's centroid and fit the width.
349 Calculate the flux vs distance from the star's centroid and fit
350 a Gaussian to get a measurement of the width.
352 Also calculates the various encircled energy metrics.
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)
362 distances = []
363 values = []
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)
375 peakPos = 0
376 amplitude = np.max(values)
377 width = 10
379 bounds = ((0, 0, 0), (np.inf, np.inf, np.inf))
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
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
396 self.radialDistances = distances
397 self.radialValues = values
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)
407 self.imStats.eeRadius50 = self.getEncircledEnergyRadius(50)
408 self.imStats.eeRadius80 = self.getEncircledEnergyRadius(80)
409 self.imStats.eeRadius90 = self.getEncircledEnergyRadius(90)
411 return
413 def getEncircledEnergyRadius(self, percentage: float | int) -> float:
414 """Radius in pixels with the given percentage of encircled energy.
416 100% is at the boxHalfWidth dy definition.
418 Parameters
419 ----------
420 percentage : `float` or `int`
421 The percentage threshold to return.
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))]
430 def plotRadialAverage(self, ax: matplotlib.axes.Axes | None = None) -> None:
431 """Make the radial average plot.
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
444 distances = self.radialDistances
445 values = self.radialValues
446 pars = (self.imStats.fitAmp, self.imStats.fitGausMean, self.imStats.fitFwhm / SIGMATOFWHM)
448 fitFailed = np.isnan(pars).any()
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")
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()
460 if plotDirect:
461 plt.show()
463 def plotContours(self, ax: matplotlib.axes.Axes | None = None, nContours: int = 10) -> None:
464 """Make the contour plot.
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
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}")
487 ax.tick_params(which="both", direction="in", top=True, right=True, labelsize=8)
488 ax.set_aspect("equal")
490 if plotDirect:
491 plt.show()
493 def plotSurface(self, ax: matplotlib.axes.Axes | None = None, useColor: bool = True) -> None:
494 """Make the surface plot.
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
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 )
531 ax.zaxis.set_major_locator(LinearLocator(10))
532 ax.zaxis.set_major_formatter("{x:,.0f}")
534 if plotDirect:
535 plt.show()
537 def plotStar(self, ax: matplotlib.axes.Axes | None = None, logScale: bool = False) -> None:
538 """Make the PSF cutout plot.
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
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)
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)
566 if plotDirect:
567 plt.show()
569 def plotFullExp(self, ax: matplotlib.axes.Axes | None = None) -> None:
570 """Make the full image cutout plot.
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
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)
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)
597 if plotDirect:
598 plt.show()
600 def plotRowColSlices(self, ax: matplotlib.axes.Axes | None = None, logScale: bool = False) -> None:
601 """Make the row and column slice plot.
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
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]
618 plotDirect = False
619 if not ax:
620 ax = plt.subplot(111)
621 plotDirect = True
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
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
634 ax.legend()
635 if plotDirect:
636 plt.show()
638 def plotStats(self, ax: matplotlib.axes.Axes, lines: list[str]) -> None:
639 """Make the stats box 'plot'.
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])
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")
659 def plotCurveOfGrowth(self, ax: matplotlib.axes.Axes | None = None) -> None:
660 """Make the encircled energy plot.
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
673 ax.plot(self.radii, self.cumFluxesNorm, markersize=10)
674 ax.set_ylabel("Encircled flux (%)")
675 ax.set_xlabel("Radius (pix)")
677 ax.set_aspect(1.0 / ax.get_data_ratio(), adjustable="box") # equal aspect for non-images
679 if plotDirect:
680 plt.show()
682 def plot(self) -> None:
683 """Plot all the subplots together, including the stats box.
685 Image is saved if ``savefig`` was set.
686 """
687 figsize = 6
688 fig = plt.figure(figsize=(figsize * 3, figsize * 2))
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)
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
710 self.plotFullExp(axExp)
711 self.plotStar(axStar)
712 self.plotSurface(axSurf)
713 self.plotContours(axCont)
714 self.plotRowColSlices(axSlices)
715 self.plotRadialAverage(axRadial)
717 # overwrite three axes with this one spanning 3 rows
718 axStats = plt.subplot2grid((3, 3), (0, 2), rowspan=2)
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)
729 self.plotCurveOfGrowth(axCoG)
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")
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.
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.
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
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
771 def plotAll(self) -> None:
772 """Make each of the plots, individually.
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()