Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,8 @@ def visualize_operable(
to_render += filter(self._annotations_match_filter, operable.annotations)

# initialize the figure
self.figure.clear()
tc = lambda x, y: operable.transform_coordinates(p2.Pxy((x, y)))[1].astuple()
(height, width), _ = it.dims_and_nchannels(image)
img_xy, img_xy2 = tc(0, 0), tc(width, height)
self.figure.view.draw_image(
base_image.nparray, img_xy, (img_xy2[0] - img_xy[0], img_xy2[1] - img_xy[1]), invert_ylim=True
)
self.prepare_figure_records([self.figure], [image.shape])
self.figure.view.imshow(image)

# render
include_label = len(to_render) > 1
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,8 @@ def visualize_operable(
new_image[white_selector] = highlight_color.rgb_255()

# show the visualization
self.figure.clear()
tc = lambda x, y: operable.transform_coordinates(p2.Pxy((x, y)))[1].astuple()
(height, width), _ = it.dims_and_nchannels(new_image)
img_xy, img_xy2 = tc(0, 0), tc(width, height)
self.figure.view.draw_image(
base_image.nparray, img_xy, (img_xy2[0] - img_xy[0], img_xy2[1] - img_xy[1]), invert_ylim=True
)
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
Expand Down
7 changes: 7 additions & 0 deletions opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,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
Expand Down Expand Up @@ -168,6 +174,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,
Expand Down
15 changes: 13 additions & 2 deletions opencsp/common/lib/cv/spot_analysis/VisualizationCoordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import copy
import dataclasses
from typing import Callable
import weakref

import numpy as np

Expand All @@ -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
Expand Down Expand Up @@ -47,6 +49,7 @@ class AbstractVisualizationImageProcessor(AbstractSpotAnalysisImageProcessor, AB
- init_figure_records()*
- process()
- _execute()
- _get_image_for_visualizing()
- visualize_operable()*
- close_figures()*

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -377,11 +399,88 @@ 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],
figure_shapes: list[list[int | float] | tuple | None] = (),
):
"""
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:
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.

Expand Down Expand Up @@ -421,16 +520,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
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,7 @@ def visualize_operable(
cs_loc_cropped = tuple([cs_cropped_x, cs_cropped_y])

# Clear the previous plot
for fig_record in self.fig_records:
fig_record.clear()
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):
Expand Down
38 changes: 35 additions & 3 deletions opencsp/common/lib/render/figure_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down