From 811af739534f688cde7072c4772ceb210fb19073 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Thu, 6 Nov 2025 09:49:50 -0700 Subject: [PATCH 1/7] 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/7] 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): From 741e4a0a26408e3a27f161900f2b5b4f74cf36f0 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Thu, 6 Nov 2025 11:08:49 -0700 Subject: [PATCH 3/7] structure for controlled "show" of visualization image processor figures --- .../ViewAnnotationsImageProcessor.py | 5 +++-- .../ViewHighlightImageProcessor.py | 1 - .../spot_analysis/VisualizationCoordinator.py | 4 ++++ .../AbstractVisualizationImageProcessor.py | 6 +++++ .../image_processor/View3dImageProcessor.py | 3 --- .../ViewCrossSectionImageProcessor.py | 8 +++---- opencsp/common/lib/tool/system_tools.py | 22 +++++++++++++++++++ 7 files changed, 39 insertions(+), 10 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 30b646945..70c7f52dc 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py @@ -120,8 +120,9 @@ def visualize_operable( for fiducials in to_render: fiducials.render_to_figure(self.figure, image, include_label) - # show the visualization - self.figure.view.show(block=False, legend=include_label) + # show the legend + if include_label: + self.figure.figure.legend() return [self.figure] 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 f1dc936d2..530d0a0c1 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py @@ -116,7 +116,6 @@ def visualize_operable( # show the visualization self.prepare_figure_records([self.figure], [new_image.shape]) self.figure.view.imshow(new_image) - self.figure.view.show(block=False) # build the return value cacheable_image = CacheableImage(new_image) diff --git a/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py b/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py index 3c96ee32e..f6a2c1a68 100644 --- a/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py +++ b/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py @@ -11,6 +11,7 @@ import opencsp.common.lib.render_control.RenderControlFigure as rcf import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr import opencsp.common.lib.tool.log_tools as lt +import opencsp.common.lib.tool.system_tools as st class VisualizationCoordinator: @@ -304,6 +305,9 @@ def visualize( processor_visualizations, _visualization_image_no_axes = visualization_processor._visualize_operable( operable, is_last ) + if not st.is_notebook(): + for fig_record in visualization_processor.init_figure_records(): + fig_record.figure.view.show(block=False) # compile all visualizations together into a single operable to be returned if len(processor_visualizations) > 0: 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 954d752ef..7fdf8c772 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py @@ -17,6 +17,7 @@ import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr import opencsp.common.lib.tool.image_tools as it import opencsp.common.lib.tool.log_tools as lt +import opencsp.common.lib.tool.system_tools as st class AbstractVisualizationImageProcessor(AbstractSpotAnalysisImageProcessor, ABC): @@ -390,6 +391,11 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn self._init_figure_records(render_control) new_visualizations, _visualization_image_no_axes = self._visualize_operable(operable, is_last) + # draw the visualizations + if not st.is_notebook(): + for fig_record in self._init_figure_records(): + fig_record.figure.view.show(block=False) + # get the visualization images list visualization_images = copy.copy(operable.visualization_images) if self not in visualization_images: 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 533301fbb..bd3e29258 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py @@ -116,9 +116,6 @@ def visualize_operable( x_mesh, y_mesh = np.meshgrid(x_arr, y_arr) self.view.draw_xyz_surface_customshape(x_mesh, y_mesh, image, self.rcs) - # draw - self.view.show(block=False) - return [self.fig_record] def close_figures(self): 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 859e5e37e..ee15bb8c9 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py @@ -338,10 +338,10 @@ def visualize_operable( graphs_per_plot_cnt += self._draw_null_image_cross_section(operable, cs_loc_cropped, cropped_region) graphs_per_plot_cnt += self._draw_cross_section(np_image, cs_loc, cropped_region) - # draw - for view in self.views: - legend = graphs_per_plot_cnt > 1 - view.show(block=False, legend=legend) + # add the legend + if graphs_per_plot_cnt > 1: + for fig_record in self._figure_records: + fig_record.figure.legend() # explicitly set the y-axis range if self.y_range is not None: diff --git a/opencsp/common/lib/tool/system_tools.py b/opencsp/common/lib/tool/system_tools.py index 6ec34801f..4330484a3 100644 --- a/opencsp/common/lib/tool/system_tools.py +++ b/opencsp/common/lib/tool/system_tools.py @@ -5,6 +5,28 @@ from opencsp import opencsp_settings +def is_notebook() -> bool: + """ + Is this code running in a notebook. From + https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook + + Returns + ------- + bool + True if this code is executing in a Jypter notebook, or False otherwise. + """ + try: + shell = get_ipython().__class__.__name__ # type: ignore + if shell == 'ZMQInteractiveShell': + return True # Jupyter notebook or qtconsole + elif shell == 'TerminalInteractiveShell': + return False # Terminal running IPython + else: + return False # Other type (?) + except NameError: + return False # Probably standard Python interpreter + + def is_solo(): """Determines if this computer is one of the Solo HPC nodes. From 750f602eb229cbefe0f53e6cf2e5f248d857ded3 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Tue, 11 Nov 2025 09:03:57 -0700 Subject: [PATCH 4/7] bad path name in default_settings.ini --- opencsp/default_settings.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencsp/default_settings.ini b/opencsp/default_settings.ini index 4f1d9d696..909cb4ed4 100644 --- a/opencsp/default_settings.ini +++ b/opencsp/default_settings.ini @@ -1,8 +1,8 @@ # These are the default settings for the OpenCSP code base. # # Settings values can be overridden by creating a file in one of the following locations: -# Windows: %USERPROFILE%/.opencsp/opencsp_settings.ini -# Other: ~/.config/opencsp/opencsp_settings.ini +# Windows: %USERPROFILE%/.opencsp/settings/opencsp_settings.ini +# Other: ~/.config/opencsp/settings/opencsp_settings.ini # In addition, the environmental variable OPENCSP_SETTINGS_DIRS, if set, will be # split on semicolon ';' characters and each directory searched for a # 'opencsp_settings.ini' file. From 66f00ac063b65fdbafc6f0ea917d82a4e0cd36a9 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Tue, 11 Nov 2025 10:54:45 -0700 Subject: [PATCH 5/7] showing the visualizations in a managed manner --- .../lib/cv/spot_analysis/VisualizationCoordinator.py | 4 ++-- .../AbstractVisualizationImageProcessor.py | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py b/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py index f6a2c1a68..d2c8dc36b 100644 --- a/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py +++ b/opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py @@ -306,8 +306,8 @@ def visualize( operable, is_last ) if not st.is_notebook(): - for fig_record in visualization_processor.init_figure_records(): - fig_record.figure.view.show(block=False) + for fig_record in visualization_processor._initialized_figure_records: + fig_record.view.show(block=False) # compile all visualizations together into a single operable to be returned if len(processor_visualizations) > 0: 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 7fdf8c772..0e71db15a 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/AbstractVisualizationImageProcessor.py @@ -157,6 +157,8 @@ def __init__( visualization image processor that has its base_image_selector set to 'visualization', and then the value is unset. """ + self._initialized_figure_records: weakref.WeakSet[rcfr.RenderControlFigureRecord] = weakref.WeakSet() + """ The figure records returned from init_figure_records(). """ @property @abstractmethod @@ -300,6 +302,9 @@ def _init_figure_records(self, render_control_fig: rcf.RenderControlFigure) -> l """ self._render_control_fig = weakref.ref(render_control_fig) ret = self.init_figure_records(render_control_fig) + self._initialized_figure_records.clear() + for fig_record in ret: + self._initialized_figure_records.add(fig_record) self.initialized_figure_records = True return ret @@ -393,8 +398,8 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn # draw the visualizations if not st.is_notebook(): - for fig_record in self._init_figure_records(): - fig_record.figure.view.show(block=False) + for fig_record in self._initialized_figure_records: + fig_record.view.show(block=False) # get the visualization images list visualization_images = copy.copy(operable.visualization_images) From 8cff9c79bd8e92fb82f4b8bc690d4906ea736332 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Tue, 11 Nov 2025 10:55:25 -0700 Subject: [PATCH 6/7] add option to use a CacheableImage in View3d imshow() and draw_image() methods --- opencsp/common/lib/render/View3d.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/opencsp/common/lib/render/View3d.py b/opencsp/common/lib/render/View3d.py index dced14a42..52e72653f 100644 --- a/opencsp/common/lib/render/View3d.py +++ b/opencsp/common/lib/render/View3d.py @@ -16,6 +16,7 @@ import scipy.ndimage from typing import TYPE_CHECKING +from opencsp.common.lib.cv.CacheableImage import CacheableImage import opencsp.common.lib.render.axis_3d as ax3d import opencsp.common.lib.render.view_spec as vs import opencsp.common.lib.render_control.RenderControlHeatmap as rcheat @@ -486,6 +487,8 @@ def imshow(self, *args, colorbar=False, **kwargs) -> None: img = args[0] args = list(args) args[0] = load_as_necessary(img) + if isinstance(args[0], CacheableImage): + args[0] = args[0].nparray im = self.axis.imshow(*args, interpolation="none", **kwargs) if self.equal: @@ -497,7 +500,7 @@ def imshow(self, *args, colorbar=False, **kwargs) -> None: def draw_image( self, - path_or_array: str | np.ndarray, + path_or_array_or_cacheable: str | np.ndarray | CacheableImage, xy_location: tuple[float, float] = None, width_height: tuple[float, float] = None, cmap: str | matplotlib.colors.Colormap = None, @@ -512,7 +515,7 @@ def draw_image( Parameters ---------- - path_or_array : str | np.ndarray + path_or_array_or_cacheable : str | np.ndarray | CacheableImage The image to be drawn. xy_location : tuple[float, float], optional The location at which to draw the image, in graph coordinate units. @@ -528,10 +531,12 @@ def draw_image( draw below. A specific zorder can be used by setting this to be an integer. None to use the matplotlib default. Default is True. """ - if isinstance(path_or_array, str): - img = mpimg.imread(path_or_array) + if isinstance(path_or_array_or_cacheable, str): + img = mpimg.imread(path_or_array_or_cacheable) + elif isinstance(path_or_array_or_cacheable, CacheableImage): + img = path_or_array_or_cacheable.nparray else: - img: np.ndarray = path_or_array + img: np.ndarray = path_or_array_or_cacheable imgw, imgh = img.shape[1], img.shape[0] xbnd, ybnd = self.axis.get_xbound(), self.axis.get_ybound() xdraw, ydraw = xbnd, ybnd From 85b20d183753f6f62d84474e0768164cbe7137a0 Mon Sep 17 00:00:00 2001 From: Benjamin Bean Date: Tue, 11 Nov 2025 10:56:02 -0700 Subject: [PATCH 7/7] update ViewCrossSectionImageProcessor to draw the CacheableImage, fix ViewFalseColorImageProcessor to show the image at all --- .../image_processor/ViewCrossSectionImageProcessor.py | 2 +- .../image_processor/ViewFalseColorImageProcessor.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 ee15bb8c9..58ac85e49 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py @@ -328,7 +328,7 @@ def visualize_operable( # Draw the image w/ cross section line overlays i_view = self.views[0] - i_view.draw_image(base_image.nparray, (0, 0), (cropped_width, cropped_height)) + i_view.draw_image(base_image, (0, 0), (cropped_width, cropped_height)) i_view.draw_pq_list([(cs_cropped_x, 0), (cs_cropped_x, cropped_height)], style=vstyle) i_view.draw_pq_list([(0, cs_cropped_y_mlab), (cropped_width, cs_cropped_y_mlab)], style=hstyle) diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewFalseColorImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewFalseColorImageProcessor.py index 8867f84f1..1daa317ad 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewFalseColorImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewFalseColorImageProcessor.py @@ -148,6 +148,10 @@ def visualize_operable( else: ret = [self.apply_mapping_jet(operable, base_image)] + # draw the image + self.prepare_figure_records([self.figure], [ret[0].nparray.shape]) + self.figure.view.imshow(ret[0]) + return ret def close_figures(self):