Coverage for python/lsst/summit/utils/imageExaminer.py: 11%
322 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-14 04:05 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2023-02-14 04:05 -0800
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.pyplot as plt
25import numpy as np
26from numpy.linalg import norm
28import scipy.ndimage as ndImage
30from matplotlib import cm
31from matplotlib.ticker import LinearLocator
32from matplotlib.colors import LogNorm
33from matplotlib.offsetbox import AnchoredText
34import matplotlib.patches as patches
36import lsst.geom as geom
37from scipy.optimize import curve_fit
38from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig
39from lsst.summit.utils.utils import getImageStats, argMax2d, countPixels, quickSmooth
42SIGMATOFWHM = 2.0*np.sqrt(2.0*np.log(2.0))
45def gauss(x, a, x0, sigma):
46 return a*np.exp(-(x-x0)**2/(2*sigma**2))
49class ImageExaminer():
50 """Class for the reproducing some of the functionality of imexam.
52 For an input image create a summary plot showing:
53 A rendering of the whole image
54 A cutout of main source's PSF
55 A 3d surface plot of the main star
56 A contour plot of the main star
57 x, y slices through the main star's centroid
58 Radial plot of the main star
59 Encircled energy as a function of radius
60 A text box with assorted image statistics and measurements
62 Parameters
63 ----------
64 exp : `lsst.afw.image.Exposure`
65 The input exposure to analyze.
66 doTweakCentroid : `bool`, optional
67 Tweak the centroid (either the one supplied, or the one found by QFM if
68 none supplied)? See ``tweakCentroid`` for full details of the
69 behavior.
70 doForceCoM : `bool`, optional
71 Use the centre of mass inside the cutout box as the star's centroid?
72 savePlots : `str`, optional
73 Filename to save the plot to. Image not saved if falsey.
74 centroid : `tuple` of `float`, optional
75 Centroid of the star to treat as the main source. If ``None``, use
76 ``lsst.pipe.tasks.quickFrameMeasurement.QuickFrameMeasurementTask`` to
77 find the main source in the image.
78 boxHalfSize : `int`, optional
79 The half-size of the cutout to use for the star's PSF and the radius
80 to use for the radial plots.
82 """
83 astroMappings = {"object": "Object name",
84 "mjd": "MJD",
85 "expTime": "Exp Time",
86 "filter": "Filter",
87 "grating": "grating",
88 "airmass": "Airmass",
89 "rotangle": "Rotation Angle",
90 "az": "Azimuth (deg)",
91 "el": "Elevation (deg)",
92 "focus": "Focus Z (mm)"}
94 imageMappings = {"centroid": "Centroid",
95 "maxValue": "Max pixel value",
96 "maxPixelLocation": "Max pixel location",
97 "multipleMaxPixels": "Multiple max pixels?",
98 "nBadPixels": "Num bad pixels",
99 "nSatPixels": "Num saturated pixels",
100 "percentile99": "99th percentile",
101 "percentile9999": "99.99th percentile",
102 "clippedMean": "Clipped mean",
103 "clippedStddev": "Clipped stddev"}
105 cutoutMappings = {"nStatPixInBox": "nSat in cutout",
106 "fitAmp": "Radial fitted amp",
107 "fitGausMean": "Radial fitted position",
108 "fitFwhm": "Radial fitted FWHM",
109 "eeRadius50": "50% flux radius",
110 "eeRadius80": "80% flux radius",
111 "eeRadius90": "90% flux radius"}
113 def __init__(self, exp, *, doTweakCentroid=True, doForceCoM=False, savePlots=None,
114 centroid=None, boxHalfSize=50):
116 self.exp = exp
117 self.savePlots = savePlots
118 self.doTweakCentroid = doTweakCentroid
119 self.doForceCoM = doForceCoM
121 self.boxHalfSize = boxHalfSize
122 if centroid is None:
123 qfmTaskConfig = QuickFrameMeasurementTaskConfig()
124 qfmTask = QuickFrameMeasurementTask(config=qfmTaskConfig)
125 result = qfmTask.run(exp)
126 if not result.success:
127 msg = ("Failed to automatically find source in image. "
128 "Either provide a centroid manually or use a new image")
129 raise RuntimeError(msg)
130 self.centroid = result.brightestObjCentroid
131 else:
132 self.centroid = centroid
134 self.imStats = getImageStats(self.exp) # need the background levels now
136 self.data = self.getStarBoxData()
137 if self.doTweakCentroid:
138 self.tweakCentroid(self.doForceCoM)
139 self.data = self.getStarBoxData()
141 self.xx, self.yy = self.getMeshGrid(self.data)
143 self.imStats.centroid = self.centroid
144 self.imStats.intCentroid = self.intCoords(self.centroid)
145 self.imStats.intCentroidRounded = self.intRoundCoords(self.centroid)
146 self.imStats.nStatPixInBox = self.nSatPixInBox
148 self.radialAverageAndFit()
150 def intCoords(self, coords):
151 """Get integer versions of the coordinates for dereferencing arrays.
153 Parameters are not rounded, but just cast as ints.
155 Parameters
156 ----------
157 coords : `tuple` of `float` or `int`
158 The coordinates.
160 Returns
161 -------
162 intCoords : `np.array` of `int`
163 The coordinates as integers.
164 """
165 return np.asarray(coords, dtype=int)
167 def intRoundCoords(self, coords):
168 """Get rounded integer versions of coordinates for dereferencing arrays
170 Parameters are rounded to the nearest integer value and returned.
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, rounded to the nearest values.
181 """
182 return (int(round(coords[0])), int(round(coords[1])))
184 def tweakCentroid(self, doForceCoM):
185 """Tweak the source centroid. Used to deal with irregular PSFs.
187 Given the star's cutout, tweak the centroid (either the one supplied
188 manually, or the one from QFM) as follows:
190 If ``doForceCoM`` then always use the centre of mass of the cutout box
191 as the centroid.
192 If the star has multiple maximum values (e.g. if it is saturated and
193 interpolated, or otherwise) then use the centre of mass of the cutout.
194 Otherwise, use the position of the brightest pixel in the cutout.
196 Parameters
197 ----------
198 doForceCoM : `bool`
199 Forcing using the centre of mass of the cutout as the centroid?
200 """
201 peak, uniquePeak, otherPeaks = argMax2d(self.data)
202 # saturated stars don't tend to have ambiguous max pixels
203 # due to the bunny ears left after interpolation
204 nSatPix = self.nSatPixInBox
206 if not uniquePeak or nSatPix or doForceCoM:
207 print("Using CoM for centroid (because was forced to, or multiple max pixels, or saturated")
208 self.data -= self.imStats.clippedMean
209 peak = ndImage.center_of_mass(self.data)
210 self.data += self.imStats.clippedMean
212 offset = np.asarray(peak) - np.array((self.boxHalfSize, self.boxHalfSize))
213 print(f"Centroid adjusted by {offset} pixels")
214 x = self.centroid[0] + offset[1] # yes, really, centroid is x,y offset is y,x
215 y = self.centroid[1] + offset[0]
216 self.centroid = (x, y)
218 def getStats(self):
219 """Get the image stats.
221 Returns
222 -------
223 stats : `dict`
224 A dictionary of the image statistics.
225 """
226 return self.imStats
228 @staticmethod
229 def _calcMaxBoxHalfSize(centroid, chipBbox):
230 """Calculate the maximum size the box can be without going outside the
231 detector's bounds.
233 Returns the smallest distance between the centroid and any of the
234 chip's edges.
236 Parameters
237 ----------
238 centroid : `tuple` of `float`
239 The centroid.
240 chipBbox : `lsst.geom.Box`
241 The detector's bounding box.
243 Returns
244 -------
245 maxSize : `int`
246 The maximum size for the box.
247 """
248 ll = chipBbox.getBeginX()
249 r = chipBbox.getEndX()
250 d = chipBbox.getBeginY()
251 u = chipBbox.getEndY()
253 x, y = np.array(centroid, dtype=int)
254 maxSize = np.min([(x-ll), (r-x-1), (u-y-1), (y-d)]) # extra -1 in x because [)
255 assert maxSize >= 0, "Box calculation went wrong"
256 return maxSize
258 def _calcBbox(self, centroid):
259 """Get the largest valid bounding box, given the centroid and box size.
261 Parameters
262 ----------
263 centroid : `tuple` of `float`
264 The centroid
266 Returns
267 -------
268 bbox : `lsst.geom.Box2I`
269 The bounding box
270 """
271 centroidPoint = geom.Point2I(centroid)
272 extent = geom.Extent2I(1, 1)
273 bbox = geom.Box2I(centroidPoint, extent)
274 bbox = bbox.dilatedBy(self.boxHalfSize)
275 bbox = bbox.clippedTo(self.exp.getBBox())
276 if bbox.getDimensions()[0] != bbox.getDimensions()[1]:
277 # TODO: one day support clipped, nonsquare regions
278 # but it's nontrivial due to all the plotting options
280 maxsize = self._calcMaxBoxHalfSize(centroid, self.exp.getBBox())
281 msg = (f"With centroid at {centroid} and boxHalfSize {self.boxHalfSize} "
282 "the selection runs off the edge of the chip. Boxsize has been "
283 f"automatically shrunk to {maxsize} (only square selections are "
284 "currently supported)")
285 print(msg)
286 self.boxHalfSize = maxsize
287 return self._calcBbox(centroid)
289 return bbox
291 def getStarBoxData(self):
292 """Get the image data for the star.
294 Calculates the maximum valid box, and uses that to return the image
295 data, setting self.starBbox and self.nSatPixInBox as this method
296 changes the bbox.
298 Returns
299 -------
300 data : `np.array`
301 The image data
302 """
303 bbox = self._calcBbox(self.centroid)
304 self.starBbox = bbox # needed elsewhere, so always set when calculated
305 self.nSatPixInBox = countPixels(self.exp.maskedImage[self.starBbox], 'SAT')
306 return self.exp.image[bbox].array
308 def getMeshGrid(self, data):
309 """Get the meshgrid for a data array.
311 Parameters
312 ----------
313 data : `np.array`
314 The image data array.
316 Returns
317 -------
318 xxyy : `tuple` of `np.array`
319 The xx, yy as calculated by np.meshgrid
320 """
321 xlen, ylen = data.shape
322 xx = np.arange(-1*xlen/2, xlen/2, 1)
323 yy = np.arange(-1*ylen/2, ylen/2, 1)
324 xx, yy = np.meshgrid(xx, yy)
325 return xx, yy
327 def radialAverageAndFit(self):
328 """Calculate flux vs radius from the star's centroid and fit the width.
330 Calculate the flux vs distance from the star's centroid and fit
331 a Gaussian to get a measurement of the width.
333 Also calculates the various encircled energy metrics.
335 Notes
336 -----
337 Nothing is returned, but sets many value in the class.
338 """
339 xlen, ylen = self.data.shape
340 center = np.array([xlen/2, ylen/2])
341 # TODO: add option to move centroid to max pixel for radial (argmax 2d)
343 distances = []
344 values = []
346 # could be much faster, but the array is tiny so its fine
347 for i in range(xlen):
348 for j in range(ylen):
349 value = self.data[i, j]
350 dist = norm((i, j) - center)
351 if dist > xlen//2:
352 continue # clip to box size, we don't need a factor of sqrt(2) extra
353 values.append(value)
354 distances.append(dist)
356 peakPos = 0
357 amplitude = np.max(values)
358 width = 10
360 bounds = ((0, 0, 0), (np.inf, np.inf, np.inf))
362 try:
363 pars, pCov = curve_fit(gauss, distances, values, [amplitude, peakPos, width], bounds=bounds)
364 pars[0] = np.abs(pars[0])
365 pars[2] = np.abs(pars[2])
366 except RuntimeError:
367 pars = None
368 self.imStats.fitAmp = np.nan
369 self.imStats.fitGausMean = np.nan
370 self.imStats.fitFwhm = np.nan
372 if pars is not None:
373 self.imStats.fitAmp = pars[0]
374 self.imStats.fitGausMean = pars[1]
375 self.imStats.fitFwhm = pars[2] * SIGMATOFWHM
377 self.radialDistances = distances
378 self.radialValues = values
380 # calculate encircled energy metric too
381 # sort distances and values in step by distance
382 d = np.array([(r, v) for (r, v) in sorted(zip(self.radialDistances, self.radialValues))])
383 self.radii = d[:, 0]
384 values = d[:, 1]
385 self.cumFluxes = np.cumsum(values)
386 self.cumFluxesNorm = self.cumFluxes/np.max(self.cumFluxes)
388 self.imStats.eeRadius50 = self.getEncircledEnergyRadius(50)
389 self.imStats.eeRadius80 = self.getEncircledEnergyRadius(80)
390 self.imStats.eeRadius90 = self.getEncircledEnergyRadius(90)
392 return
394 def getEncircledEnergyRadius(self, percentage):
395 """Radius in pixels with the given percentage of encircled energy.
397 100% is at the boxHalfWidth dy definition.
399 Parameters
400 ----------
401 percentage : `float` or `int`
402 The percentage threshold to return.
404 Returns
405 -------
406 radius : `float`
407 The radius at which the ``percentage`` threshold is crossed.
408 """
409 return self.radii[np.argmin(np.abs((percentage/100)-self.cumFluxesNorm))]
411 def plotRadialAverage(self, ax=None):
412 """Make the radial average plot.
414 Parameters
415 ----------
416 ax : `maplotlib.axes`, optional
417 If ``None`` a new figure is created. Supply axes if including this
418 as a subplot.
419 """
420 plotDirect = False
421 if not ax:
422 ax = plt.subplot(111)
423 plotDirect = True
425 distances = self.radialDistances
426 values = self.radialValues
427 pars = (self.imStats.fitAmp,
428 self.imStats.fitGausMean,
429 self.imStats.fitFwhm / SIGMATOFWHM)
431 fitFailed = np.isnan(pars).any()
433 ax.plot(distances, values, 'x', label='Radial average')
434 if not fitFailed:
435 fitline = gauss(distances, *pars)
436 ax.plot(distances, fitline, label="Gaussian fit")
438 ax.set_ylabel('Flux (ADU)')
439 ax.set_xlabel('Radius (pix)')
440 ax.set_aspect(1.0/ax.get_data_ratio(), adjustable='box') # equal aspect for non-images
441 ax.legend()
443 if plotDirect:
444 plt.show()
446 def plotContours(self, ax=None, nContours=10):
447 """Make the contour plot.
449 Parameters
450 ----------
451 ax : `maplotlib.axes`, optional
452 If ``None`` a new figure is created. Supply axes if including this
453 as a subplot.
454 nContours : `int`, optional
455 The number of contours to use.
456 """
457 plotDirect = False
458 if not ax:
459 fig = plt.figure(figsize=(8, 8)) # noqa F841
460 ax = plt.subplot(111)
461 plotDirect = True
463 vmin = np.percentile(self.data, 0.1)
464 vmax = np.percentile(self.data, 99.9)
465 lvls = np.linspace(vmin, vmax, nContours)
466 intervalSize = (lvls[1]-lvls[0])
467 contourPlot = ax.contour(self.xx, self.yy, self.data, levels=lvls) # noqa F841
468 print(f"Contoured from {vmin:,.0f} to {vmax:,.0f} using {nContours} contours of {intervalSize:.1f}")
470 ax.tick_params(which="both", direction="in", top=True, right=True, labelsize=8)
471 ax.set_aspect("equal")
473 if plotDirect:
474 plt.show()
476 def plotSurface(self, ax=None, useColor=True):
477 """Make the surface plot.
479 Parameters
480 ----------
481 ax : `maplotlib.axes`, optional
482 If ``None`` a new figure is created. Supply axes if including this
483 as a subplot.
484 useColor : `bool`, optional
485 Plot at as a surface if ``True``, else plot as a wireframe.
486 """
487 plotDirect = False
488 if not ax:
489 fig, ax = plt.subplots(subplot_kw={"projection": "3d"}, figsize=(10, 10))
490 plotDirect = True
492 if useColor:
493 surf = ax.plot_surface(self.xx, self.yy, self.data, cmap=cm.plasma,
494 linewidth=1, antialiased=True, color='k', alpha=0.9)
495 else:
496 surf = ax.plot_wireframe(self.xx, self.yy, self.data, cmap=cm.gray, # noqa F841
497 linewidth=1, antialiased=True, color='k')
499 ax.zaxis.set_major_locator(LinearLocator(10))
500 ax.zaxis.set_major_formatter('{x:,.0f}')
502 if plotDirect:
503 plt.show()
505 def plotStar(self, ax=None, logScale=False):
506 """Make the PSF cutout plot.
508 Parameters
509 ----------
510 ax : `maplotlib.axes`, optional
511 If ``None`` a new figure is created. Supply axes if including this
512 as a subplot.
513 logScale : `bool`, optional
514 Use a log scale?
515 """
516 # TODO: display centroid in use
517 plotDirect = False
518 if not ax:
519 ax = plt.subplot(111)
520 plotDirect = True
522 interp = 'none'
523 if logScale:
524 ax.imshow(self.data, norm=LogNorm(), origin='lower', interpolation=interp)
525 else:
526 ax.imshow(self.data, origin='lower', interpolation=interp)
527 ax.tick_params(which="major", direction="in", top=True, right=True, labelsize=8)
529 xlen, ylen = self.data.shape
530 center = np.array([xlen/2, ylen/2])
531 ax.plot(*center, 'r+', markersize=10)
532 ax.plot(*center, 'rx', markersize=10)
534 if plotDirect:
535 plt.show()
537 def plotFullExp(self, ax=None):
538 """Make the full image 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 """
546 plotDirect = False
547 if not ax:
548 fig = plt.figure(figsize=(10, 10))
549 ax = fig.add_subplot(111)
550 plotDirect = True
552 imData = quickSmooth(self.exp.image.array, 2.5)
553 vmin = np.percentile(imData, 10)
554 vmax = np.percentile(imData, 99.9)
555 ax.imshow(imData, norm=LogNorm(vmin=vmin, vmax=vmax),
556 origin='lower', cmap='gray_r', interpolation='bicubic')
557 ax.tick_params(which="major", direction="in", top=True, right=True, labelsize=8)
559 xy0 = self.starBbox.getCorners()[0].x, self.starBbox.getCorners()[0].y
560 width, height = self.starBbox.getWidth(), self.starBbox.getHeight()
561 rect = patches.Rectangle(xy0, width, height, linewidth=1, edgecolor='r', facecolor='none')
562 ax.add_patch(rect)
564 if plotDirect:
565 plt.show()
567 def plotRowColSlices(self, ax=None, logScale=False):
568 """Make the row and column slice plot.
570 Parameters
571 ----------
572 ax : `maplotlib.axes`, optional
573 If ``None`` a new figure is created. Supply axes if including this
574 as a subplot.
575 logScale : `bool`, optional
576 Use a log scale?
577 """
578 # TODO: display centroid in use
580 # slice through self.boxHalfSize because it's always the point being
581 # used by definition
582 rowSlice = self.data[self.boxHalfSize, :]
583 colSlice = self.data[:, self.boxHalfSize]
585 plotDirect = False
586 if not ax:
587 ax = plt.subplot(111)
588 plotDirect = True
590 xs = range(-1*self.boxHalfSize, self.boxHalfSize+1)
591 ax.plot(xs, rowSlice, label='Row plot')
592 ax.plot(xs, colSlice, label='Column plot')
593 if logScale:
594 pass
595 # TODO: set yscale as log here also protect against negatives
597 ax.set_ylabel('Flux (ADU)')
598 ax.set_xlabel('Radius (pix)')
599 ax.set_aspect(1.0/ax.get_data_ratio(), adjustable='box') # equal aspect for non-images
601 ax.legend()
602 if plotDirect:
603 plt.show()
605 def plotStats(self, ax, lines):
606 """Make the stats box 'plot'.
608 Parameters
609 ----------
610 ax : `maplotlib.axes`
611 Axes to use.
612 lines : `list` of `str`
613 The data to include in the text box
614 """
615 text = "\n".join([line for line in lines])
617 stats_text = AnchoredText(text, loc="center", pad=0.5,
618 prop=dict(size=14, ma="left", backgroundcolor="white",
619 color="black", family='monospace'))
620 ax.add_artist(stats_text)
621 ax.axis('off')
623 def plotCurveOfGrowth(self, ax=None):
624 """Make the encircled energy plot.
626 Parameters
627 ----------
628 ax : `maplotlib.axes`, optional
629 If ``None`` a new figure is created. Supply axes if including this
630 as a subplot.
631 """
632 plotDirect = False
633 if not ax:
634 ax = plt.subplot(111)
635 plotDirect = True
637 ax.plot(self.radii, self.cumFluxesNorm, markersize=10)
638 ax.set_ylabel('Encircled flux (%)')
639 ax.set_xlabel('Radius (pix)')
641 ax.set_aspect(1.0/ax.get_data_ratio(), adjustable='box') # equal aspect for non-images
643 if plotDirect:
644 plt.show()
646 def plot(self):
647 """Plot all the subplots together, including the stats box.
649 Image is saved if ``savefig`` was set.
650 """
651 figsize = 6
652 fig = plt.figure(figsize=(figsize*3, figsize*2))
654 ax1 = fig.add_subplot(331)
655 ax2 = fig.add_subplot(332)
656 ax3 = fig.add_subplot(333)
657 ax4 = fig.add_subplot(334, projection='3d')
658 ax5 = fig.add_subplot(335)
659 ax6 = fig.add_subplot(336)
660 ax7 = fig.add_subplot(337)
661 ax8 = fig.add_subplot(338)
662 ax9 = fig.add_subplot(339)
664 axExp = ax1
665 axStar = ax2
666 axStats1 = ax3 # noqa F841 - overwritten
667 axSurf = ax4
668 axCont = ax5
669 axStats2 = ax6 # noqa F841 - overwritten
670 axSlices = ax7
671 axRadial = ax8
672 axCoG = ax9 # noqa F841 - overwritten
674 self.plotFullExp(axExp)
675 self.plotStar(axStar)
676 self.plotSurface(axSurf)
677 self.plotContours(axCont)
678 self.plotRowColSlices(axSlices)
679 self.plotRadialAverage(axRadial)
681 # overwrite three axes with this one spanning 3 rows
682 axStats = plt.subplot2grid((3, 3), (0, 2), rowspan=2)
684 lines = []
685 lines.append(" ---- Astro ----")
686 lines.extend(self.translateStats(self.imStats, self.astroMappings))
687 lines.append("\n ---- Image ----")
688 lines.extend(self.translateStats(self.imStats, self.imageMappings))
689 lines.append("\n ---- Cutout ----")
690 lines.extend(self.translateStats(self.imStats, self.cutoutMappings))
691 self.plotStats(axStats, lines)
693 self.plotCurveOfGrowth(axCoG)
695 plt.tight_layout()
696 if self.savePlots:
697 print(f'Plot saved to {self.savePlots}')
698 fig.savefig(self.savePlots)
699 plt.show()
700 plt.close('all')
702 @staticmethod
703 def translateStats(imStats, mappingDict):
704 """Create the text for the stats box from the stats themselves.
706 Parameters
707 ----------
708 imStats : `lsst.pipe.base.Struct`
709 A container with attributes containing measurements and statistics
710 for the image.
711 mappingDict : `dict` of `str`
712 A mapping from attribute name to name for rendereding as text.
714 Returns
715 -------
716 lines : `list` of `str`
717 The translated lines of text.
718 """
719 lines = []
720 for k, v in mappingDict.items():
721 try:
722 value = getattr(imStats, k)
723 except Exception:
724 lines.append("")
725 continue
727 if type(value) == float or isinstance(value, np.floating):
728 value = f"{value:,.3f}"
729 if k == 'centroid': # special case the only tuple
730 value = f"{value[0]:.1f}, {value[1]:.1f}"
731 lines.append(f"{v} = {value}")
732 return lines
734 def plotAll(self):
735 """Make each of the plots, individually.
737 Makes all the plots, full size, one by one, as opposed to plot() which
738 creates a single image containing all the plots.
739 """
740 self.plotStar()
741 self.plotRadialAverage()
742 self.plotContours()
743 self.plotSurface()
744 self.plotStar()
745 self.plotRowColSlices()