Skip to content
17 changes: 11 additions & 6 deletions contrib/common/lib/cv/annotations/CircularAnnotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def __init__(
self,
style: rcps.RenderControlPointSeq = None,
centers_radiuses: tuple[p2.Pxy, list[int]] = None,
pixels_to_meters: float = None,
meters_per_pixel: float = None,
):
"""
Parameters
Expand All @@ -32,7 +32,7 @@ def __init__(
The rendering style, by default {magenta, no corner markers}
centers_radiuses : tuple[Pxy, list[int]]
The center(s) and radius(es) for this annotation, in pixels
pixels_to_meters : float, optional
meters_per_pixel : float, optional
A simple conversion method for how many meters a pixel represents,
for use in scale(). By default None.
"""
Expand All @@ -41,7 +41,7 @@ def __init__(
super().__init__(style)

self.p2r = centers_radiuses
self.pixels_to_meters = pixels_to_meters
self.meters_per_pixel = meters_per_pixel

def get_bounding_box(self, index=0) -> reg.RegionXY:
x = self.p2r[0].x[index]
Expand All @@ -54,6 +54,11 @@ def get_bounding_box(self, index=0) -> reg.RegionXY:
def origin(self) -> p2.Pxy:
return self.p2r[0]

def translate(self, translation: p2.Pxy):
centers, radiuses = self.p2r
p2r = (centers + translation, radiuses)
return self.__class__(self.style, p2r, self.meters_per_pixel)

@property
def rotation(self) -> scipy.spatial.transform.Rotation:
raise NotImplementedError("Orientation is not yet implemented for CircularAnnotations")
Expand All @@ -64,13 +69,13 @@ def size(self) -> list[float]:

@property
def scale(self) -> list[float]:
if self.pixels_to_meters is None:
if self.meters_per_pixel is None:
lt.error_and_raise(
RuntimeError,
"Error in CircularAnnotations.scale(): "
+ "no pixels_to_meters conversion ratio is set, so scale can't be estimated",
+ "no meters_per_pixel conversion ratio is set, so scale can't be estimated",
)
return [d * self.pixels_to_meters for d in self.size]
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):
label = self.get_label(include_label)
Expand Down
15 changes: 10 additions & 5 deletions contrib/common/lib/cv/annotations/EnclosedEnergyAnnotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def __init__(
style: rcps.RenderControlPointSeq = None,
centers_radiuses: tuple[p2.Pxy, list[int]] = None,
enclosed_shape: str = "circle",
pixels_to_meters: float = None,
meters_per_pixel: float = None,
):
"""
Parameters
Expand All @@ -32,7 +32,7 @@ def __init__(
The center(s) and radius(es) for this annotation, in pixels
enclosed_shape : str, optional
The shape used to determine the enclosed energy. Supports "circle" and "square". Default is "circle".
pixels_to_meters : float, optional
meters_per_pixel : float, optional
A simple conversion method for how many meters a pixel represents,
for use in scale(). By default None.
"""
Expand All @@ -50,15 +50,15 @@ def __init__(

self._p2r = centers_radiuses
self.enclosed_shape = enclosed_shape
self.pixels_to_meters = pixels_to_meters
self.meters_per_pixel = meters_per_pixel

self.label = f"En{enclosed_shape}d Energy" # Encircled, Ensquared

r = p2.Pxy((self._p2r[1], self._p2r[1]))
upper_left = self._p2r[0] - r
lower_right = self._p2r[0] + r
self._representative_circle = CircularAnnotations(style, centers_radiuses, pixels_to_meters)
self._representative_square = RectangleAnnotations(style, (upper_left, lower_right), pixels_to_meters)
self._representative_circle = CircularAnnotations(style, centers_radiuses, meters_per_pixel)
self._representative_square = RectangleAnnotations(style, (upper_left, lower_right), meters_per_pixel)

def get_bounding_box(self, index=0) -> reg.RegionXY:
return self._representative_circle.get_bounding_box(index)
Expand All @@ -67,6 +67,11 @@ def get_bounding_box(self, index=0) -> reg.RegionXY:
def origin(self) -> p2.Pxy:
return self._representative_circle.origin

def translate(self, translation: p2.Pxy):
centers, radiuses = self._p2r
centers += translation
return self.__class__(self.style, (centers, radiuses), self.enclosed_shape, self.meters_per_pixel)

@property
def rotation(self) -> scipy.spatial.transform.Rotation:
return self._representative_circle.rotation
Expand Down
77 changes: 77 additions & 0 deletions contrib/common/lib/cv/annotations/MomentsAnnotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,83 @@ def get_bounding_box(self, index=0) -> reg.RegionXY:
def origin(self) -> p2.Pxy:
return self.centroid

def translate(self, translations: p2.Pxy):
old_m00 = self.moments["m00"]
old_m10 = self.moments["m10"]
old_m01 = self.moments["m01"]
old_u11 = self.central_moment(1, 1)
old_u20 = self.central_moment(2, 0)
old_u02 = self.central_moment(0, 2)
old_u21 = self.central_moment(2, 1)
old_u12 = self.central_moment(1, 2)
old_u30 = self.central_moment(3, 0)
old_u03 = self.central_moment(0, 3)

Tx = translations.x[0]
Ty = translations.y[0]

# Example calculated values for an elipse at 135 degrees, size 100x50:
# center at 50, 50 at 60, 70
# m00 1031985.0 1031985.0
# m10 51626280.0 61946130.0
# m01 51634950.0 72274650.0
# m11 2825640210.0 4580912310.0
# m20 2994052920.0 4129777020.0
# m02 2995071900.0 5473263900.0
# m12 174088606080.0 362497365480.0
# m21 174061884630.0 318333724230.0
# m30 190942591890.0 297284048490.0
# m03 191033689110.0 440955823110.0
# u00 1031985.0 1031985.0
# u01 0 0
# u10 0 0
# u11 242540274.9369 242540274.9369
# u20 411386712.0237 411386712.0237
# u02 411538165.0111 411538165.0111
# u21 -11069058.1853 -11069058.1854
# u12 -14244705.8617 -14244705.8616
# u30 1303451.534790 1303451.534790
# u03 -5805601.44134 -5805601.44128

# central_moment formulas:
# u00 = m00
# u01 = 0
# u10 = 0
# u11 = m11 - m01 / m00 * m10
# u20 = m20 - m10**2 / m00
# u02 = m02 - m01**2 / m00
# u21 = m21 - 2 * m10 / m00 * m11 - m01 / m00 * m20 + 2 * m10**2 / m00**2 * m01
# u12 = m12 - 2 * m01 / m00 * m11 - m10 / m00 * m02 + 2 * m01**2 / m00**2 * m10
# u30 = m30 - 3 * m10 / m00 * m20 + 2 * m10**3 / m00**2
# u03 = m03 - 3 * m01 / m00 * m02 + 2 * m01**3 / m00**2

# fmt: off
new_m00 = old_m00
new_m10 = old_m10 + old_m00 * Tx
new_m01 = old_m01 + old_m00 * Ty
new_m11 = old_u11 + new_m01 / new_m00 * new_m10
new_m20 = old_u20 + new_m10**2 / new_m00
new_m02 = old_u02 + new_m01**2 / new_m00
new_m21 = old_u21 + 2 * new_m10 / new_m00 * new_m11 + new_m01 / new_m00 * new_m20 - 2 * new_m10**2 / new_m00**2 * new_m01
new_m12 = old_u12 + 2 * new_m01 / new_m00 * new_m11 + new_m10 / new_m00 * new_m02 - 2 * new_m01**2 / new_m00**2 * new_m10
new_m30 = old_u30 + 3 * new_m10 / new_m00 * new_m20 - 2 * new_m10**3 / new_m00**2
new_m03 = old_u03 + 3 * new_m01 / new_m00 * new_m02 - 2 * new_m01**3 / new_m00**2
# fmt: on

new_moments = copy.copy(self.moments)
new_moments["m00"] = new_m00
new_moments["m10"] = new_m10
new_moments["m01"] = new_m01
new_moments["m11"] = new_m11
new_moments["m20"] = new_m20
new_moments["m02"] = new_m02
new_moments["m21"] = new_m21
new_moments["m12"] = new_m12
new_moments["m30"] = new_m30
new_moments["m03"] = new_m03

return self.__class__(new_moments, self.style, self.rotation_style)

@cached_property
def rotation_angle_2d(self) -> float:
# from https://en.wikipedia.org/wiki/Image_moment
Expand Down
17 changes: 11 additions & 6 deletions contrib/common/lib/cv/annotations/RectangleAnnotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def __init__(
self,
style: rcps.RenderControlPointSeq = None,
upperleft_lowerright_corners: tuple[p2.Pxy, p2.Pxy] = None,
pixels_to_meters: float = None,
meters_per_pixel: float = None,
):
"""
Parameters
Expand All @@ -32,7 +32,7 @@ def __init__(
The rendering style, by default {magenta, no corner markers}
upperleft_lowerright_corners : Pxy
The upper-left and lower-right corners of the bounding box for this rectangle, in pixels
pixels_to_meters : float, optional
meters_per_pixel : float, optional
A simple conversion method for how many meters a pixel represents,
for use in scale(). By default None.
"""
Expand All @@ -41,7 +41,7 @@ def __init__(
super().__init__(style)

self.points = upperleft_lowerright_corners
self.pixels_to_meters = pixels_to_meters
self.meters_per_pixel = meters_per_pixel

def get_bounding_box(self, index=0) -> reg.RegionXY:
x1 = self.points[0].x[index]
Expand All @@ -57,6 +57,11 @@ def get_bounding_box(self, index=0) -> reg.RegionXY:
def origin(self) -> p2.Pxy:
return self.points[0]

def translate(self, translation: p2.Pxy):
upper_left = self.points[0] + translation
lower_right = self.points[1] + translation
return self.__class__(self.style, (upper_left, lower_right), self.meters_per_pixel)

@property
def rotation(self) -> scipy.spatial.transform.Rotation:
raise NotImplementedError("Orientation is not yet implemented for RectangleAnnotations")
Expand All @@ -69,13 +74,13 @@ def size(self) -> list[float]:

@property
def scale(self) -> list[float]:
if self.pixels_to_meters is None:
if self.meters_per_pixel is None:
lt.error_and_raise(
RuntimeError,
"Error in RectangeAnnotations.scale(): "
+ "no pixels_to_meters conversion ratio is set, so scale can't be estimated",
+ "no meters_per_pixel conversion ratio is set, so scale can't be estimated",
)
return [self.size * self.pixels_to_meters]
return [self.size * self.meters_per_pixel]

def render_to_figure(self, fig: rcfr.RenderControlFigureRecord, image: np.ndarray = None, include_label=False):
label = self.get_label(include_label)
Expand Down
13 changes: 13 additions & 0 deletions contrib/common/lib/cv/annotations/SpotWidthAnnotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ def single_width_bounding_box(center: p2.Pxy, width: float) -> reg.RegionXY:
def origin(self) -> p2.Pxy:
return self.centroid_loc

def translate(self, translation: p2.Pxy):
centroid_loc = self.centroid_loc + translation
long_axis_center = self.long_axis_center + translation
return self.__class__(
self.spot_width_technique,
centroid_loc,
self.width,
self.long_axis_rotation,
long_axis_center,
self.orthogonal_axis_width,
self.style,
)

@property
def rotation(self) -> scipy.spatial.transform.Rotation:
if self.spot_width_technique == "fwhm":
Expand Down
123 changes: 123 additions & 0 deletions contrib/common/lib/cv/annotations/test/TestMomentsAnnotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import cv2 as cv
import numpy as np
from PIL import Image
import unittest

from opencsp.common.lib.cv.CacheableImage import CacheableImage
from contrib.common.lib.cv.spot_analysis.image_processor.MomentsImageProcessor import MomentsImageProcessor
from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable
from opencsp.common.lib.cv.SpotAnalysis import SpotAnalysis
import opencsp.common.lib.geometry.Pxy as p2


class TestImportAnnotations(unittest.TestCase):
def setUp(self):
self._build_ellipse_img()

return super().setUp()

def _build_ellipse_img(self):
dimensions = (140, 140, 3)
base_image = np.zeros(dimensions, dtype="uint8")
center = (70, 70)
axes_lengths = (50, 25)
angle = 45
s, e = 0, 360 # start, end angles
color = (255, 255, 255)
thickness = -1 # fill
self.ellipse_img = cv.ellipse(base_image, center, axes_lengths, angle, s, e, color, thickness)

def test_centroid(self):
"""Test that the moments annotation produces the correct value when asked for the centroid"""
# import here to avoid import cycles
import contrib.common.lib.cv.annotations.MomentsAnnotation as manno

# calculate the moments
processor = MomentsImageProcessor()
sa = SpotAnalysis(self._testMethodName, [processor])
sa.set_primary_images([self.ellipse_img])
for result in sa:
pass

# verify that the centroid is in the center
moments: manno.MomentsAnnotation = result.get_fiducials_by_type(manno.MomentsAnnotation)[0]
self.assertAlmostEqual(moments.cX, 70, delta=0.1)
self.assertAlmostEqual(moments.cY, 70, delta=0.1)

def test_rotation(self):
"""Test that the moments annotation produces the correct value when asked for the rotation"""
# import here to avoid import cycles
import contrib.common.lib.cv.annotations.MomentsAnnotation as manno

# calculate the moments
processor = MomentsImageProcessor()
sa = SpotAnalysis(self._testMethodName, [processor])
sa.set_primary_images([self.ellipse_img])
for result in sa:
pass

# verify that the rotation is at 45 degrees
moments: manno.MomentsAnnotation = result.get_fiducials_by_type(manno.MomentsAnnotation)[0]
self.assertAlmostEqual(
np.rad2deg(moments.rotation_angle_2d), 135, delta=0.1
) # 135 degrees is an acceptable answer

def test_eccentricity(self):
"""Regression test to check that the eccentricity value hasn't changed since the last time this test was run."""
# import here to avoid import cycles
import contrib.common.lib.cv.annotations.MomentsAnnotation as manno

# calculate the moments
processor = MomentsImageProcessor()
sa = SpotAnalysis(self._testMethodName, [processor])
sa.set_primary_images([self.ellipse_img])
for result in sa:
pass

# verify that the eccentricity matches the mathematical definition
# e = sqrt(1 - b**2/a**2) = 0.8660254037844386
moments: manno.MomentsAnnotation = result.get_fiducials_by_type(manno.MomentsAnnotation)[0]
self.assertAlmostEqual(moments.eccentricity_untested, 0.8660254037844386, delta=0.01)

def test_translate(self):
# import here to avoid import cycles
import contrib.common.lib.cv.annotations.MomentsAnnotation as manno

# calculate the moments for the untranslated image
processor = MomentsImageProcessor()
sa = SpotAnalysis(self._testMethodName, [processor])
sa.set_primary_images([self.ellipse_img])
for result in sa:
act_moments: manno.MomentsAnnotation = result.get_fiducials_by_type(manno.MomentsAnnotation)[0]
act_moments = act_moments.translate(p2.Pxy([10, 20]))

# translate the image and recalculate
t_ellipse_img = np.zeros_like(self.ellipse_img)
t_ellipse_img[20:, 10:, :] = self.ellipse_img[:-20, :-10, :]
t_processor = MomentsImageProcessor()
t_sa = SpotAnalysis(self._testMethodName, [t_processor])
t_sa.set_primary_images([t_ellipse_img])
for t_result in t_sa:
t_moments: manno.MomentsAnnotation = t_result.get_fiducials_by_type(manno.MomentsAnnotation)[0]

# verify that the moments have changed as expected
self.assertAlmostEqual(t_moments.moments["m00"], act_moments.moments["m00"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m10"], act_moments.moments["m10"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m01"], act_moments.moments["m01"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m11"], act_moments.moments["m11"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m20"], act_moments.moments["m20"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m02"], act_moments.moments["m02"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m12"], act_moments.moments["m12"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m21"], act_moments.moments["m21"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m30"], act_moments.moments["m30"], delta=0.1)
self.assertAlmostEqual(t_moments.moments["m03"], act_moments.moments["m03"], delta=0.1)

# verify that the centroid is the only changed value
self.assertAlmostEqual(act_moments.cX, 80, delta=0.1)
self.assertAlmostEqual(act_moments.cY, 90, delta=0.1)
self.assertAlmostEqual(np.rad2deg(act_moments.rotation_angle_2d), 135, delta=0.1)
self.assertAlmostEqual(act_moments.eccentricity_untested, 0.86, delta=0.01)


if __name__ == "__main__":
unittest.main()
Loading