From 6ee50ac9952081c6b3ab8042366f0f052adad7ed Mon Sep 17 00:00:00 2001 From: bbean Date: Wed, 24 Apr 2024 10:44:47 -0600 Subject: [PATCH] add BcsFiducial, BcsLocatorImageProcessor, RenderControlBcs --- contrib/app/SpotAnalysis/PeakFlux.py | 11 +- .../common/lib/cv/fiducials/BcsFiducial.py | 77 +++++++++++++ .../BcsLocatorImageProcessor.py | 107 ++++++++++++++++++ .../spot_analysis/image_processor/__init__.py | 2 + .../lib/render_control/RenderControlBcs.py | 63 +++++++++++ 5 files changed, 251 insertions(+), 9 deletions(-) create mode 100644 opencsp/common/lib/cv/fiducials/BcsFiducial.py create mode 100644 opencsp/common/lib/cv/spot_analysis/image_processor/BcsLocatorImageProcessor.py create mode 100644 opencsp/common/lib/render_control/RenderControlBcs.py diff --git a/contrib/app/SpotAnalysis/PeakFlux.py b/contrib/app/SpotAnalysis/PeakFlux.py index cca9c282d..e107b14f3 100644 --- a/contrib/app/SpotAnalysis/PeakFlux.py +++ b/contrib/app/SpotAnalysis/PeakFlux.py @@ -51,14 +51,6 @@ def __init__(self, indir: str, outdir: str, experiment_name: str, settings_path_ ImageType.PRIMARY: lambda operable, operables: "off" not in operable.primary_image_source_path, ImageType.NULL: lambda operable, operables: "off" in operable.primary_image_source_path, } - # max_pixel_value_locator = AnnotationImageProcessor.AnnotationEngine( - # feature_locator=lambda operable: np.argmax(operable.primary_image.ndarray), - # color='k' - # ) - # bcs_locator = AnnotationImageProcessor.AnnotationEngine( - # feature_locator=lambda operable: self.bcs_pixel, - # color='k' - # ) self.image_processors: list[AbstractSpotAnalysisImagesProcessor] = [ CroppingImageProcessor(*self.crop_box), @@ -67,9 +59,10 @@ def __init__(self, indir: str, outdir: str, experiment_name: str, settings_path_ SupportingImagesCollectorImageProcessor(supporting_images_map), NullImageSubtractionImageProcessor(), ConvolutionImageProcessor(kernel="box", diameter=3), + BcsLocatorImageProcessor(), PopulationStatisticsImageProcessor(initial_min=0, initial_max=255), FalseColorImageProcessor(), - # AnnotationImageProcessor(max_pixel_value_locator, bcs_locator) + AnnotationImageProcessor(), ] self.spot_analysis = sa.SpotAnalysis( experiment_name, self.image_processors, save_dir=outdir, save_overwrite=True diff --git a/opencsp/common/lib/cv/fiducials/BcsFiducial.py b/opencsp/common/lib/cv/fiducials/BcsFiducial.py new file mode 100644 index 000000000..a43905081 --- /dev/null +++ b/opencsp/common/lib/cv/fiducials/BcsFiducial.py @@ -0,0 +1,77 @@ +import matplotlib.axes +import matplotlib.patches + +from opencsp.common.lib.cv.AbstractFiducials import AbstractFiducials +import opencsp.common.lib.geometry.LoopXY as loop +import opencsp.common.lib.geometry.RegionXY as reg +import opencsp.common.lib.geometry.Pxy as p2 +import opencsp.common.lib.geometry.Vxyz as v3 +import opencsp.common.lib.render_control.RenderControlBcs as rcb + + +class BcsFiducial(AbstractFiducials): + def __init__( + self, origin_px: p2.Pxy, radius_px: float, style: rcb.RenderControlBcs = None, pixels_to_meters: float = 0.1 + ): + """ + Fiducial for indicating where the BCS target is in an image. + + Parameters + ---------- + origin_px : Pxy + The center point of the BCS target, in pixels + radius_px : float + The radius of the BCS target, in pixels + style : RenderControlBcs, optional + The rendering style, by default None + pixels_to_meters : float, optional + A simple conversion method for how many meters a pixel represents, for use in scale(). by default 0.1 + """ + super().__init__(style=style) + self.origin_px = origin_px + self.radius_px = radius_px + self.pixels_to_meters = pixels_to_meters + + def get_bounding_box(self, index=0) -> reg.RegionXY: + x1, x2 = self.origin.x[0] - self.radius_px, self.origin.x[0] + self.radius_px + y1, y2 = self.origin.y[0] - self.radius_px, self.origin.y[0] + self.radius_px + return reg.RegionXY(loop.LoopXY.from_rectangle(x1, y1, x2 - x1, y2 - y1)) + + @property + def origin(self) -> p2.Pxy: + return self.origin_px + + @property + def orientation(self) -> v3.Vxyz: + return v3.Vxyz([0, 0, 0]) + + @property + def size(self) -> list[float]: + return [self.radius_px * 2] + + @property + def scale(self) -> list[float]: + return [self.size * self.pixels_to_meters] + + def _render(self, axes: matplotlib.axes.Axes): + if self.style.linestyle is not None: + circ = matplotlib.patches.Circle( + self.origin.data.tolist(), + self.radius_px, + color=self.style.color, + linestyle=self.style.linestyle, + linewidth=self.style.linewidth, + fill=False, + ) + axes.add_patch(circ) + + if self.style.marker is not None: + axes.scatter( + self.origin.x, + self.origin.y, + linewidth=self.style.linewidth, + marker=self.style.marker, + s=self.style.markersize, + c=self.style.markerfacecolor, + edgecolor=self.style.markeredgecolor, + ) diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/BcsLocatorImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/BcsLocatorImageProcessor.py new file mode 100644 index 000000000..c3eec92a9 --- /dev/null +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/BcsLocatorImageProcessor.py @@ -0,0 +1,107 @@ +import copy +import dataclasses + +import cv2 as cv +import matplotlib.pyplot as plt +import numpy as np + +from opencsp.common.lib.cv.CacheableImage import CacheableImage +from opencsp.common.lib.cv.fiducials.BcsFiducial import BcsFiducial +from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable +from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractSpotAnalysisImageProcessor import ( + AbstractSpotAnalysisImagesProcessor, +) +from opencsp.common.lib.cv.spot_analysis.image_processor.AnnotationImageProcessor import AnnotationImageProcessor +from opencsp.common.lib.cv.spot_analysis.image_processor.ConvolutionImageProcessor import ConvolutionImageProcessor +import opencsp.common.lib.geometry.Pxy as p2 +import opencsp.common.lib.opencsp_path.opencsp_root_path as orp +import opencsp.common.lib.render_control.RenderControlBcs as rcb +import opencsp.common.lib.render_control.RenderControlPointSeq as rcps +import opencsp.common.lib.tool.file_tools as ft +import opencsp.common.lib.tool.log_tools as lt + + +class BcsLocatorImageProcessor(AbstractSpotAnalysisImagesProcessor): + def __init__(self, min_radius_px=30, max_radius_px=150): + """ + Locates the BCS by identifying a circle in the image. + + It is recommended this this processor be used after ConvolutionImageProcessor(kernel='gaussian'). + + Parameters + ---------- + min_radius_px : int, optional + Minimum radius of the BSC circle, in pixels. By default 50 + max_radius_px : int, optional + Maximum radius of the BSC circle, in pixels. By default 300 + """ + super().__init__(self.__class__.__name__) + + self.min_radius_px = min_radius_px + self.max_radius_px = max_radius_px + + def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAnalysisOperable]: + image = operable.primary_image.nparray.squeeze() + if image.ndim > 2: + lt.error_and_raise( + RuntimeError, + "Error in BcsLocatorImageProcessor._execute(): image must be grayscale (2 dimensions), but " + + f"the shape of the image is {image.shape} for '{operable.primary_image_source_path}'", + ) + + # find all possible matches + method = cv.HOUGH_GRADIENT + accumulator_pixel_size: float = 1 + circles: np.ndarray | None = cv.HoughCircles( + image, + method, + accumulator_pixel_size, # resolution for accumulators + minDist=self.min_radius_px, # distance between circles + param1=70, # upper threshold to Canny edge detector + param2=20, # minimum accumulations count for matching circles + minRadius=self.min_radius_px, + maxRadius=self.max_radius_px, + ) + + # opencv returns circles in order from best to worst matches, choose the first circle (best match) + circle: BcsFiducial = None + if circles is not None: + circle_arr = circles[0][0] + center = p2.Pxy([circle_arr[0], circle_arr[1]]) + radius = circle_arr[2] + circle = BcsFiducial(center, radius, style=rcb.thin(color='m')) + + # assign to the operable + new_found_fiducials = copy.copy(operable.found_fiducials) + if circle != None: + new_found_fiducials.append(circle) + ret = dataclasses.replace(operable, found_fiducials=new_found_fiducials) + return [ret] + + +if __name__ == "__main__": + import os + + indir = ft.norm_path( + os.path.join( + orp.opencsp_scratch_dir(), + "solar_noon/dev/2023-05-12_SpringEquinoxMidSummerSolstice/2_Data/BCS_data/Measure_01/raw_images", + ) + ) + image_file = ft.norm_path(os.path.join(indir, "20230512_114854.74 5E09_000_880_2890 Raw.JPG")) + + style = rcps.RenderControlPointSeq(markersize=10) + operable = SpotAnalysisOperable(CacheableImage(source_path=image_file)) + + processor0 = ConvolutionImageProcessor(kernel='gaussian', diameter=3) + processor1 = BcsLocatorImageProcessor() + processor2 = AnnotationImageProcessor() + + result0 = processor0.process_image(operable)[0] + result1 = processor1.process_image(result0)[0] + result2 = processor2.process_image(result1)[0] + img = result2.primary_image.nparray + + plt.figure() + plt.imshow(img) + plt.show(block=True) diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/__init__.py b/opencsp/common/lib/cv/spot_analysis/image_processor/__init__.py index 98d2995b4..339d1f341 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/__init__.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/__init__.py @@ -8,6 +8,7 @@ from opencsp.common.lib.cv.spot_analysis.image_processor.AverageByGroupImageProcessor import ( AverageByGroupImageProcessor, ) +from opencsp.common.lib.cv.spot_analysis.image_processor.BcsLocatorImageProcessor import BcsLocatorImageProcessor from opencsp.common.lib.cv.spot_analysis.image_processor.ConvolutionImageProcessor import ConvolutionImageProcessor from opencsp.common.lib.cv.spot_analysis.image_processor.CroppingImageProcessor import CroppingImageProcessor from opencsp.common.lib.cv.spot_analysis.image_processor.EchoImageProcessor import EchoImageProcessor @@ -32,6 +33,7 @@ 'AbstractSpotAnalysisImagesProcessor', 'AnnotationImageProcessor', 'AverageByGroupImageProcessor', + 'BcsLocatorImageProcessor', 'ConvolutionImageProcessor', 'CroppingImageProcessor', 'EchoImageProcessor', diff --git a/opencsp/common/lib/render_control/RenderControlBcs.py b/opencsp/common/lib/render_control/RenderControlBcs.py new file mode 100644 index 000000000..510a5a3ba --- /dev/null +++ b/opencsp/common/lib/render_control/RenderControlBcs.py @@ -0,0 +1,63 @@ +from opencsp.common.lib.render_control.RenderControlPointSeq import RenderControlPointSeq + + +class RenderControlBcs(RenderControlPointSeq): + def __init__( + self, + linestyle: str | None = '-', + linewidth: float = 1, + color: str = 'b', + marker: str | None = '.', + markersize: float = 8, + markeredgecolor: str | None = None, + markeredgewidth: float | None = None, + markerfacecolor: str | None = None, + ): + """ + Render control for the Beam Characterization System target. + + Controls style of the point marker and circle marker of the BCS. + + Parameters + ---------- + linestyle : str, optional + How to draw the line for the circle around the BCS. One of '-', '--', '-.', ':', '' or None (see RenderControlPointSeq for a description). By default '-' + linewidth : int, optional + Width of the line for the circle around the BCS. By default 1 + color : str, optional + Color for the circle around the BCS. One of bgrcmykw (see RenderControlPointSeq for a description). By default 'b' + marker : str, optional + Shape of the center BCS marker. One of .,ov^<>12348sp*hH+xXDd|_ or None. By default '.' + markersize : int, optional + Size of the center BCS marker. By default 8 + markeredgecolor : str, optional + Defaults to color above if not set. By default None + markeredgewidth : float, optional + Defaults to linewidth if not set. By default None + markerfacecolor : str, optional + Defaults to color above if not set. By default None + """ + super().__init__( + linestyle=linestyle, + linewidth=linewidth, + color=color, + marker=marker, + markersize=markersize, + markeredgecolor=markeredgecolor, + markeredgewidth=markeredgewidth, + markerfacecolor=markerfacecolor, + ) + + +# COMMON CASES + + +def default(marker='.', color='b', linewidth=1, markersize=8) -> RenderControlBcs: + """ + What to draw if no particular preference is expressed. + """ + return RenderControlBcs(linewidth=linewidth, color=color, marker=marker, markersize=markersize) + + +def thin(marker='.', color='b', linewidth=0.3, markersize=5) -> RenderControlBcs: + return RenderControlBcs(color=color, marker=marker, linewidth=linewidth, markersize=markersize)