Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
811af73
add capability for visualization image processors to receive the undr…
bbean23 Nov 6, 2025
796c3d8
clear the whitespace around figures
bbean23 Nov 6, 2025
741e4a0
structure for controlled "show" of visualization image processor figures
bbean23 Nov 6, 2025
8d76755
Merge branch 'develop' into 302-code-bug-spot-analysis-visualizations…
jehsharp Nov 10, 2025
750f602
bad path name in default_settings.ini
bbean23 Nov 11, 2025
82f15ba
Merge branch '305-code-bug-visualizations-are-unexpectedly-shown-in-j…
bbean23 Nov 11, 2025
66f00ac
showing the visualizations in a managed manner
bbean23 Nov 11, 2025
8cff9c7
add option to use a CacheableImage in View3d imshow() and draw_image(…
bbean23 Nov 11, 2025
85b20d1
update ViewCrossSectionImageProcessor to draw the CacheableImage, fix…
bbean23 Nov 11, 2025
15616bd
Merge branch '305-code-bug-visualizations-are-unexpectedly-shown-in-j…
bbean23 Nov 11, 2025
b69cf1b
add CrossSection example script
bbean23 Nov 11, 2025
26670db
add documentation
bbean23 Nov 11, 2025
2381503
add PerspectiveTransform.pixels_to_meters_transforms() and meters_to_…
bbean23 Nov 18, 2025
a5ac65a
fix circular import issue
bbean23 Nov 18, 2025
51b3ca5
add list-of-pairs option to Vxy.from_list() and Vxyz.from_list()
bbean23 Nov 18, 2025
bfc1f29
black
bbean23 Nov 18, 2025
a239390
fix Vxyz.from_list() and the from_list tests for Vxy and Vxyz
bbean23 Nov 19, 2025
0d10b14
add coordinate_transforms to SpotAnalysisOperable, apply coordinate t…
bbean23 Nov 19, 2025
27e5a36
rename transforms -> conversions
bbean23 Nov 25, 2025
cacfa13
better unit tests for TestPerspectiveTransform
bbean23 Nov 25, 2025
b0a6e89
unset conversions when data changes
bbean23 Nov 25, 2025
9101dd9
finish renaming transforms -> conversions
bbean23 Nov 25, 2025
97d919c
formatting
bbean23 Nov 25, 2025
a1e005f
add transformed_pixels_to_meters_conversions() and transformed_pixels…
bbean23 Nov 25, 2025
a4b5fef
use new function image_files_in_directory() instead of files_in_direc…
bbean23 Nov 25, 2025
b74e93c
fix TestTargetBoardLocatorImageProcessor, commit images for tests
bbean23 Nov 25, 2025
0d9d091
TargetBoardLocatorImageProcessor sets x_coordinates_transform and y_c…
bbean23 Nov 25, 2025
d7f17aa
fix single plot for ViewCrossSectionImageProcessor
bbean23 Nov 26, 2025
4126441
use transformed coordinates in ViewCrossSectionImageProcessor, add un…
bbean23 Nov 26, 2025
9d1c29a
use transformed coordinates in View3dImageProcessor, add unit tests f…
bbean23 Nov 26, 2025
8124947
fix numbering_outline_color in ImageGrid
bbean23 Nov 26, 2025
d7f29f3
update ViewAnnotationsImageProcessor and ViewHighlightImageProcessor …
bbean23 Nov 26, 2025
1aba763
Merge branch '309-code-bug-incorrect-units-on-enclosed-energy-example…
bbean23 Nov 27, 2025
04191a2
correctly position annotations in a cropped image in ViewAnnotationsI…
bbean23 Nov 27, 2025
4095732
better layout for explaining the operations done in the CrossSection/…
bbean23 Nov 28, 2025
92a4777
fix CrossSection visualization by working around the "MomentsAnnotati…
bbean23 Nov 28, 2025
11cd345
don't overwrite existing files by default
bbean23 Nov 28, 2025
68a95ad
Merge branch '307-code-feature-direct-sun-example' of github.com:bbea…
jehsharp Dec 12, 2025
252116d
Merge branch 'bbean23-307-code-feature-direct-sun-example' into develop
jehsharp Dec 12, 2025
c2ae245
Merge branch 'develop' of github.com:sandialabs/OpenCSP into develop
jehsharp Dec 12, 2025
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
15 changes: 14 additions & 1 deletion contrib/common/lib/cv/annotations/CircularAnnotations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import TYPE_CHECKING
import numpy as np
import scipy.spatial.transform

Expand All @@ -13,6 +14,10 @@
import opencsp.common.lib.render_control.RenderControlPointSeq as rcps
import opencsp.common.lib.tool.log_tools as lt

if TYPE_CHECKING:
# don't import at runtime in order to avoid cyclic dependencies
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable


class CircularAnnotations(AbstractAnnotations):
"""
Expand Down Expand Up @@ -77,7 +82,13 @@ def scale(self) -> list[float]:
)
return [d * self.meters_per_pixel for d in self.size]

def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarray = None, include_label=False):
def render_to_figure(
self,
fig: rcfr.RenderControlFigureRecord,
image: np.ndarray = None,
include_label=False,
operable: "SpotAnalysisOperable" = None,
):
label = self.get_label(include_label)

# draw the circles
Expand All @@ -93,6 +104,8 @@ def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarra
for seg in range(nverticies):
p = (np.sin(seg / nverticies * np.pi * 2) * r) + x
q = (np.cos(seg / nverticies * np.pi * 2) * r) + y
if operable is not None:
p, q = operable.transform_coordinates(p2.Pxy((p, q)))[1].astuple()
pq_list.append((p, q))

fig.view.draw_pq_list(pq_list, close=True, style=self.style, label=label)
Expand Down
17 changes: 14 additions & 3 deletions contrib/common/lib/cv/annotations/EnclosedEnergyAnnotations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import TYPE_CHECKING
import numpy as np
import scipy.spatial.transform

Expand All @@ -10,6 +11,10 @@
import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr
import opencsp.common.lib.render_control.RenderControlPointSeq as rcps

if TYPE_CHECKING:
# don't import at runtime in order to avoid cyclic dependencies
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable


class EnclosedEnergyAnnotations(AbstractAnnotations):
"""
Expand Down Expand Up @@ -84,11 +89,17 @@ def size(self) -> list[float]:
def scale(self) -> list[float]:
return self._representative_circle.scale

def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarray = None, include_label=False):
def render_to_figure(
self,
fig: rcfr.RenderControlFigureRecord,
image: np.ndarray = None,
include_label=False,
operable: "SpotAnalysisOperable" = None,
):
if self.enclosed_shape == "circle":
return self._representative_circle.render_to_figure(fig, image, include_label)
return self._representative_circle.render_to_figure(fig, image, include_label, operable)
elif self.enclosed_shape == "square":
return self._representative_square.render_to_figure(fig, image, include_label)
return self._representative_square.render_to_figure(fig, image, include_label, operable)
else:
raise RuntimeError(
"Error in EnclosedEnergyAnnotations.render_to_figure() "
Expand Down
21 changes: 19 additions & 2 deletions contrib/common/lib/cv/annotations/MomentsAnnotation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import copy
from functools import cache, cached_property
from typing import TYPE_CHECKING

import numpy as np
import scipy.spatial.transform
Expand All @@ -15,6 +16,10 @@
import opencsp.common.lib.tool.image_tools as it
import opencsp.common.lib.tool.log_tools as lt

if TYPE_CHECKING:
# don't import at runtime in order to avoid cyclic dependencies
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable


class MomentsAnnotation(AbstractAnnotations):
def __init__(
Expand Down Expand Up @@ -252,11 +257,18 @@ def central_moment(self, p: int, q: int) -> float:
# other uses of moments...

def render_to_figure(
self, fig_record: rcfr.RenderControlFigureRecord, image: np.ndarray = None, include_label: bool = False
self,
fig_record: rcfr.RenderControlFigureRecord,
image: np.ndarray = None,
include_label: bool = False,
operable: "SpotAnalysisOperable" = None,
):
# draw the centroid marker
label = None if not include_label else "centroid"
fig_record.view.draw_pq(([self.cX], [self.cY]), self.style, label=label)
cX, cY = self.cX, self.cY
if operable is not None:
cX, cY = operable.transform_coordinates(p2.Pxy((cX, cY)))[1].astuple()
fig_record.view.draw_pq(([cX], [cY]), self.style, label=label)

# start by assuming that the plot is >= 30 pixels
height, width, rotation_arrow_dist = None, None, 30
Expand Down Expand Up @@ -289,6 +301,11 @@ def render_to_figure(
if len(intersections) > 0:
rotation_endpoints_list = [(intersections.x[i], intersections.y[i]) for i in range(len(intersections))]

# transform coordinates for the operable
if operable is not None:
for i, (p, q) in enumerate(rotation_endpoints_list):
rotation_endpoints_list[i] = operable.transform_coordinates(p2.Pxy((p, q)))[1].astuple()

# draw the rotation as an arrow
style = copy.deepcopy(self.rotation_style)
style.marker = "arrow"
Expand Down
15 changes: 14 additions & 1 deletion contrib/common/lib/cv/annotations/RectangleAnnotations.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import TYPE_CHECKING
import numpy as np
import scipy.spatial.transform

Expand All @@ -13,6 +14,10 @@
import opencsp.common.lib.render_control.RenderControlPointSeq as rcps
import opencsp.common.lib.tool.log_tools as lt

if TYPE_CHECKING:
# don't import at runtime in order to avoid cyclic dependencies
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable


class RectangleAnnotations(AbstractAnnotations):
"""
Expand Down Expand Up @@ -82,7 +87,13 @@ def scale(self) -> list[float]:
)
return [self.size * self.meters_per_pixel]

def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarray = None, include_label=False):
def render_to_figure(
self,
fig: rcfr.RenderControlFigureRecord,
image: np.ndarray = None,
include_label=False,
operable: "SpotAnalysisOperable" = None,
):
label = self.get_label(include_label)

# get the corner vertices for each bounding box
Expand All @@ -92,6 +103,8 @@ def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarra
for loop in bbox.loops:
loop_verts = list(zip(loop.vertices.x, loop.vertices.y))
loop_verts = [(int(x), int(y)) for x, y in loop_verts]
if operable is not None:
loop_verts = [operable.transform_coordinates(p2.Pxy((x, y)))[1].astuple() for x, y in loop_verts]
draw_loops.append(loop_verts)

# draw the bounding boxes
Expand Down
30 changes: 26 additions & 4 deletions contrib/common/lib/cv/annotations/SpotWidthAnnotation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from typing import TYPE_CHECKING
import matplotlib.patches
import numpy as np
import scipy.spatial.transform
Expand All @@ -10,6 +11,10 @@
import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr
import opencsp.common.lib.render_control.RenderControlSpotSize as rcss

if TYPE_CHECKING:
# don't import at runtime in order to avoid cyclic dependencies
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable


class SpotWidthAnnotation(AbstractAnnotations):
def __init__(
Expand Down Expand Up @@ -94,7 +99,13 @@ def rotation(self) -> scipy.spatial.transform.Rotation:
def size(self) -> list[float]:
raise NotImplementedError

def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarray, include_label=False):
def render_to_figure(
self,
fig: rcfr.RenderControlFigureRecord,
image: np.ndarray,
include_label=False,
operable: "SpotAnalysisOperable" = None,
):
label = self.get_label(include_label)

# draw the full-width boundary
Expand All @@ -109,17 +120,23 @@ def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarra
}
label = None
if self.spot_width_technique == "fwhm":
center = self.long_axis_center
if operable is not None:
center = operable.transform_coordinates(center)[1]
ellipse = matplotlib.patches.Ellipse(
xy=self.long_axis_center.astuple(),
xy=center.astuple(),
width=self.width,
height=self.orthogonal_axis_width,
angle=np.rad2deg(self.long_axis_rotation),
**style_params
)
fig.view.axis.add_patch(ellipse)
else:
center = self.centroid_loc
if operable is not None:
center = operable.transform_coordinates(center)[1]
ellipse = matplotlib.patches.Ellipse(
xy=self.centroid_loc.astuple(), width=self.width, height=self.width, **style_params
xy=center.astuple(), width=self.width, height=self.width, **style_params
)
fig.view.axis.add_patch(ellipse)

Expand All @@ -129,10 +146,15 @@ def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarra
for loop in bbox.loops:
loop_verts = list(zip(loop.vertices.x, loop.vertices.y))
loop_verts = [(int(x), int(y)) for x, y in loop_verts]
if operable is not None:
loop_verts = [operable.transform_coordinates(p2.Pxy((x, y)))[1].astuple() for x, y in loop_verts]
fig.view.draw_pq_list(loop_verts, close=True, style=self.style.bounding_box_style, label=label)
label = None

# draw the centroid
if self.style.center_style != "None":
fig.view.draw_pq(self.centroid_loc.data, self.style.center_style, label=label)
xy = self.centroid_loc
if operable is not None:
xy = operable.transform_coordinates(xy)[1]
fig.view.draw_pq(xy.data, self.style.center_style, label=label)
label = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import copy
import dataclasses
from typing import Callable, Type

import cv2
import numpy as np

from opencsp.common.lib.cv.CacheableImage import CacheableImage
from opencsp.common.lib.cv.fiducials.AbstractFiducials import AbstractFiducials
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable
from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractSpotAnalysisImageProcessor import (
AbstractSpotAnalysisImageProcessor,
)
import opencsp.common.lib.geometry.LoopXY as l2
import opencsp.common.lib.geometry.Pxy as p2
import opencsp.common.lib.tool.log_tools as lt
import opencsp.common.lib.tool.image_tools as it


class DiscardAnnotationsImageProcessor(AbstractSpotAnalysisImageProcessor):
"""
Discards fiducials and/or annotations from operables that pass through
based on given criteria.

TODO: extend this class to discard single instances of fiducials for instances where multiple fiducials are included in a single "AbstractFiducials" instance.
"""

def __init__(
self,
outside_boundaries: bool = False,
matching_types: list[Type[AbstractFiducials]] = None,
custom_criteria: Callable[[SpotAnalysisOperable], list[AbstractFiducials]] = None,
):
"""
Parameters
----------
outside_boundaries : bool, optional
If True, then any fiducials and/or annotations that are centered
outside the boundaries of the image are discarded.
matching_types : list[Type[AbstractFiducials]], optional
All fiducials and/or annotations that are instances of one of the
given types are discarded.
custom_criteria : Callable[[SpotAnalysisOperable], np.ndarray], optional
The function to be provided that takes in an operable and returns a
list of fiducials and/or annotations to be discarded.
"""
super().__init__()

self.outside_boundaries = outside_boundaries
self.matching_types = matching_types or []
self.custom_criteria = custom_criteria

def get_fiducials_outside_boundaries(self, operable: SpotAnalysisOperable) -> list[AbstractFiducials]:
"""
Get the annotations that fall outside the boundaries of the image. This can happen, for example, after a CroppingImageProcessor has trimmed down an image area and the new area excludes an existing fiducial. If self.outside_boundaries is False, then returns an empty list.
"""
if not self.outside_boundaries:
return []
fiducials_to_discard: list[AbstractFiducials] = []

(height, width), _ = it.dims_and_nchannels(operable.primary_image.nparray)
image_bounds_tltrbrbl = p2.Pxy([[0, width, width, 0], [0, 0, height, height]])
transformed_bounds_tltrbrbl = operable.transform_coordinates(image_bounds_tltrbrbl)[1]
transformed_loop = l2.LoopXY.from_vertices(transformed_bounds_tltrbrbl)

for fiducials_list in [operable.given_fiducials, operable.found_fiducials, operable.annotations]:
for fiducials in fiducials_list:
fiducials: AbstractFiducials = fiducials
origins = operable.transform_coordinates(fiducials.origin)[1]
origins_inside = transformed_loop.is_inside_or_on_border(origins)
if not np.all(origins_inside):
fiducials_to_discard.append(fiducials)

return fiducials_to_discard

def get_fiducials_matching_types(self, operable: SpotAnalysisOperable):
"""Get the fiducials to discard based on the types of the fiducials."""
fiducials_to_discard: list[AbstractFiducials] = []

for fiducials_list in [operable.given_fiducials, operable.found_fiducials, operable.annotations]:
for fiducials in fiducials_list:
fiducials: AbstractFiducials = fiducials
for fiducials_type in self.matching_types:
if isinstance(fiducials, fiducials_type):
fiducials_to_discard.append(fiducials)

return fiducials_to_discard

def get_fiducials_by_custom_criteria(self, operable: SpotAnalysisOperable) -> list[AbstractFiducials]:
"""Get the annotations to be discarded based on a custom function."""
if self.custom_criteria is None:
return []
return self.custom_criteria(operable)

def discard_fiducials(
self, operable: SpotAnalysisOperable, fiducials_to_discard: list[AbstractFiducials]
) -> SpotAnalysisOperable:
"""
Discards the fiducials in the given list and returns a new operable with
newly updated annotations.
"""
# Make copies so that we don't change the lists on the input operable
given_fiducials = copy.copy(operable.given_fiducials)
found_fiducials = copy.copy(operable.found_fiducials)
annotations = copy.copy(operable.annotations)

# Remove all fiducials in the list to be discarded
for fiducials_list in [given_fiducials, found_fiducials, annotations]:
fiducials_list: list[AbstractFiducials] = fiducials_list
for fiducials in copy.copy(fiducials_list):
if fiducials in fiducials_to_discard:
fiducials_list.remove(fiducials)

# Update with a new operable instance
return dataclasses.replace(
operable, given_fiducials=given_fiducials, found_fiducials=found_fiducials, annotations=annotations
)

def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAnalysisOperable]:
fiducials_to_discard: list[AbstractFiducials] = []

# Get the fiducials to discard
fiducials_to_discard += self.get_fiducials_outside_boundaries(operable)
fiducials_to_discard += self.get_fiducials_matching_types(operable)
fiducials_to_discard += self.get_fiducials_by_custom_criteria(operable)

# Build a new operable instance, keeping all fiducials not in the discard list
operable = self.discard_fiducials(operable, fiducials_to_discard)

return [operable]
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,17 @@ def visualize_operable(

# initialize the figure
self.prepare_figure_records([self.figure], [image.shape])
self.figure.view.imshow(image)
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
)

# render
include_label = len(to_render) > 1
for fiducials in to_render:
fiducials.render_to_figure(self.figure, image, include_label)
fiducials.render_to_figure(self.figure, image, include_label, operable)

# show the legend
if include_label:
Expand Down
Loading