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
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