From 1465fa1b991b8fa1db1731ad4239e57101c9d571 Mon Sep 17 00:00:00 2001 From: bbean Date: Thu, 25 Apr 2024 20:48:48 -0600 Subject: [PATCH] add View3dImageProcessor, add interactive methods to View3d --- contrib/app/SpotAnalysis/PeakFlux.py | 2 +- .../image_processor/View3dImageProcessor.py | 167 +++++++++++++++ .../spot_analysis/image_processor/__init__.py | 2 + opencsp/common/lib/render/View3d.py | 199 ++++++++++++++++-- .../render_control/RenderControlSurface.py | 83 +++++++- 5 files changed, 431 insertions(+), 22 deletions(-) create mode 100644 opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py diff --git a/contrib/app/SpotAnalysis/PeakFlux.py b/contrib/app/SpotAnalysis/PeakFlux.py index f7aa1fb61..dd51b8906 100644 --- a/contrib/app/SpotAnalysis/PeakFlux.py +++ b/contrib/app/SpotAnalysis/PeakFlux.py @@ -1,7 +1,6 @@ import os import re - import opencsp.common.lib.cv.SpotAnalysis as sa from opencsp.common.lib.cv.spot_analysis.SpotAnalysisImagesStream import ImageType import opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperableAttributeParser as saoap @@ -57,6 +56,7 @@ def __init__(self, indir: str, outdir: str, experiment_name: str, settings_path_ NullImageSubtractionImageProcessor(), ConvolutionImageProcessor(kernel="box", diameter=3), BcsLocatorImageProcessor(), + View3dImageProcessor(crop_to_threshold=20, max_dims=(30, 30)), PopulationStatisticsImageProcessor(initial_min=0, initial_max=255), FalseColorImageProcessor(), AnnotationImageProcessor(), diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py new file mode 100644 index 000000000..5361eea07 --- /dev/null +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py @@ -0,0 +1,167 @@ +from typing import Callable + +import cv2 as cv +import matplotlib.backend_bases +import numpy as np + +from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable +from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractSpotAnalysisImageProcessor import ( + AbstractSpotAnalysisImagesProcessor, +) +import opencsp.common.lib.render.figure_management as fm +import opencsp.common.lib.render_control.RenderControlAxis as rca +import opencsp.common.lib.render_control.RenderControlFigure as rcf +import opencsp.common.lib.render_control.RenderControlSurface as rcs +import opencsp.common.lib.tool.file_tools as ft + + +class View3dImageProcessor(AbstractSpotAnalysisImagesProcessor): + def __init__( + self, + label: str | rca.RenderControlAxis = 'Light Intensity', + interactive: bool | Callable[[SpotAnalysisOperable], bool] = False, + max_resolution: tuple[int, int] | None = None, + crop_to_threshold: int | None = None, + ): + """ + Interprets the current image as a 3D surface plot and either displays it, or if interactive it displays the + surface and waits on the next press of the "enter" key. + + Parameters + ---------- + label : str | rca.RenderControlAxis, optional + The label to use for the window title, by default 'Light Intensity' + interactive : bool | Callable[[SpotAnalysisOperable], bool], optional + If True then the spot analysis pipeline is paused until the user presses the "enter" key, by default False + max_resolution : tuple[int, int] | None, optional + Limits the resolution along the x and y axes to the given values. No limit if None. By default None. + crop_to_threshold : int | None, optional + Crops the image on the x and y axis to the first/last value >= the given threshold. None to not crop the + image. Useful when trying to inspect hot spots on images with very concentrated values. By default None. + """ + super().__init__(self.__class__.__name__) + + self.interactive = interactive + self.enter_pressed = False + self.closed = False + self.max_resolution = max_resolution + self.crop_to_threshold = crop_to_threshold + + self.rcf = rcf.RenderControlFigure(tile=False) + if isinstance(label, str): + self.rca = rca.RenderControlAxis(z_label=label) + else: + self.rca = label + self.rcs = rcs.RenderControlSurface(alpha=1.0, color=None, contour='xyz') + + self._init_figure_record() + + def _init_figure_record(self): + self.fig_record = fm.setup_figure_for_3d_data( + self.rcf, + self.rca, + equal=False, + number_in_name=False, + name=self.rca.z_label, + code_tag=f"{__file__}.__init__()", + ) + self.view = self.fig_record.view + self.axes = self.fig_record.figure.gca() + + self.enter_pressed = False + self.closed = False + self.fig_record.figure.canvas.mpl_connect('close_event', self.on_close) + self.fig_record.figure.canvas.mpl_connect('key_release_event', self.on_key_release) + + def on_key_release(self, event: matplotlib.backend_bases.KeyEvent): + if event.key == "enter" or event.key == "return": + self.enter_pressed = True + + def on_close(self, event: matplotlib.backend_bases.CloseEvent): + self.closed = True + + def _get_range_for_threshold(self, image: np.ndarray, threshold: int, axis: int) -> tuple[int, int]: + """ + Get the start (inclusive) and end (exclusive) range for which the given image is >= the given threshold. + + Parameters + ---------- + image : np.ndarray + The 2d numpy array to be searched. + threshold : int + The cutoff value that the returned region should have pixels greater than. + axis : int + 0 for rows (y), 1 for columns (x) + + Returns + ------- + start, end: tuple[int, int] + The start (inclusive) and end (exclusive) matching range. Returns the full image size if there are no + matching pixels. + """ + # If we want the maximum value for all rows, then we need to accumulate across columns. + # If we want the maximum value for all columns, then we need to accumulate across rows. + perpendicular_axis = 0 if axis == 1 else 1 + + # find matches + img_matching = np.max(image, perpendicular_axis) >= self.crop_to_threshold + match_idxs = np.argwhere(img_matching) + + # find the range + if match_idxs.size > 0: + start, end = match_idxs[0][0], match_idxs[-1][0] + 1 + else: + start, end = 0, image.shape[axis] + + return start, end + + def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAnalysisOperable]: + image = operable.primary_image.nparray + image_path = ( + operable.primary_image_source_path + or operable.primary_image.source_path + or operable.primary_image.cache_path + ) + + # check if the view has been closed + if self.closed: + # UI design decision: it feels more natural to me (Ben) for the plot to not be shown again when it has + # been closed instead of being reinitialized and popping back up. + return [operable] + # self._init_figure_record() + + # reduce data based on threshold + if self.crop_to_threshold is not None: + x_start, x_end = self._get_range_for_threshold(image, self.crop_to_threshold, 1) + y_start, y_end = self._get_range_for_threshold(image, self.crop_to_threshold, 0) + image = image[y_start:y_end, x_start:x_end] + + # reduce data based on max_resolution + if self.max_resolution is not None: + width = np.min([image.shape[1], self.max_resolution[0]]) + height = np.min([image.shape[0], self.max_resolution[1]]) + image = cv.resize(image, (height, width), interpolation=cv.INTER_AREA) + + # Clear the previous data + self.fig_record.view.clear() + + # Update the title + _, image_name, _ = ft.path_components(image_path) + self.fig_record.title = image_name + + # Draw the new data + self.view.draw_xyz_surface(image, self.rcs) + + # draw + self.view.show(block=False) + + # wait for the user to press enter + wait_for_enter_key = self.interactive if isinstance(self.interactive, bool) else self.interactive(operable) + if wait_for_enter_key: + self.enter_pressed = False + while True: + if self.enter_pressed or self.closed: + break + self.fig_record.figure.waitforbuttonpress(0.1) + + return [operable] 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 339d1f341..351da21b8 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/__init__.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/__init__.py @@ -11,6 +11,7 @@ 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.View3dImageProcessor import View3dImageProcessor from opencsp.common.lib.cv.spot_analysis.image_processor.EchoImageProcessor import EchoImageProcessor from opencsp.common.lib.cv.spot_analysis.image_processor.ExposureDetectionImageProcessor import ( ExposureDetectionImageProcessor, @@ -43,4 +44,5 @@ 'NullImageSubtractionImageProcessor', 'PopulationStatisticsImageProcessor', 'SupportingImagesCollectorImageProcessor', + 'View3dImageProcessor', ] diff --git a/opencsp/common/lib/render/View3d.py b/opencsp/common/lib/render/View3d.py index 5a6349688..68e329680 100644 --- a/opencsp/common/lib/render/View3d.py +++ b/opencsp/common/lib/render/View3d.py @@ -1,15 +1,14 @@ -""" - - -""" +import os +import time +from typing import Callable -import numpy as np +import matplotlib.backend_bases as backb import matplotlib.image as mpimg import matplotlib.pyplot as plt from matplotlib.axes import Axes from matplotlib.figure import Figure -from numpy import ndarray -import os +from mpl_toolkits.mplot3d.axes3d import Axes3D +import numpy as np from PIL import Image from opencsp.common.lib.geometry.Pxyz import Pxyz @@ -18,7 +17,6 @@ import opencsp.common.lib.render.view_spec as vs import opencsp.common.lib.render_control.RenderControlPointSeq as rcps import opencsp.common.lib.render_control.RenderControlText as rctxt -import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.log_tools as lt import opencsp.common.lib.render.lib.AbstractPlotHandler as aph from opencsp.common.lib.render_control.RenderControlSurface import RenderControlSurface @@ -57,6 +55,10 @@ def __init__( equal = equal if equal != None else parent.equal equal = equal if equal != None else True + # interactive graphing values + self._callbacks: dict[str, int] = {} + + # other values self._figure = figure self.axis = axis self.view_spec = view_spec @@ -75,6 +77,17 @@ def view(self, val: Figure): self._figure = val self._register_plot(val) + # CLEAR + + def clear(self): + """ + Clears the old plot data without deleting the window, listeners, or orientation. Useful for updating a plot + interactively. + """ + # Clear the previous data + # self.fig_record.figure.clear(keep_observers=True) <-- not doing this, clears everything except window + self.axis.clear() + # ACCESS def is_3d(self) -> bool: @@ -203,6 +216,23 @@ def show( # Draw. plt.show(block=block) + # INTERACTION + + def register_event_handler(self, event_type: str, callback: Callable[[backb.Event], None]): + # deregister the previous callback + if event_type in self._callbacks: + self.view.figure.canvas.mpl_disconnect(self._callbacks[event_type]) + del self._callbacks[event_type] + + # register the new callback + self._callbacks[event_type] = self.view.figure.canvas.mpl_connect(event_type, callback) + + def on_key_press(self, event: backb.KeyEvent, draw_func: Callable): + if event.key == 'f5': + lt.info(time.time()) + self.clear() + draw_func() + # WRITE def show_and_save_multi_axis_limits(self, output_dir, output_figure_body, limits_list, grid=True): @@ -731,24 +761,153 @@ def draw_Vxyz(self, V: Vxyz, close=False, style=None, label=None) -> None: self.draw_xyz_list(V.data.T, close, style, label) # TODO tjlarki: only implemented for 3d views, should extend - def draw_xyz_surface( - self, - x_mesh: ndarray, - y_mesh: ndarray, - z_mesh: ndarray, - surface_style: RenderControlSurface = RenderControlSurface(), + def _draw_xyz_surface_customshape( + self, x_mesh: np.ndarray, y_mesh: np.ndarray, z_mesh: np.ndarray, surface_style: RenderControlSurface = None, **kwargs ): + if surface_style is None: + surface_style = RenderControlSurface() + if self.view_spec['type'] == '3d': - self.axis.plot_surface( - x_mesh.flatten(), - y_mesh.flatten(), - z_mesh.flatten(), + axis: Axes3D = self.axis + + # Draw the surface + axis.plot_surface( + x_mesh, + y_mesh, + z_mesh, color=surface_style.color, + cmap=surface_style.color_map, + edgecolor=surface_style.edgecolor, + linewidth=surface_style.linewidth, alpha=surface_style.alpha, + antialiased=surface_style.antialiased, + **kwargs, ) + # Draw the contour plots + if surface_style.contour: + for ax, mesh in [('x', x_mesh), ('y', y_mesh), ('z', z_mesh)]: + if surface_style.contours[ax]: + mmin, mmax = np.min(mesh), np.max(mesh) + height = mmax - mmin + + # placement is determined by graph's orientation + lower_offset = mmin - np.max([height / 3, 1]) + upper_offset = mmax + np.max([height / 3, 1]) + offset = lower_offset + elev, azim = axis.elev, axis.azim # angle 0-360 + if ax == 'z': + if elev < 0 or elev > 180: + offset = upper_offset + elif ax == 'x': + if azim > 90 and azim < 270: + offset = upper_offset + elif ax == 'y': + if azim < 0 or azim > 180: + offset = upper_offset + + # axis-specific arguments + contourf_kwargs = {} + if ax != 'z': + contourf_kwargs = {'zdir': ax} + + axis.contourf( + x_mesh, + y_mesh, + z_mesh, + offset=offset, + cmap=surface_style.contour_color_map, + alpha=surface_style.contour_alpha, + **contourf_kwargs, + ) + + # Draw the title + if surface_style.draw_title: + if self.parent is not None: + axis.set_title(self.parent.title) + + def draw_xyz_surface_customshape( + self, x_mesh: np.ndarray, y_mesh: np.ndarray, z_mesh: np.ndarray, surface_style: RenderControlSurface = None, **kwargs + ): + draw_callback = lambda: self._draw_xyz_surface_customshape(x_mesh, y_mesh, z_mesh, surface_style, **kwargs) + self.register_event_handler('key_release_event', lambda event: self.on_key_press(event, draw_callback)) + draw_callback() + + def draw_xyz_surface(self, surface: np.ndarray, surface_style: RenderControlSurface = None, **kwargs): + """ + Draw a 3D plot for the given z_mesh surface. + + Example from https://matplotlib.org/stable/plot_types/3D/surface3d_simple.html#sphx-glr-plot-types-3d-surface3d-simple-py + + # Make data + X = np.arange(-5, 5) + Y = np.arange(-5, 5) + X_mesh, Y_mesh = np.meshgrid(X, Y) + R = np.sqrt(X_mesh**2 + Y_mesh**2) + Z = np.sin(R) + + print(X) + # array([[-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4], + # [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4]]) + print(np.round(Z, 2)) + # array([[ 0.71, 0.12, -0.44, -0.78, -0.93, -0.96, -0.93, -0.78, -0.44, 0.12], + # [ 0.12, -0.59, -0.96, -0.97, -0.83, -0.76, -0.83, -0.97, -0.96, -0.59], + # [-0.44, -0.96, -0.89, -0.45, -0.02, 0.14, -0.02, -0.45, -0.89, -0.96], + # [-0.78, -0.97, -0.45, 0.31, 0.79, 0.91, 0.79, 0.31, -0.45, -0.97], + # [-0.93, -0.83, -0.02, 0.79, 0.99, 0.84, 0.99, 0.79, -0.02, -0.83], + # [-0.96, -0.76, 0.14, 0.91, 0.84, 0, 0.84, 0.91, 0.14, -0.76], + # [-0.93, -0.83, -0.02, 0.79, 0.99, 0.84, 0.99, 0.79, -0.02, -0.83], + # [-0.78, -0.97, -0.45, 0.31, 0.79, 0.91, 0.79, 0.31, -0.45, -0.97], + # [-0.44, -0.96, -0.89, -0.45, -0.02, 0.14, -0.02, -0.45, -0.89, -0.96], + # [ 0.12, -0.59, -0.96, -0.97, -0.83, -0.76, -0.83, -0.97, -0.96, -0.59]]) + + # Plot the surface + rc_fig = rcf.RenderControlFigure(tile=False) + rc_axis = rca.RenderControlAxis(z_label='Light Intensity') + rc_surf = rcs.RenderControlSurface() + fig_record = fm.setup_figure_for_3d_data(rc_fig, rc_axis, equal=False, name='Light Intensity', code_tag=f"{__file__}") + view = fig_record.view + view.draw_xyz_surface(Z) + plt.show() + + Parameters + ---------- + surface : ndarray + 2D array of surface values. + surface_style : RenderControlSurface, optional + How to style the surface, by default RenderControlSurface() + """ + # validate the inputs + conformed_surface = surface + if conformed_surface.ndim > 2: + conformed_surface = np.squeeze(conformed_surface) + if conformed_surface.ndim != 2: + lt.error_and_raise( + ValueError, + "Error in View3d.draw_xyz_surface(): " + + f"given surface should have 2 dimensions, but shape is {conformed_surface.shape}", + ) + + # generate the x_mesh and y_mesh + width = conformed_surface.shape[1] + height = conformed_surface.shape[0] + x_arr = np.arange(0, width) + y_arr = np.arange(0, height) + x_mesh, y_mesh = np.meshgrid(x_arr, y_arr) + + # draw! + self.draw_xyz_surface_customshape(x_mesh, y_mesh, conformed_surface, surface_style, **kwargs) + def draw_xyz_trisurface( - self, x: ndarray, y: ndarray, z: ndarray, surface_style: RenderControlSurface = None, **kwargs + self, x: np.ndarray, y: np.ndarray, z: np.ndarray, surface_style: RenderControlSurface = None, **kwargs ): if surface_style == None: surface_style = RenderControlSurface() @@ -757,7 +916,7 @@ def draw_xyz_trisurface( # TODO tjlarki: currently unused # TODO tjlarki: might want to remove, this is a very slow function - def quiver(self, X: ndarray, Y: ndarray, Z: ndarray, U: ndarray, V: ndarray, W: ndarray, length: float = 0) -> None: + def quiver(self, X: np.ndarray, Y: np.ndarray, Z: np.ndarray, U: np.ndarray, V: np.ndarray, W: np.ndarray, length: float = 0) -> None: self.axis.quiver(X, Y, Z, U, V, W, length=0) # PQ PLOTTING diff --git a/opencsp/common/lib/render_control/RenderControlSurface.py b/opencsp/common/lib/render_control/RenderControlSurface.py index c606bb85c..a85d4cbbb 100644 --- a/opencsp/common/lib/render_control/RenderControlSurface.py +++ b/opencsp/common/lib/render_control/RenderControlSurface.py @@ -1,4 +1,85 @@ +import opencsp.common.lib.render.color as cl +import opencsp.common.lib.tool.log_tools as lt + + class RenderControlSurface: - def __init__(self, alpha: float = 0.25, color: str = "silver") -> None: + def __init__( + self, + draw_title=True, + color: str | None = None, + color_map: str | None = "viridis", + alpha: float = 1.0, + edgecolor='black', + linewidth=0.3, + contour: None | bool | str = True, + contour_color_map: str | None = None, + ) -> None: + """ + Render control information for how to style surface plots (see View3d function plot_surface and plot_trisurface). + + Parameters + ---------- + draw_title : bool, optional + If True then the title will be drawn on graph, default is True + color : str | None, optional + The color of the plot if not using a color map, example "silver", by default _PlotColors().blue + color_map : str | None, optional + The color map of the plot to help decern different plot values. See + https://matplotlib.org/stable/gallery/color/colormap_reference.html for common options. By default "viridis". + alpha : float, optional + The opacity of the plot between 0 (fully transparent) and 1 (fully opaque), by default 1.0 + edgecolor: str, optional + The color to use for the lines between the faces, default is 'black' + linewidth: float, optional + The width of the edge lines between the faces in points, default is 0.3 + contour : None | bool | str, optional + If False or None, then don't include a contour plot alongside the 3d plot. If True, then draw a 2D contour + plot below the 3D surface plot (on z-axis). If a string, can be any combination of 'x', 'y', and 'z'. + Default is True. + contour_color_map : str, optional + If set, then this determines the color map for the contour. If None, then use the same color map for the + contour as for the surface. If None and the color_map argument is also None, then we should create a custom + color map based on the given color. Default is None. + """ + self.draw_title = draw_title self.alpha = alpha + self.antialiased = False if self.alpha > 0.99 else None self.color = color + self.color_map = color_map + self.edgecolor = edgecolor + self.linewidth = linewidth + self.contour = False + self.contour_color_map = contour_color_map + self.contour_alpha = 0.7 + self.contours = {'x': False, 'y': False, 'z': False} + + # make sure one of "color" or "color_map" is set + if self.color is None: + if self.color_map is None: + self.color = cl._PlotColors().blue + + # determine the type of contour to be drawn + if contour is None or contour == False: + self.contour = False + elif contour == True: + self.contours['x'] = True + elif isinstance(contour, str): + self.contour = True + for axis in contour: + axis = axis.replace('p', 'x').replace('q', 'y').replace('r', 'z') + if axis not in self.contours: + lt.error_and_raise( + ValueError, + "Error in RenderControlSurface(): " + + f"unknown axis in {contour=} (only 'x', 'y', 'z', 'p', 'q', or 'r' are allowed)", + ) + self.contours[axis] = True + + # set the "contour_color_map" based on "color_map" or "color" + if self.contour: + if self.contour_color_map is None: + if self.color_map is not None: + self.contour_color_map = self.color_map + else: + # TODO create a custom color map based on the color + raise NotImplementedError()