From 811af739534f688cde7072c4772ceb210fb19073 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Thu, 6 Nov 2025 09:49:50 -0700 Subject: [PATCH 1/2] add capability for visualization image processors to receive the undressed version of an earlier visualization --- .../ViewAnnotationsImageProcessor.py | 2 +- .../ViewHighlightImageProcessor.py | 2 +- .../cv/spot_analysis/SpotAnalysisOperable.py | 7 ++ .../spot_analysis/VisualizationCoordinator.py | 15 ++- .../AbstractVisualizationImageProcessor.py | 100 ++++++++++++++++-- .../image_processor/View3dImageProcessor.py | 2 +- .../ViewCrossSectionImageProcessor.py | 3 +- .../common/lib/render/figure_management.py | 38 ++++++- 8 files changed, 151 insertions(+), 18 deletions(-) diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py b/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py index 4ba7294b4..80e1d8b42 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py @@ -112,7 +112,7 @@ def visualize_operable( to_render += filter(self._annotations_match_filter, operable.annotations) # initialize the figure - self.figure.clear() + self.prepare_figure_records([self.figure]) self.figure.view.imshow(image) # render diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py b/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py index bfb953920..0aaa4b30e 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py @@ -114,7 +114,7 @@ def visualize_operable( new_image[white_selector] = highlight_color.rgb_255() # show the visualization - self.figure.clear() + self.prepare_figure_records([self.figure]) self.figure.view.imshow(new_image) self.figure.view.show(block=False) diff --git a/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py b/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py index 8d070dd55..7fd53ae4d 100644 --- a/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py +++ b/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py @@ -61,6 +61,12 @@ class SpotAnalysisOperable: algorithm_images supporting_images """ + _visualization_image_no_axes: CacheableImage = None + """ + The visualization image from the last AbstractVisualizationImageProcessor. + This value is used by the next AbstractVisualizationImageProcessor that has + its base_image_selector set to 'visualization', and then the value is unset. + """ algorithm_images: dict["AbstractSpotAnalysisImageProcessor", list[CacheableImage]] = field(default_factory=dict) """ The images produced by the image processors to explain how certain values @@ -149,6 +155,7 @@ def __post_init__(self): primary_image_source_path, supporting_images, self.visualization_images, + self._visualization_image_no_axes, self.algorithm_images, self.previous_operables, self.given_fiducials, diff --git a/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py b/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py index dae3ee81b..3c96ee32e 100644 --- a/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py +++ b/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py @@ -204,6 +204,13 @@ def initialize_vis_processors(self, operable: SpotAnalysisOperable): # register this figure for coordinated management self.figures.append(weakref.ref(fig_record)) + # set up no_axes dependencies + previous_processor = self.visualization_processors[0] + for processor in self.visualization_processors[1:]: + if processor.base_image_selector == 'visualization': + previous_processor._include_visualization_image_no_axes = True + previous_processor = processor + def _get_figures(self) -> list[rcfr.RenderControlFigureRecord]: """ Get strong references to the figure_records from the registered @@ -294,7 +301,9 @@ def visualize( self.initialize_vis_processors(operable) # render the visualization image processor - processor_visualizations = visualization_processor._visualize_operable(operable, is_last) + processor_visualizations, _visualization_image_no_axes = visualization_processor._visualize_operable( + operable, is_last + ) # compile all visualizations together into a single operable to be returned if len(processor_visualizations) > 0: @@ -309,7 +318,9 @@ def visualize( all_vis_images[visualization_processor] += processor_visualizations # update the operable - operable = dataclasses.replace(operable, visualization_images=all_vis_images) + operable = dataclasses.replace( + operable, visualization_images=all_vis_images, _visualization_image_no_axes=_visualization_image_no_axes + ) else: # update the operable operable = dataclasses.replace(operable) diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py index 01030e035..2b6a9ea57 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py @@ -2,6 +2,7 @@ import copy import dataclasses from typing import Callable +import weakref import numpy as np @@ -11,6 +12,7 @@ from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractSpotAnalysisImageProcessor import ( AbstractSpotAnalysisImageProcessor, ) +import opencsp.common.lib.render.figure_management as fm import opencsp.common.lib.render_control.RenderControlFigure as rcf import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr import opencsp.common.lib.tool.image_tools as it @@ -47,6 +49,7 @@ class AbstractVisualizationImageProcessor(AbstractSpotAnalysisImageProcessor, AB - init_figure_records()* - process() - _execute() + - _get_image_for_visualizing() - visualize_operable()* - close_figures()* @@ -141,6 +144,18 @@ def __init__( """ self.initialized_figure_records = False """ True if init_figure_records() has been called, False otherwise. """ + self._render_control_fig: weakref.ref[rcf.RenderControlFigure] = None + """ + The figure control used in init_figure_records(). Managed as a weak + reference so that this reference doesn't complicating garbage collection. + """ + self._include_visualization_image_no_axes: bool = False + """ + If True, then the _visualization_image_no_axes image is set on the + returned SpotAnalysisOperable. This value is used by the next + visualization image processor that has its base_image_selector set to + 'visualization', and then the value is unset. + """ @property @abstractmethod @@ -182,6 +197,9 @@ def _get_image_for_visualizing(self, operable: SpotAnalysisOperable) -> Cacheabl return operable.primary_image elif isinstance(self.base_image_selector, str): if self.base_image_selector.lower() == 'visualization': + if operable._visualization_image_no_axes is not None: + print(self.name) + return operable._visualization_image_no_axes return list(operable.visualization_images.values())[-1][0] elif self.base_image_selector.lower() == 'algorithm': return list(operable.algorithm_images.values())[-1][0] @@ -231,7 +249,7 @@ def visualize_operable( ------- visualizations: list[CacheableImage|rcfr.RenderControlFigureRecord] Visualizations from this image processor as cacheable images or as - figure records. Empty list if there aren't any. + figure records. Figure records preferred. Empty list if there aren't any. """ pass @@ -279,6 +297,7 @@ def _init_figure_records(self, render_control_fig: rcf.RenderControlFigure) -> l list[rcfr.RenderControlFigureRecord] The list of newly created visualization windows. """ + self._render_control_fig = weakref.ref(render_control_fig) ret = self.init_figure_records(render_control_fig) self.initialized_figure_records = True return ret @@ -352,6 +371,9 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn # ChatGPT 4o-mini assisted with generating this docstring, reviewed by a human ret: SpotAnalysisOperable = None + # Remember the _visualization_image_no_axes value so that it can be unset if unchanged. + previs_visualization_image_no_axes = operable._visualization_image_no_axes + if self.has_visualization_coordinator: # Visualize the operable and block (if interactive). op_with_vis = self.visualization_coordinator.visualize(self, operable, is_last) @@ -366,7 +388,7 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn # Create the figure to plot to render_control = self.default_render_control_figure_for_operable(operable) self._init_figure_records(render_control) - new_visualizations = self._visualize_operable(operable, is_last) + new_visualizations, _visualization_image_no_axes = self._visualize_operable(operable, is_last) # get the visualization images list visualization_images = copy.copy(operable.visualization_images) @@ -377,11 +399,56 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn visualization_images[self] += new_visualizations # update the return value - ret = dataclasses.replace(operable, visualization_images=visualization_images) + ret = dataclasses.replace( + operable, + visualization_images=visualization_images, + _visualization_image_no_axes=_visualization_image_no_axes, + ) + + # Unset the _visualization_image_no_axes if unchanged. + # We do this because that value is intended to be used for the next processor only. + if ret._visualization_image_no_axes == previs_visualization_image_no_axes: + ret = dataclasses.replace(ret, _visualization_image_no_axes=None) return [ret] - def _visualize_operable(self, operable: SpotAnalysisOperable, is_last: bool) -> list[CacheableImage]: + def prepare_figure_records( + self, + figure_records: list[rcfr.RenderControlFigureRecord] + ): + """ + Clears the figure records and sets the layout margins to "tight". + + Parameters + ---------- + figure_records : list[rcfr.RenderControlFigureRecord] + The figure records to be prepared. + """ + for fig_record in figure_records: + fig_record.figure.tight_layout() + fig_record.clear() + + def _hide_vis_window_dressings(self, fig_record: rcfr.RenderControlFigureRecord): + # hide all the window dressings + if (render_control_fig := self._render_control_fig()) is not None: + fm.hide_axes(fig_record, render_control_fig, force_hide=True) + old_title = fig_record.title + fig_record.title = "" + fig_record.figure.subplots_adjust(left=0, right=1, top=1, bottom=0, wspace=0, hspace=0) + + return { "old_title": old_title } + + def _apply_window_dressings(self, fig_record: rcfr.RenderControlFigureRecord, window_dressings: dict[str, any]): + # re-apply all the window dressings + fig_record.figure.tight_layout() + if (render_control_fig := self._render_control_fig()) is not None: + fm.show_axes(fig_record, render_control_fig) + old_title: str = window_dressings["old_title"] + fig_record.title = old_title + + def _visualize_operable( + self, operable: SpotAnalysisOperable, is_last: bool + ) -> tuple[list[CacheableImage], CacheableImage | None]: """ Calls :py:meth:`visualize_operable` and collects the visualziation images. @@ -421,16 +488,33 @@ def _visualize_operable(self, operable: SpotAnalysisOperable, is_last: bool) -> # build the list of visualization images all_vis_images: list[CacheableImage] = [] - for cacheable_or_figure_rec in visualizations: + _visualization_image_no_axes: CacheableImage = None + for i, cacheable_or_figure_rec in enumerate(visualizations): if isinstance(cacheable_or_figure_rec, CacheableImage): - all_vis_images.append(cacheable_or_figure_rec) + cacheable = cacheable_or_figure_rec + + all_vis_images.append(cacheable) else: + fig_record = cacheable_or_figure_rec + # get the figure as an numpy array, using the standard 8 inches figure height - np_image = cacheable_or_figure_rec.to_array(8.0) + np_image = fig_record.to_array(8.0) + + # assign the figure as the latest visualization + if self._include_visualization_image_no_axes: + if i == 0: + # hide window dressings + window_dressings = self._hide_vis_window_dressings(fig_record) + + # render the undressed visualization + _visualization_image_no_axes = CacheableImage.from_single_source(fig_record.to_array(8.0)) + + # re-apply the window dressings + self._apply_window_dressings(fig_record, window_dressings) # add the image cacheable_image = CacheableImage(np_image) all_vis_images.append(cacheable_image) - return all_vis_images + return all_vis_images, _visualization_image_no_axes diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py index 5eeeffa69..533301fbb 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py @@ -100,7 +100,7 @@ def visualize_operable( image = cv.resize(image, (height, width), interpolation=cv.INTER_AREA) # Clear the previous data - self.fig_record.clear() + self.prepare_figure_records([self.fig_record]) # Update the title self.fig_record.title = operable.best_primary_nameext diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py index 9d78754e7..5cb09e747 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py @@ -305,8 +305,7 @@ def visualize_operable( cs_cropped_y_mlab = cropped_height - cs_cropped_y # Clear the previous plot - for fig_record in self.fig_records: - fig_record.clear() + self.prepare_figure_records(self.fig_records) # Update the title for plot_title_prefix, fig_record in zip(self.plot_titles, self.fig_records): diff --git a/opencsp/common/lib/render/figure_management.py b/opencsp/common/lib/render/figure_management.py index 22f3a96bd..ae00de4d4 100644 --- a/opencsp/common/lib/render/figure_management.py +++ b/opencsp/common/lib/render/figure_management.py @@ -310,7 +310,7 @@ def _setup_figure( return fig_record -def hide_axes(fig_record: RenderControlFigureRecord, figure_control: RenderControlFigure): +def hide_axes(fig_record: RenderControlFigureRecord, figure_control: RenderControlFigure, force_hide: bool = False): """ Hides the axes for the plot if axis_control.draw_axes is False. Hides the whitespace around the plot if figure_control.draw_whitespace_padding is @@ -322,20 +322,52 @@ def hide_axes(fig_record: RenderControlFigureRecord, figure_control: RenderContr The figure record to be modified. figure_control : RenderControlFigure The control that manages if axes and whitespace are drawn. + force_hide : bool, optional + True to hide the axes and whitespace regardless of the axis_control + and figure_control settings. Default is False. """ axis_control = fig_record.axis_control # hide the axes - if not axis_control.draw_axes: + if (not axis_control.draw_axes) or (force_hide): fig_record.axis.axis('off') fig_record.axis.axes.get_xaxis().set_visible(False) fig_record.axis.axes.get_yaxis().set_visible(False) # hide the whitespace - if not figure_control.draw_whitespace_padding: + if (not figure_control.draw_whitespace_padding) or (force_hide): fig_record.figure.tight_layout(pad=0) +def show_axes(fig_record: RenderControlFigureRecord, figure_control: RenderControlFigure, force_show: bool = False): + """ + Shows the axes for the plot if axis_control.draw_axes is True. Shows the + whitespace around the plot if figure_control.draw_whitespace_padding is + True. + + Parameters + ---------- + fig_record : RenderControlFigureRecord + The figure record to be modified. + figure_control : RenderControlFigure + The control that manages if axes and whitespace are drawn. + force_show : bool, optional + True to show the axes and whitespace regardless of the axis_control + and figure_control settings. Default is False. + """ + axis_control = fig_record.axis_control + + # show the axes + if axis_control.draw_axes or force_show: + fig_record.axis.axis('on') + fig_record.axis.axes.get_xaxis().set_visible(True) + fig_record.axis.axes.get_yaxis().set_visible(True) + + # show the whitespace + if figure_control.draw_whitespace_padding or force_show: + fig_record.figure.tight_layout() + + def setup_figure( figure_control: RenderControlFigure, axis_control=None, From 796c3d80ff07f26531190a6d3d64af71bc0cccb6 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Thu, 6 Nov 2025 09:51:16 -0700 Subject: [PATCH 2/2] clear the whitespace around figures --- .../ViewAnnotationsImageProcessor.py | 2 +- .../ViewHighlightImageProcessor.py | 2 +- .../AbstractVisualizationImageProcessor.py | 38 +++++++++++++++++-- .../ViewCrossSectionImageProcessor.py | 2 +- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py b/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py index 80e1d8b42..30b646945 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py @@ -112,7 +112,7 @@ def visualize_operable( to_render += filter(self._annotations_match_filter, operable.annotations) # initialize the figure - self.prepare_figure_records([self.figure]) + self.prepare_figure_records([self.figure], [image.shape]) self.figure.view.imshow(image) # render diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py b/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py index 0aaa4b30e..f1dc936d2 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py @@ -114,7 +114,7 @@ def visualize_operable( new_image[white_selector] = highlight_color.rgb_255() # show the visualization - self.prepare_figure_records([self.figure]) + self.prepare_figure_records([self.figure], [new_image.shape]) self.figure.view.imshow(new_image) self.figure.view.show(block=False) diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py index 2b6a9ea57..954d752ef 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py @@ -414,20 +414,52 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn def prepare_figure_records( self, - figure_records: list[rcfr.RenderControlFigureRecord] + figure_records: list[rcfr.RenderControlFigureRecord], + figure_shapes: list[list[int | float] | tuple | None] = (), ): """ - Clears the figure records and sets the layout margins to "tight". + Clears the figure records, sets the layout margins to "tight", and + sets the figure record shape. Parameters ---------- figure_records : list[rcfr.RenderControlFigureRecord] The figure records to be prepared. + figure_shapes : list[list[int | float] | tuple | None], optional + The shapes of the figure records. This can either be the desired + height/width of the figure in inches (if < 50), or the number of + rows/columns in the figure (if > 50). The figure will be resized so + that the width-to-height ratio matches this value. """ for fig_record in figure_records: fig_record.figure.tight_layout() fig_record.clear() + for i in range(min(len(figure_records), len(figure_shapes))): + fig_record, fig_shape = figure_records[i], figure_shapes[i] + if fig_shape is None: + continue + if len(fig_shape) < 2: + lt.error_and_raise( + ValueError, + f"In AbstractVisualizationImageProcessor.prepare_figure_records ({self.name}):" + + f"expected figure shape to have at least two dimensions, but {figure_shapes[i]=}", + ) + curr_height = fig_record.figure.get_size_inches()[1] + + if fig_shape[0] >= 50 or fig_shape[1] >= 50: + # Assume that fig_shape is pixel dimensions with + # rows (0) and columns (1), such as from array.shape + nrows, ncols = fig_shape[0], fig_shape[1] + width_to_height = ncols / nrows + fig_record.figure.set_size_inches(width_to_height * curr_height, curr_height) + + else: + # Assume the the fig_shape is the height/width in inches. + height, width = fig_shape[0], fig_shape[1] + width_to_height = width / height + fig_record.figure.set_size_inches(width_to_height * curr_height, curr_height) + def _hide_vis_window_dressings(self, fig_record: rcfr.RenderControlFigureRecord): # hide all the window dressings if (render_control_fig := self._render_control_fig()) is not None: @@ -436,7 +468,7 @@ def _hide_vis_window_dressings(self, fig_record: rcfr.RenderControlFigureRecord) fig_record.title = "" fig_record.figure.subplots_adjust(left=0, right=1, top=1, bottom=0, wspace=0, hspace=0) - return { "old_title": old_title } + return {"old_title": old_title} def _apply_window_dressings(self, fig_record: rcfr.RenderControlFigureRecord, window_dressings: dict[str, any]): # re-apply all the window dressings diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py index 5cb09e747..859e5e37e 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py @@ -305,7 +305,7 @@ def visualize_operable( cs_cropped_y_mlab = cropped_height - cs_cropped_y # Clear the previous plot - self.prepare_figure_records(self.fig_records) + self.prepare_figure_records(self.fig_records, [base_image.nparray.shape, None, None]) # Update the title for plot_title_prefix, fig_record in zip(self.plot_titles, self.fig_records):