Coverage for python/lsst/summit/utils/imageExaminer.py: 12%
322 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-26 12:41 +0000
« prev ^ index » next coverage.py v7.5.0, created at 2024-04-26 12:41 +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/>.
22__all__ = ["ImageExaminer"]
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
35import lsst.geom as geom
36from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig
37from lsst.summit.utils.utils import argMax2d, countPixels, getImageStats, quickSmooth
39SIGMATOFWHM = 2.0 * np.sqrt(2.0 * np.log(2.0))
42def gauss(x, a, x0, sigma):
43 return a * np.exp(-((x - x0) ** 2) / (2 * sigma**2))
46class ImageExaminer:
47 """Class for the reproducing some of the functionality of imexam.
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
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.
79 """
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 }
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 }
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 }
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
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
140 self.imStats = getImageStats(self.exp) # need the background levels now
142 self.data = self.getStarBoxData()
143 if self.doTweakCentroid:
144 self.tweakCentroid(self.doForceCoM)
145 self.data = self.getStarBoxData()
147 self.xx, self.yy = self.getMeshGrid(self.data)
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
154 self.radialAverageAndFit()
156 def intCoords(self, coords):
157 """Get integer versions of the coordinates for dereferencing arrays.
159 Parameters are not rounded, but just cast as ints.
161 Parameters
162 ----------
163 coords : `tuple` of `float` or `int`
164 The coordinates.
166 Returns
167 -------
168 intCoords : `np.array` of `int`
169 The coordinates as integers.
170 """
171 return np.asarray(coords, dtype=int)
173 def intRoundCoords(self, coords):
174 """Get rounded integer versions of coordinates for dereferencing arrays
176 Parameters are rounded to the nearest integer value and returned.
178 Parameters
179 ----------
180 coords : `tuple` of `float` or `int`
181 The coordinates.
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])))
190 def tweakCentroid(self, doForceCoM):
191 """Tweak the source centroid. Used to deal with irregular PSFs.
193 Given the star's cutout, tweak the centroid (either the one supplied
194 manually, or the one from QFM) as follows:
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.
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
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
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)
224 def getStats(self):
225 """Get the image stats.
227 Returns
228 -------
229 stats : `dict`
230 A dictionary of the image statistics.
231 """
232 return self.imStats
234 @staticmethod
235 def _calcMaxBoxHalfSize(centroid, chipBbox):
236 """Calculate the maximum size the box can be without going outside the
237 detector's bounds.
239 Returns the smallest distance between the centroid and any of the
240 chip's edges.
242 Parameters
243 ----------
244 centroid : `tuple` of `float`
245 The centroid.
246 chipBbox : `lsst.geom.Box`
247 The detector's bounding box.
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()
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
264 def _calcBbox(self, centroid):
265 """Get the largest valid bounding box, given the centroid and box size.
267 Parameters
268 ----------
269 centroid : `tuple` of `float`
270 The centroid
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
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)
297 return bbox
299 def getStarBoxData(self):
300 """Get the image data for the star.
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.
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
316 def getMeshGrid(self, data):
317 """Get the meshgrid for a data array.
319 Parameters
320 ----------
321 data : `np.array`
322 The image data array.
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
335 def radialAverageAndFit(self):
336 """Calculate flux vs radius from the star's centroid and fit the width.
338 Calculate the flux vs distance from the star's centroid and fit
339 a Gaussian to get a measurement of the width.
341 Also calculates the various encircled energy metrics.
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)
351 distances = []
352 values = []
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)
364 peakPos = 0
365 amplitude = np.max(values)
366 width = 10
368 bounds = ((0, 0, 0), (np.inf, np.inf, np.inf))
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
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
385 self.radialDistances = distances
386 self.radialValues = values
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)
396 self.imStats.eeRadius50 = self.getEncircledEnergyRadius(50)
397 self.imStats.eeRadius80 = self.getEncircledEnergyRadius(80)
398 self.imStats.eeRadius90 = self.getEncircledEnergyRadius(90)
400 return
402 def getEncircledEnergyRadius(self, percentage):
403 """Radius in pixels with the given percentage of encircled energy.
405 100% is at the boxHalfWidth dy definition.
407 Parameters
408 ----------
409 percentage : `float` or `int`
410 The percentage threshold to return.
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))]
419 def plotRadialAverage(self, ax=None):
420 """Make the radial average plot.
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
433 distances = self.radialDistances
434 values = self.radialValues
435 pars = (self.imStats.fitAmp, self.imStats.fitGausMean, self.imStats.fitFwhm / SIGMATOFWHM)
437 fitFailed = np.isnan(pars).any()
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")
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()
449 if plotDirect:
450 plt.show()
452 def plotContours(self, ax=None, nContours=10):
453 """Make the contour plot.
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
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}")
476 ax.tick_params(which="both", direction="in", top=True, right=True, labelsize=8)
477 ax.set_aspect("equal")
479 if plotDirect:
480 plt.show()
482 def plotSurface(self, ax=None, useColor=True):
483 """Make the surface plot.
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
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 )
520 ax.zaxis.set_major_locator(LinearLocator(10))
521 ax.zaxis.set_major_formatter("{x:,.0f}")
523 if plotDirect:
524 plt.show()
526 def plotStar(self, ax=None, logScale=False):
527 """Make the PSF cutout plot.
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
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)
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)
555 if plotDirect:
556 plt.show()
558 def plotFullExp(self, ax=None):
559 """Make the full image cutout plot.
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
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)
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)
586 if plotDirect:
587 plt.show()
589 def plotRowColSlices(self, ax=None, logScale=False):
590 """Make the row and column slice plot.
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
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]
607 plotDirect = False
608 if not ax:
609 ax = plt.subplot(111)
610 plotDirect = True
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
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
623 ax.legend()
624 if plotDirect:
625 plt.show()
627 def plotStats(self, ax, lines):
628 """Make the stats box 'plot'.
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])
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")
648 def plotCurveOfGrowth(self, ax=None):
649 """Make the encircled energy plot.
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
662 ax.plot(self.radii, self.cumFluxesNorm, markersize=10)
663 ax.set_ylabel("Encircled flux (%)")
664 ax.set_xlabel("Radius (pix)")
666 ax.set_aspect(1.0 / ax.get_data_ratio(), adjustable="box") # equal aspect for non-images
668 if plotDirect:
669 plt.show()
671 def plot(self):
672 """Plot all the subplots together, including the stats box.
674 Image is saved if ``savefig`` was set.
675 """
676 figsize = 6
677 fig = plt.figure(figsize=(figsize * 3, figsize * 2))
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)
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
699 self.plotFullExp(axExp)
700 self.plotStar(axStar)
701 self.plotSurface(axSurf)
702 self.plotContours(axCont)
703 self.plotRowColSlices(axSlices)
704 self.plotRadialAverage(axRadial)
706 # overwrite three axes with this one spanning 3 rows
707 axStats = plt.subplot2grid((3, 3), (0, 2), rowspan=2)
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)
718 self.plotCurveOfGrowth(axCoG)
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")
727 @staticmethod
728 def translateStats(imStats, mappingDict):
729 """Create the text for the stats box from the stats themselves.
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.
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
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
760 def plotAll(self):
761 """Make each of the plots, individually.
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()