diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/TargetBoardLocatorImageProcessor.py b/contrib/common/lib/cv/spot_analysis/image_processor/TargetBoardLocatorImageProcessor.py index 13ece3684..7a5f3498d 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/TargetBoardLocatorImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/TargetBoardLocatorImageProcessor.py @@ -5,9 +5,10 @@ import os from numpy._typing._array_like import NDArray +import sympy from opencsp.common.lib.cv.CacheableImage import CacheableImage -import contrib.common.lib.cv.PerspectiveTransform as pt +import opencsp.common.lib.cv.PerspectiveTransform as pt import contrib.common.lib.cv.RegionDetector as rd from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractSpotAnalysisImageProcessor import ( @@ -19,6 +20,7 @@ import opencsp.common.lib.opencsp_path.opencsp_root_path as orp import opencsp.common.lib.render.Color as color import opencsp.common.lib.tool.file_tools as ft +import opencsp.common.lib.tool.image_tools as it import opencsp.common.lib.tool.log_tools as lt @@ -257,7 +259,9 @@ def _find_rectangle_in_reference_image(self): # Compile a list of all reference images if os.path.isdir(self.reference_image_dir_or_file): - image_filenames = ft.files_in_directory(self.reference_image_dir_or_file, files_only=True) + image_filenames = it.image_files_in_directory( + self.reference_image_dir_or_file, it.pil_image_formats_readable + ) image_files: list[str] = [] for filename in image_filenames: file_path_name_ext = os.path.join(self.reference_image_dir_or_file, filename) @@ -360,6 +364,13 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn # target board images my_visualization_images = [annotated_cacheable] + # apply the changes to the image coordinates + x, y = sympy.symbols('x y') + x_coordinates_transform, y_coordinates_transform = self.transform.transformed_pixels_to_meters_conversions() + if operable.x_coordinates_transform is not None: + x_coordinates_transform = x_coordinates_transform.subs({x: operable.x_coordinates_transform}) + y_coordinates_transform = y_coordinates_transform.subs({y: operable.y_coordinates_transform}) + visualization_images = copy.copy(operable.visualization_images) visualization_images[self] = my_visualization_images algorithm_images = copy.copy(operable.algorithm_images) @@ -369,5 +380,7 @@ def _execute(self, operable: SpotAnalysisOperable, is_last: bool) -> list[SpotAn primary_image=isolated_cacheable, visualization_images=visualization_images, algorithm_images=algorithm_images, + x_coordinates_transform=x_coordinates_transform, + y_coordinates_transform=y_coordinates_transform, ) return [ret] 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..74ca4677d 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewAnnotationsImageProcessor.py @@ -113,7 +113,12 @@ def visualize_operable( # initialize the figure self.figure.clear() - 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 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..ece715ad7 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/ViewHighlightImageProcessor.py @@ -115,7 +115,12 @@ def visualize_operable( # show the visualization self.figure.clear() - self.figure.view.imshow(new_image) + 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.figure.view.show(block=False) # build the return value diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/TestTargetBoardLocatorImageProcessor.py b/contrib/common/lib/cv/spot_analysis/image_processor/test/TestTargetBoardLocatorImageProcessor.py index 71ace7ace..84389282d 100644 --- a/contrib/common/lib/cv/spot_analysis/image_processor/test/TestTargetBoardLocatorImageProcessor.py +++ b/contrib/common/lib/cv/spot_analysis/image_processor/test/TestTargetBoardLocatorImageProcessor.py @@ -7,6 +7,8 @@ from PIL import Image from contrib.common.lib.cv.spot_analysis.image_processor import TargetBoardLocatorImageProcessor +from opencsp.common.lib.cv.CacheableImage import CacheableImage +from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable import opencsp.common.lib.geometry.Pxy as p2 import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.image_tools as it @@ -30,71 +32,81 @@ def test_target_board_location(self): cropped_x1x2y1y2=None, target_width_meters=2.44, target_height_meters=2.44, - canny_edges_gradient=30, - canny_non_edges_gradient=20, + canny_edges_gradient=10, + canny_non_edges_gradient=15, + # canny_test_gradients=[(5,5),(5,10),(5,15),(5,20),(10,5),(10,10),(10,15),(10,20)], # debug_target_locating=True ) - lighted = Image.open(ft.join(self.data_dir, "09W01.JPG")) + lighted = Image.open(ft.join(self.data_dir, "09W01.png")) result = processor.process_images([lighted])[0] - result.to_image().save(ft.join(self.out_dir, self._testMethodName + ".png")) + result.save(ft.join(self.out_dir, self._testMethodName + ".png")) corners = processor.corners - self.assertAlmostEqual(corners["tl"].x[0], 517, delta=20) - self.assertAlmostEqual(corners["tl"].y[0], 257, delta=20) - self.assertAlmostEqual(corners["tr"].x[0], 1107, delta=20) - self.assertAlmostEqual(corners["tr"].y[0], 268, delta=20) - self.assertAlmostEqual(corners["br"].x[0], 1094, delta=20) - self.assertAlmostEqual(corners["br"].y[0], 855, delta=20) - self.assertAlmostEqual(corners["bl"].x[0], 503, delta=20) - self.assertAlmostEqual(corners["bl"].y[0], 842, delta=20) - - def test_target_board_location_cropped(self): - # crop the input image - crop_size = random.randint(1, 200) - crop = [crop_size, 1626 + 1 - crop_size, crop_size, 1236 + 1 - crop_size] - lighted = Image.open(ft.join(self.data_dir, "09W01.JPG")) - lighted_cropped = lighted.crop([crop[0], crop[2], crop[1], crop[3]]) - - # evaluate - processor = TargetBoardLocatorImageProcessor( - reference_image_dir_or_file=ft.join(self.data_dir, "reference_target_board"), - cropped_x1x2y1y2=crop, - target_width_meters=2.44, - target_height_meters=2.44, - canny_edges_gradient=30, - canny_non_edges_gradient=20, - # debug_target_locating=True - ) - result = processor.process_images([lighted_cropped])[0] - result.to_image().save(ft.join(self.out_dir, self._testMethodName + ".png")) - - # verify - corners = processor.corners - self.assertAlmostEqual(corners["tl"].x[0], 517 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["tl"].y[0], 257 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["tr"].x[0], 1107 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["tr"].y[0], 268 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["br"].x[0], 1094 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["br"].y[0], 855 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["bl"].x[0], 503 - crop_size, delta=20, msg=f"failed for {crop_size=}") - self.assertAlmostEqual(corners["bl"].y[0], 842 - crop_size, delta=20, msg=f"failed for {crop_size=}") + # fmt: off + self.assertAlmostEqual(corners["tl"].x[0], 35.7295165, delta=2) # max delta for (max - min) in 100 runs: 0.31794104 + self.assertAlmostEqual(corners["tl"].y[0], 29.26274199, delta=2) # max delta for (max - min) in 100 runs: 0.07478309 + self.assertAlmostEqual(corners["tr"].x[0], 625.25789369, delta=2) # max delta for (max - min) in 100 runs: 0.31775339 + self.assertAlmostEqual(corners["tr"].y[0], 45.87442367, delta=2) # max delta for (max - min) in 100 runs: 0.0335551 + self.assertAlmostEqual(corners["br"].x[0], 605.83119206, delta=2) # max delta for (max - min) in 100 runs: 0.30441256 + self.assertAlmostEqual(corners["br"].y[0], 633.02880323, delta=2) # max delta for (max - min) in 100 runs: 0.01701056 + self.assertAlmostEqual(corners["bl"].x[0], 16.30514864, delta=2) # max delta for (max - min) in 100 runs: 0.49065163 + self.assertAlmostEqual(corners["bl"].y[0], 616.74420236, delta=2) # max delta for (max - min) in 100 runs: 0.05482708 + # fmt: on def test_perspective_transform(self): corners = { - "tl": p2.Pxy([519.42333545, 256.22223199]), - "tr": p2.Pxy([1108.33624737, 271.21012117]), - "br": p2.Pxy([1091.97009466, 857.37629342]), - "bl": p2.Pxy([501.9556769, 840.95732619]), + "tl": p2.Pxy([35.7295165, 29.26274199]), + "tr": p2.Pxy([625.25789369, 45.87442367]), + "br": p2.Pxy([605.83119206, 633.02880323]), + "bl": p2.Pxy([16.30514864, 616.74420236]), } processor = TargetBoardLocatorImageProcessor.from_corners( corners, target_width_meters=2.44, target_height_meters=2.44 ) - lighted = Image.open(ft.join(self.data_dir, "09W01.JPG")) + lighted = Image.open(ft.join(self.data_dir, "09W01.png")) result = processor.process_images([lighted])[0] - result.to_image().save(ft.join(self.out_dir, self._testMethodName + ".png")) + result.save(ft.join(self.out_dir, self._testMethodName + ".png")) expected = Image.open(ft.join(self.data_dir, "09W01_transformed.png")) - npt.assert_allclose(result.nparray, np.array(expected), atol=2) + npt.assert_array_equal(np.array(result), np.array(expected)) + + def test_coordinate_transform(self): + corners = { + "tl": p2.Pxy([35.7295165, 29.26274199]), + "tr": p2.Pxy([625.25789369, 45.87442367]), + "br": p2.Pxy([605.83119206, 633.02880323]), + "bl": p2.Pxy([16.30514864, 616.74420236]), + } + processor = TargetBoardLocatorImageProcessor.from_corners( + corners, target_width_meters=2.44, target_height_meters=2.44 + ) + lighted = CacheableImage.from_single_source(Image.open(ft.join(self.data_dir, "09W01.png"))) + operable = SpotAnalysisOperable(lighted, "lighted") + dewarped_operable = processor.process_operable(operable, is_last=True)[0] + dewarped_image = dewarped_operable.primary_image.nparray + (h, w), _ = it.dims_and_nchannels(dewarped_image) + + # Check that we get the expected values from the transforms. + # First, sanity check. + tx = operable.transform_coordinates + self.assertEqual(tx(p2.Pxy([0, 0]))[0], False) + self.assertEqual(tx(p2.Pxy([1108, 0]))[0], False) + self.assertEqual(tx(p2.Pxy([1108, 857]))[0], False) + self.assertEqual(tx(p2.Pxy([0, 857]))[0], False) + npt.assert_array_almost_equal(tx(p2.Pxy([0, 0]))[1]._data, p2.Pxy([0, 0])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([1108, 0]))[1]._data, p2.Pxy([1108, 0])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([1108, 857]))[1]._data, p2.Pxy([1108, 857])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([0, 857]))[1]._data, p2.Pxy([0, 857])._data) + # Now check that the cropped transform is correct. + tcx = dewarped_operable.transform_coordinates + self.assertEqual(tcx(p2.Pxy([0, 0]))[0], True) + self.assertEqual(tcx(p2.Pxy([w, 0]))[0], True) + self.assertEqual(tcx(p2.Pxy([w, h]))[0], True) + self.assertEqual(tcx(p2.Pxy([0, h]))[0], True) + npt.assert_array_almost_equal(tcx(p2.Pxy([0, 0]))[1]._data, p2.Pxy([0, 0])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([w, 0]))[1]._data, p2.Pxy([2.44, 0])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([w, h]))[1]._data, p2.Pxy([2.44, 2.44])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([0, h]))[1]._data, p2.Pxy([0, 2.44])._data) if __name__ == "__main__": diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01.png new file mode 100755 index 000000000..072c81853 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01_transformed.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01_transformed.png new file mode 100644 index 000000000..996dc8d11 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01_transformed.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun1.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun1.png new file mode 100755 index 000000000..5927f0d48 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun1.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun10.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun10.png new file mode 100644 index 000000000..d2a4f67ed Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun10.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun11.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun11.png new file mode 100644 index 000000000..897de707c Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun11.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun12.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun12.png new file mode 100644 index 000000000..65cd70e40 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun12.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun13.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun13.png new file mode 100644 index 000000000..5927f0d48 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun13.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun2.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun2.png new file mode 100644 index 000000000..6713b3949 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun2.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun3.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun3.png new file mode 100644 index 000000000..d8792edfe Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun3.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun4.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun4.png new file mode 100644 index 000000000..cfed17351 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun4.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun5.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun5.png new file mode 100644 index 000000000..49f27476f Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun5.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun6.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun6.png new file mode 100644 index 000000000..103d48e68 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun6.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun7.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun7.png new file mode 100644 index 000000000..50ad5349e Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun7.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun8.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun8.png new file mode 100644 index 000000000..7d0004523 Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun8.png differ diff --git a/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun9.png b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun9.png new file mode 100644 index 000000000..3df2bcb5e Binary files /dev/null and b/contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/reference_target_board/NoSun9.png differ diff --git a/contrib/experiments/ExExLookback/DirectSun/cross_section.ipynb b/contrib/experiments/ExExLookback/DirectSun/cross_section.ipynb index 6f63d19c6..1780f4185 100644 --- a/contrib/experiments/ExExLookback/DirectSun/cross_section.ipynb +++ b/contrib/experiments/ExExLookback/DirectSun/cross_section.ipynb @@ -79,9 +79,12 @@ } ], "source": [ - "print(\"opencsp_settings['opencsp_root_path']['large_data_example_dir'] =\", opencsp_settings['opencsp_root_path']['large_data_example_dir'])\n", + "print(\n", + " \"opencsp_settings['opencsp_root_path']['large_data_example_dir'] =\",\n", + " opencsp_settings['opencsp_root_path']['large_data_example_dir'],\n", + ")\n", "experiment_dir = os.path.join(\n", - " opencsp_settings['opencsp_root_path']['large_data_example_dir'], \"1xSunFilter_ManualFocus_VarExp\",\n", + " opencsp_settings['opencsp_root_path']['large_data_example_dir'], \"1xSunFilter_ManualFocus_VarExp\"\n", ")\n", "input_dir = experiment_dir\n", "intermediary_dir = os.path.join(experiment_dir, \"intermediary\")\n", @@ -341,8 +344,8 @@ " \"GetOrigl\": CustomSimpleImageProcessor(\n", " lambda o: get_primary_prev_processor(o, cropping_image_processors[\"Original\"])\n", " ),\n", - "# TODO RCB FIX THE BELOW\n", - "# \"CropCent\": CroppingImageProcessor.by_center_and_size(centroid_pixel_locator, width=1400, height=1400),\n", + " # TODO RCB FIX THE BELOW\n", + " # \"CropCent\": CroppingImageProcessor.by_center_and_size(centroid_pixel_locator, width=1400, height=1400),\n", " \"CropCent\": CroppingImageProcessor.by_center_and_size(centroid_pixel_locator, width_height=(1400, 1400)),\n", "}\n", "cropping_image_processors_list = list(cropping_image_processors.values())\n", diff --git a/contrib/common/lib/cv/PerspectiveTransform.py b/opencsp/common/lib/cv/PerspectiveTransform.py similarity index 50% rename from contrib/common/lib/cv/PerspectiveTransform.py rename to opencsp/common/lib/cv/PerspectiveTransform.py index 87f9d3dc3..b47252392 100644 --- a/contrib/common/lib/cv/PerspectiveTransform.py +++ b/opencsp/common/lib/cv/PerspectiveTransform.py @@ -1,5 +1,6 @@ import cv2 as cv import numpy as np +import sympy import opencsp.common.lib.geometry.Pxy as p2 import opencsp.common.lib.tool.log_tools as lt @@ -44,11 +45,17 @@ def __init__(self, pixel_coordinates: p2.Pxy | list[p2.Pxy], meters_coordinates: self.millimeters_coordinates = p2.Pxy(self.meters_coordinates.data * 1000.0) # transform matrices - self.meters_to_pixels_transform: np.ndarray = None + self._meters_to_pixels_transform: np.ndarray = None self.millimeters_to_pixels_transform: np.ndarray = None - self.pixels_to_meters_transform: np.ndarray = None + self._pixels_to_meters_transform: np.ndarray = None self.pixels_to_millimeters_transform: np.ndarray = None + # transform equations + self.pnt_x_forward_func: sympy.Expr = None + self.pnt_y_forward_func: sympy.Expr = None + self.pnt_x_backward_func: sympy.Expr = None + self.pnt_y_backward_func: sympy.Expr = None + self._find_transforms() def _find_transforms(self): @@ -68,11 +75,17 @@ def _find_transforms(self): mm_xy = mm_xy.astype(np.float32) # find the transforms - self.meters_to_pixels_transform = cv.getPerspectiveTransform(m_xy, px_xy) + self._meters_to_pixels_transform = cv.getPerspectiveTransform(m_xy, px_xy) self.millimeters_to_pixels_transform = cv.getPerspectiveTransform(mm_xy, px_xy) - self.pixels_to_meters_transform = cv.getPerspectiveTransform(px_xy, m_xy) + self._pixels_to_meters_transform = cv.getPerspectiveTransform(px_xy, m_xy) self.pixels_to_millimeters_transform = cv.getPerspectiveTransform(px_xy, mm_xy) + # unset the old conversions + self.pnt_x_forward_func = None + self.pnt_x_forward_func = None + self.pnt_x_backward_func = None + self.pnt_y_backward_func = None + @property def width_meters(self) -> float: """ @@ -144,7 +157,180 @@ def _max_distance( distances.append(np.abs(data[i] - data[j])) return np.max(distances) + def pixels_to_meters_conversions(self) -> tuple[sympy.Expr, sympy.Expr]: + """Sympy transform for converting values in pixels to values in meters. + + Example usage: + + tx_p2m, ty_p2m = persp_xform.pixels_to_meters_conversions() + x, y = sympy.symbols("x y") + mxy = tx_p2m.evalf(subs={x: px, y: py}), ty_p2m.evalf(subs={x: px, y: py}) + + Returns + ------- + tx_p2m : sympy.Expr + The sympy expression that can be evaluated to get the transformed x value. + ty_p2m : sympy.Expr + The sympy expression that can be evaluated to get the transformed y value. + """ + if self.pnt_x_forward_func is None: + # This code is largely from Google Gemini circa Nov 2025 + # 1. Define symbols for the points and the transformation matrix elements + x, y = sympy.symbols('x y') + h11, h12, h13, h21, h22, h23, h31, h32, h33 = sympy.symbols('h11 h12 h13 h21 h22 h23 h31 h32 h33') + + # 2. Define the source point as a SymPy Matrix (homogeneous coordinates) + source_point = sympy.Matrix([[x], [y], [1]]) + + # 3. Define the 3x3 perspective transform matrix (homography matrix) + H = sympy.Matrix([[h11, h12, h13], [h21, h22, h23], [h31, h32, h33]]) + + # 4. Perform the matrix multiplication + # projective_point will be a 3x1 matrix with elements X, Y, W + projective_point = H * source_point + + X: sympy.Expr = projective_point[0] + Y: sympy.Expr = projective_point[1] + W: sympy.Expr = projective_point[2] + + # 5. Get the normalized target coordinates (x', y') as SymPy expressions + forward_x: sympy.Expr = X / W + forward_y: sympy.Expr = Y / W + backward_x: sympy.Expr = X / W + backward_y: sympy.Expr = Y / W + + t = self._pixels_to_meters_transform + tp = np.linalg.inv(t) # t' (t "prime") + self.pnt_x_forward_func = forward_x.subs( + { + h11: t[0, 0], + h12: t[0, 1], + h13: t[0, 2], + h21: t[1, 0], + h22: t[1, 1], + h23: t[1, 2], + h31: t[2, 0], + h32: t[2, 1], + h33: t[2, 2], + } + ) + self.pnt_y_forward_func = forward_y.subs( + { + h11: t[0, 0], + h12: t[0, 1], + h13: t[0, 2], + h21: t[1, 0], + h22: t[1, 1], + h23: t[1, 2], + h31: t[2, 0], + h32: t[2, 1], + h33: t[2, 2], + } + ) + self.pnt_x_backward_func = backward_x.subs( + { + h11: tp[0, 0], + h12: tp[0, 1], + h13: tp[0, 2], + h21: tp[1, 0], + h22: tp[1, 1], + h23: tp[1, 2], + h31: tp[2, 0], + h32: tp[2, 1], + h33: tp[2, 2], + } + ) + self.pnt_y_backward_func = backward_y.subs( + { + h11: tp[0, 0], + h12: tp[0, 1], + h13: tp[0, 2], + h21: tp[1, 0], + h22: tp[1, 1], + h23: tp[1, 2], + h31: tp[2, 0], + h32: tp[2, 1], + h33: tp[2, 2], + } + ) + + return self.pnt_x_forward_func, self.pnt_y_forward_func + + def meters_to_pixels_conversions(self) -> tuple[sympy.Expr, sympy.Expr]: + """Sympy transform for converting values in meters to values in pixels. + + Example usage: + + tx_m2p, ty_m2p = persp_xform.meters_to_pixels_conversions() + x, y = sympy.symbols("x y") + pxy = tx_m2p.evalf(subs={x: mx, y: my}), ty_m2p.evalf(subs={x: mx, y: my}) + + Returns + ------- + tx_m2p : sympy.Expr + The sympy expression that can be evaluated to get the transformed x value. + ty_m2p : sympy.Expr + The sympy expression that can be evaluated to get the transformed y value. + """ + self.pixels_to_meters_conversions() + return self.pnt_x_backward_func, self.pnt_y_backward_func + + def transformed_pixels_to_meters_conversions(self) -> tuple[sympy.Expr, sympy.Expr]: + """ + Returns the x and y transforms from the transformed image (as in the image from the + "transform_image" function) to meters. + + Returns + ------- + tuple[sympy.Expr, sympy.Expr] + The x and y transforms to go from a pixel coordinate in the transformed image to a meters coordinate. + """ + tx_t2m = sympy.sympify("x / 1000") + ty_t2m = sympy.sympify("y / 1000") + return tx_t2m, ty_t2m + + def transformed_pixels_to_pixels_conversions(self) -> tuple[sympy.Expr, sympy.Expr]: + """ + Returns the x and y transforms from the transformed image (as in the image from the + "transform_image" function) to the original image pixels. + + Returns + ------- + tuple[sympy.Expr, sympy.Expr] + The x and y transforms to go from a pixel coordinate in the transformed image to a pixels coordinate in the original image. + """ + tx_t2m, ty_t2m = self.transformed_pixels_to_meters_conversions() + tx_m2p, ty_m2p = self.meters_to_pixels_conversions() + x, y = sympy.symbols("x y") + tx_t2p = tx_m2p.subs({x: tx_t2m, y: ty_t2m}) + ty_t2p = ty_m2p.subs({x: tx_t2m, y: ty_t2m}) + return tx_t2p, ty_t2p + def transform_image(self, image: np.ndarray, buffer_width_px: int = 0, full_image=False) -> np.ndarray: + """ + Applies the forward transform to the given image, effectively + de-warping the image. The resulting image will contain 1000 pixels + per meter (aka a 1x1 meter target will be 1000x1000 pixels big). + + Only the pixels from within the original pixel_coordinates used to + create the class are included in the de-warped image, unless either + buffer_width_px or full_image are specified. + + Parameters + ---------- + image : np.ndarray + The input image to be transformed. + buffer_width_px : int, optional + Extra pixels to include from around the original pixel coordinate. + If set, then full_image is ignored. Default is 0. + full_image : bool, optional + If True, then include all pixels from the given image. By default False. + + Returns + ------- + np.ndarray + The transformed version of the input image. + """ if buffer_width_px != 0: xs = self.pixel_coordinates.x.copy() ys = self.pixel_coordinates.y.copy() @@ -181,9 +367,11 @@ def _a_to_b(self, a_coordinate: p2.Pxy, transform: np.ndarray) -> p2.Pxy: return p2.Pxy((b_x_vals, b_y_vals)) def pixels_to_meters(self, pixel_coordinate: p2.Pxy) -> p2.Pxy: - meters_coordinate = self._a_to_b(pixel_coordinate, self.pixels_to_meters_transform) + """Converts the given pixel coordinates into meters.""" + meters_coordinate = self._a_to_b(pixel_coordinate, self._pixels_to_meters_transform) return p2.Pxy(meters_coordinate.data) def meters_to_pixels(self, meter_coordinate: p2.Pxy) -> p2.Pxy: + """Converts the given meter coordinates into pixels.""" meters_coordinate = p2.Pxy(meter_coordinate.data) - return self._a_to_b(meters_coordinate, self.meters_to_pixels_transform) + return self._a_to_b(meters_coordinate, self._meters_to_pixels_transform) diff --git a/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py b/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py index 8d070dd55..4b9a1bf5d 100644 --- a/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py +++ b/opencsp/common/lib/cv/spot_analysis/SpotAnalysisOperable.py @@ -4,7 +4,9 @@ import numpy.typing as npt import os import sys -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Iterable + +import sympy import opencsp.common.lib.csp.LightSource as ls import opencsp.common.lib.cv.annotations.AbstractAnnotations as aa @@ -12,6 +14,7 @@ from opencsp.common.lib.cv.CacheableImage import CacheableImage from opencsp.common.lib.cv.spot_analysis.ImageType import ImageType from opencsp.common.lib.cv.spot_analysis.SpotAnalysisPopulationStatistics import SpotAnalysisPopulationStatistics +import opencsp.common.lib.geometry.Pxy as p2 import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.log_tools as lt @@ -100,6 +103,12 @@ class SpotAnalysisOperable: image_processor_notes: list[tuple[str, list[str]]] = field(default_factory=list) """ Notes from specific image processors. These notes are generally intended for human use, but it is recommended that they maintain a consistent formatting so that they can also be used programmatically. """ + x_coordinates_transform: sympy.Expr = None + """ The expression to convert from (x,y) image values to real-world x + coordinates, in meters. If None, then the input x value will be returned. """ + y_coordinates_transform: sympy.Expr = None + """ The expression to convert from (x,y) image values to real-world y + coordinates, in meters. If None, then the input y value will be returned. """ def __post_init__(self): # We use this method to sanitize the inputs to the constructor. @@ -142,6 +151,16 @@ def __post_init__(self): if primary_image_source_path is not None: requires_update = True + # check that either no transform is set, or both transforms are set + if (self.x_coordinates_transform is None and self.y_coordinates_transform is not None) or ( + self.x_coordinates_transform is not None and self.y_coordinates_transform is None + ): + raise RuntimeError( + "Error in SpotAnalysisOperable(): " + + "either both of x_coordinates_transform and y_coordinates_transform must be set or they must both be unset, " + + f"but {self.x_coordinates_transform=} and {self.y_coordinates_transform=}!" + ) + if requires_update: # use __init__ to update frozen values self.__init__( @@ -339,6 +358,32 @@ def is_ancestor_of(self, other: "SpotAnalysisOperable") -> bool: return False + def transform_coordinates(self, xy_pixels: p2.Pxy) -> tuple[bool, p2.Pxy]: + """ + Given a set of (x,y) pixel coordinates, get the (x,y) location in real-world meters. + If self.xy_coordinates_transform is None, then the input pixel value will be returned. + + Parameters + ---------- + xy_pixels : Pxy + The pixel coordinates to be transformed. + + Returns + ------- + transformed : bool + True if xy_meters is in meters, False if it is in pixels. + xy_meters : Pxy + The transformed coordinates in meters, or the input xy_pixels. + """ + if self.x_coordinates_transform is None: + return False, xy_pixels + else: + fx = sympy.lambdify(sympy.symbols('x y'), self.x_coordinates_transform, "numpy") + fy = sympy.lambdify(sympy.symbols('x y'), self.y_coordinates_transform, "numpy") + f = lambda x, y: (fx(x, y), fy(x, y)) + xy_meters = [f(xy_pixels.x[i], xy_pixels.y[i]) for i in range(len(xy_pixels))] + return True, p2.Pxy.from_list(xy_meters) + def __sizeof__(self) -> int: """ Get the size of this operable in memory including all primary images, diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/CroppingImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/CroppingImageProcessor.py index 120b5e4fc..1f5eede5a 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/CroppingImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/CroppingImageProcessor.py @@ -3,6 +3,7 @@ from typing import Callable import numpy as np +import sympy from opencsp.common.lib.cv.CacheableImage import CacheableImage from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable @@ -197,12 +198,30 @@ def _crop_image( for i, annot in enumerate(annots): annots[i] = annot.translate(p2.Pxy([-x1, -y1])) + # apply the changes to the image coordinates + if operable.x_coordinates_transform is None: + x_coordinates_transform = sympy.sympify(f"x + {x1}") + y_coordinates_transform = sympy.sympify(f"y + {y1}") + else: + x, y = sympy.symbols('x y') + get_points = lambda t, s, v0, v1: (t.evalf(subs={s: v0}), t.evalf(subs={s: v1})) + xstart, xend = get_points(operable.x_coordinates_transform, x, 0, x1) + ystart, yend = get_points(operable.y_coordinates_transform, y, 0, y1) + x_coordinates_transform = operable.x_coordinates_transform + (xend - xstart) + y_coordinates_transform = operable.y_coordinates_transform + (yend - ystart) + # apply the changes to the notes image_processor_notes = copy.copy(operable.image_processor_notes) image_processor_notes += additional_notes # build the new operable - ret = dataclasses.replace(operable, primary_image=new_primary, image_processor_notes=image_processor_notes) + ret = dataclasses.replace( + operable, + primary_image=new_primary, + x_coordinates_transform=x_coordinates_transform, + y_coordinates_transform=y_coordinates_transform, + image_processor_notes=image_processor_notes, + ) return ret 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..0cb180045 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/View3dImageProcessor.py @@ -12,6 +12,7 @@ from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractVisualizationImageProcessor import ( AbstractVisualizationImageProcessor, ) +import opencsp.common.lib.geometry.Pxy as p2 import opencsp.common.lib.render.figure_management as fm import opencsp.common.lib.render.View3d as v3d import opencsp.common.lib.render_control.RenderControlAxis as rca @@ -106,15 +107,17 @@ def visualize_operable( self.fig_record.title = operable.best_primary_nameext # Draw the new data + (height, width), _ = it.dims_and_nchannels(image) if self.crop_to_threshold is None and self.max_resolution is None: - self.view.draw_xyz_surface(image, self.rcs) + x_arr = np.arange(0, width) + y_arr = np.arange(0, height) else: - width = image.shape[1] - height = image.shape[0] x_arr = (np.arange(0, width) * (x_end - x_start) / width) + x_start y_arr = (np.arange(0, height) * (y_end - y_start) / height) + y_start - x_mesh, y_mesh = np.meshgrid(x_arr, y_arr) - self.view.draw_xyz_surface_customshape(x_mesh, y_mesh, image, self.rcs) + x_arr = operable.transform_coordinates(p2.Pxy((x_arr, [0] * x_arr.size)))[1].x + y_arr = operable.transform_coordinates(p2.Pxy(([0] * y_arr.size, y_arr)))[1].y + 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) 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 e9ee02cc7..416b5727a 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/ViewCrossSectionImageProcessor.py @@ -2,17 +2,16 @@ from typing import Callable, Literal import matplotlib.axes -import matplotlib.backend_bases import numpy as np from contrib.common.lib.cv.spot_analysis.PixelOfInterest import PixelOfInterest from opencsp.common.lib.cv.CacheableImage import CacheableImage -import opencsp.common.lib.cv.image_reshapers as ir from opencsp.common.lib.cv.spot_analysis.ImageType import ImageType from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable from opencsp.common.lib.cv.spot_analysis.image_processor.AbstractVisualizationImageProcessor import ( AbstractVisualizationImageProcessor, ) +import opencsp.common.lib.geometry.Pxy as p2 import opencsp.common.lib.render.Color as color import opencsp.common.lib.render.figure_management as fm import opencsp.common.lib.render.view_spec as vs @@ -22,7 +21,6 @@ import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr import opencsp.common.lib.render_control.RenderControlPointSeq as rcps import opencsp.common.lib.tool.exception_tools as et -import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.image_tools as it @@ -128,7 +126,7 @@ def init_figure_records( ) if self.single_plot: - plot_titles = ["Image"] + plot_titles = ["Image", "Cross Section"] else: plot_titles = ["Image", "Horizontal CS: ", "Vertical CS: "] @@ -173,6 +171,7 @@ def _figure_records(self) -> tuple[rcfr.RenderControlFigureRecord, rcfr.RenderCo def _draw_cross_section( self, + operable: SpotAnalysisOperable, np_image: np.ndarray, cs_loc: tuple[int, int], cropped_region: tuple[int, int, int, int], @@ -187,6 +186,8 @@ def _draw_cross_section( Parameters ---------- + operable : SpotAnalysisOperable + The operable used to transform the drawn coordinates np_image : np.ndarray The image to grab the cross section data from. Should already be cropped according to the cropped_region. cs_loc : tuple[int, int] @@ -240,6 +241,10 @@ def _draw_cross_section( v_p_list = [i + crop_top for i in v_p_list] h_p_list = [i + crop_left for i in h_p_list] + # Adjust the coordinates to match the operable coordinates transforms + v_p_list = operable.transform_coordinates(p2.Pxy(([cs_loc_x] * len(v_p_list), v_p_list)))[1].y + h_p_list = operable.transform_coordinates(p2.Pxy((h_p_list, [cs_loc_y] * len(h_p_list))))[1].x + # Draw the cross section graphs v_fig_record, h_fig_record = self._figure_records v_fig_record.view.draw_pq_list(zip(v_p_list, v_cross_section), style=vstyle, label=vlabel) @@ -267,7 +272,9 @@ def _draw_null_image_cross_section( # add the no-sun cross sections to the plots label = "No Sun" - return self._draw_cross_section(no_sun_image, cs_loc, cropped_region, vstyle, hstyle, label, label) + return self._draw_cross_section( + operable, no_sun_image, cs_loc, cropped_region, vstyle, hstyle, label, label + ) else: return 0 @@ -301,9 +308,6 @@ def visualize_operable( cropped_region = tuple([x_start, y_start, x_end, y_end]) cs_loc_cropped = tuple([cs_cropped_x, cs_cropped_y]) - # matplotlib puts the origin in the bottom left instead of the top left - cs_cropped_y_mlab = cropped_height - cs_cropped_y - # Clear the previous plot for fig_record in self.fig_records: fig_record.clear() @@ -329,15 +333,19 @@ 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_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) + tc = lambda x, y: operable.transform_coordinates(p2.Pxy((x, y)))[1].astuple() + img_xy, img_xy2 = tc(0, 0), tc(cropped_width, cropped_height) + i_view.draw_image( + base_image.nparray, img_xy, (img_xy2[0] - img_xy[0], img_xy2[1] - img_xy[1]), invert_ylim=True + ) + i_view.draw_pq_list([tc(cs_cropped_x, 0), tc(cs_cropped_x, cropped_height)], style=vstyle) + i_view.draw_pq_list([tc(0, cs_cropped_y), tc(cropped_width, cs_cropped_y)], style=hstyle) # Draw the cross sections for the no-sun image. # Draw the cross sections for the primary image using the same axes. graphs_per_plot_cnt = 0 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) + graphs_per_plot_cnt += self._draw_cross_section(operable, np_image, cs_loc, cropped_region) # draw first_view = True diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestCroppingImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestCroppingImageProcessor.py index ee757e0d3..cc518dd6f 100644 --- a/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestCroppingImageProcessor.py +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestCroppingImageProcessor.py @@ -1,7 +1,10 @@ import numpy as np +import numpy.testing as npt import os import unittest +import sympy + from opencsp.common.lib.cv.CacheableImage import CacheableImage from opencsp.common.lib.cv.SpotAnalysis import SpotAnalysis from opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable import SpotAnalysisOperable @@ -102,6 +105,79 @@ def test_crop_annotations(self): self.assertEqual(new_annot0.origin.astuple(), (30, 30)) self.assertEqual(new_annot1.origin.astuple(), (80, 80)) + def test_coordinate_transform_raw(self): + """ + Tests that the resulting operable's coordinate transforms take the crop + location into account. + """ + # get the cropped operable + tenbyfive = CacheableImage(np.arange(50).reshape((5, 10))) + operable = SpotAnalysisOperable(tenbyfive, "tenbyfive") + processor = CroppingImageProcessor.by_region((1, 9, 2, 4)) + cropped_operable = processor.process_operable(operable)[0] + + # Check that we get the expected values from the transforms. + # First, sanity check. + tx = operable.transform_coordinates + self.assertEqual(tx(p2.Pxy([0, 0]))[0], False) + self.assertEqual(tx(p2.Pxy([9, 0]))[0], False) + self.assertEqual(tx(p2.Pxy([9, 4]))[0], False) + self.assertEqual(tx(p2.Pxy([0, 4]))[0], False) + npt.assert_array_almost_equal(tx(p2.Pxy([0, 0]))[1]._data, p2.Pxy([0, 0])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([9, 0]))[1]._data, p2.Pxy([9, 0])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([9, 4]))[1]._data, p2.Pxy([9, 4])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([0, 4]))[1]._data, p2.Pxy([0, 4])._data) + # Now check that the cropped transform is correct. + tcx = cropped_operable.transform_coordinates + self.assertEqual(tcx(p2.Pxy([0, 0]))[0], True) + self.assertEqual(tcx(p2.Pxy([7, 0]))[0], True) + self.assertEqual(tcx(p2.Pxy([7, 1]))[0], True) + self.assertEqual(tcx(p2.Pxy([0, 1]))[0], True) + npt.assert_array_almost_equal(tcx(p2.Pxy([0, 0]))[1]._data, p2.Pxy([1, 2])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([7, 0]))[1]._data, p2.Pxy([8, 2])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([7, 1]))[1]._data, p2.Pxy([8, 3])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([0, 1]))[1]._data, p2.Pxy([1, 3])._data) + + def test_coordinate_transform_complex(self): + """ + Tests that the resulting operable's coordinate transforms take the crop + location into account. + """ + # get the cropped operable + tenbyfive = CacheableImage(np.arange(50).reshape((5, 10))) + x_coordinates_transform = sympy.sympify("x / 10") + y_coordinates_transform = sympy.sympify("y / 10") + operable = SpotAnalysisOperable( + tenbyfive, + "tenbyfive", + x_coordinates_transform=x_coordinates_transform, + y_coordinates_transform=y_coordinates_transform, + ) + processor = CroppingImageProcessor.by_region((1, 9, 2, 4)) + cropped_operable = processor.process_operable(operable)[0] + + # Check that we get the expected values from the transforms. + # First, sanity check. + tx = operable.transform_coordinates + self.assertEqual(tx(p2.Pxy([0, 0]))[0], True) + self.assertEqual(tx(p2.Pxy([9, 0]))[0], True) + self.assertEqual(tx(p2.Pxy([9, 4]))[0], True) + self.assertEqual(tx(p2.Pxy([0, 4]))[0], True) + npt.assert_array_almost_equal(tx(p2.Pxy([0, 0]))[1]._data, p2.Pxy([0, 0])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([9, 0]))[1]._data, p2.Pxy([9 / 10, 0])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([9, 4]))[1]._data, p2.Pxy([9 / 10, 4 / 10])._data) + npt.assert_array_almost_equal(tx(p2.Pxy([0, 4]))[1]._data, p2.Pxy([0, 4 / 10])._data) + # Now check that the cropped transform is correct. + tcx = cropped_operable.transform_coordinates + self.assertEqual(tcx(p2.Pxy([0, 0]))[0], True) + self.assertEqual(tcx(p2.Pxy([7, 0]))[0], True) + self.assertEqual(tcx(p2.Pxy([7, 1]))[0], True) + self.assertEqual(tcx(p2.Pxy([0, 1]))[0], True) + npt.assert_array_almost_equal(tcx(p2.Pxy([0, 0]))[1]._data, p2.Pxy([1 / 10, 2 / 10])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([7, 0]))[1]._data, p2.Pxy([8 / 10, 2 / 10])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([7, 1]))[1]._data, p2.Pxy([8 / 10, 3 / 10])._data) + npt.assert_array_almost_equal(tcx(p2.Pxy([0, 1]))[1]._data, p2.Pxy([1 / 10, 3 / 10])._data) + if __name__ == "__main__": unittest.main() diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestView3dImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestView3dImageProcessor.py new file mode 100644 index 000000000..505fcc29e --- /dev/null +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestView3dImageProcessor.py @@ -0,0 +1,107 @@ +import numpy as np +import numpy.testing as npt +from PIL import Image +import unittest + +from opencsp.common.lib.cv.SpotAnalysis import SpotAnalysis +from contrib.common.lib.cv.spot_analysis.image_processor import * +from opencsp.common.lib.cv.spot_analysis.image_processor import * +import opencsp.common.lib.geometry.Pxy as p2 +import opencsp.common.lib.tool.file_tools as ft + + +skip_msg = """ +There's an issue where matplotlib.close() doesn't fully release the plots from +memory. In our case that is causing the plots generated with a single test case +to differ from plots generated as the 2nd+ test case. I (BGB) think the +solution is here: +https://stackoverflow.com/questions/28757348/how-to-clear-memory-completely-of-all-matplotlib-plots""" + + +class TestView3dImageProcessor(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + path, name, _ = ft.path_components(__file__) + cls.in_dir = ft.join(path, "data/input", name.split("Test")[-1]) + cls.out_dir = ft.join(path, "data/output", name.split("Test")[-1]) + ft.create_directories_if_necessary(cls.out_dir) + ft.delete_files_in_directory(cls.out_dir, "*") + return super().setUpClass() + + def _build_image_comparison_error_msg(self, expected_path, actual_path): + return ( + f"Expected images to match but they don't. Compare the images with:\n" + + f"python contrib/app/ImageDiff/image_diff.py {expected_path} {actual_path}" + ) + + # @unittest.skip(skip_msg) # Can run one of the tests + def test_transform_coordinates_tblocator(self): + corners = { + "tl": p2.Pxy([35.7295165, 29.26274199]), + "tr": p2.Pxy([625.25789369, 45.87442367]), + "br": p2.Pxy([605.83119206, 633.02880323]), + "bl": p2.Pxy([16.30514864, 616.74420236]), + } + + processors = { + "target_board": TargetBoardLocatorImageProcessor.from_corners( + corners, target_width_meters=2.44, target_height_meters=2.44 + ), + "v3d": View3dImageProcessor(), + } + + sa = SpotAnalysis("test_targetboard_coords", list(processors.values())) + sa.set_primary_images([ft.join(self.in_dir, "09W01.png")]) + + load_dir = ft.join(self.in_dir, "test_transform_coordinates_tblocator") + save_dir = ft.join(self.out_dir, "test_transform_coordinates_tblocator") + ft.create_directories_if_necessary(save_dir) + ft.delete_files_in_directory(save_dir, "*.png") + imgs_to_compare: list[tuple[str, str]] = [] + for result in sa: + for i, visualization_image in enumerate(result.visualization_images[processors["v3d"]]): + load_path = ft.join(load_dir, f"vis_{i}.png") + save_path = ft.join(save_dir, f"vis_{i}.png") + visualization_image.to_image().save(save_path) + imgs_to_compare.append((load_path, save_path)) + + for expected_path, actual_path in imgs_to_compare: + expected_img = Image.open(expected_path) + actual_img = Image.open(actual_path) + err_msg = self._build_image_comparison_error_msg(expected_path, actual_path) + npt.assert_array_equal(np.array(expected_img), np.array(actual_img), err_msg) + + csproc: View3dImageProcessor = processors["v3d"] + csproc.close_figures() + + @unittest.skip(skip_msg) + def test_transform_coordinates_crop(self): + processors = {"cropping": CroppingImageProcessor.by_region((100, 500, 200, 400)), "v3d": View3dImageProcessor()} + + sa = SpotAnalysis("test_targetboard_coords", list(processors.values())) + sa.set_primary_images([ft.join(self.in_dir, "09W01.png")]) + + load_dir = ft.join(self.in_dir, "test_transform_coordinates_crop") + save_dir = ft.join(self.out_dir, "test_transform_coordinates_crop") + ft.create_directories_if_necessary(save_dir) + ft.delete_files_in_directory(save_dir, "*.png") + imgs_to_compare: list[tuple[str, str]] = [] + for result in sa: + for i, visualization_image in enumerate(result.visualization_images[processors["v3d"]]): + load_path = ft.join(load_dir, f"vis_{i}.png") + save_path = ft.join(save_dir, f"vis_{i}.png") + visualization_image.to_image().save(save_path) + imgs_to_compare.append((load_path, save_path)) + + for expected_path, actual_path in imgs_to_compare: + expected_img = Image.open(expected_path) + actual_img = Image.open(actual_path) + err_msg = self._build_image_comparison_error_msg(expected_path, actual_path) + npt.assert_array_equal(np.array(expected_img), np.array(actual_img), err_msg) + + csproc: View3dImageProcessor = processors["v3d"] + csproc.close_figures() + + +if __name__ == "__main__": + unittest.main() diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestViewCrossSectionImageProcessor.py b/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestViewCrossSectionImageProcessor.py new file mode 100644 index 000000000..df5cc19f3 --- /dev/null +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/test/TestViewCrossSectionImageProcessor.py @@ -0,0 +1,110 @@ +import numpy as np +import numpy.testing as npt +from PIL import Image +import unittest + +from opencsp.common.lib.cv.SpotAnalysis import SpotAnalysis +from contrib.common.lib.cv.spot_analysis.image_processor import * +from opencsp.common.lib.cv.spot_analysis.image_processor import * +import opencsp.common.lib.geometry.Pxy as p2 +import opencsp.common.lib.tool.file_tools as ft + + +skip_msg = """ +There's an issue where matplotlib.close() doesn't fully release the plots from +memory. In our case that is causing the plots generated with a single test case +to differ from plots generated as the 2nd+ test case. I (BGB) think the +solution is here: +https://stackoverflow.com/questions/28757348/how-to-clear-memory-completely-of-all-matplotlib-plots""" + + +class TestViewCrossSectionImageProcessor(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + path, name, _ = ft.path_components(__file__) + cls.in_dir = ft.join(path, "data/input", name.split("Test")[-1]) + cls.out_dir = ft.join(path, "data/output", name.split("Test")[-1]) + ft.create_directories_if_necessary(cls.out_dir) + ft.delete_files_in_directory(cls.out_dir, "*") + return super().setUpClass() + + def _build_image_comparison_error_msg(self, expected_path, actual_path): + return ( + f"Expected images to match but they don't. Compare the images with:\n" + + f"python contrib/app/ImageDiff/image_diff.py {expected_path} {actual_path}" + ) + + # @unittest.skip(skip_msg) Can run one of the tests + def test_transform_coordinates_tblocator(self): + corners = { + "tl": p2.Pxy([35.7295165, 29.26274199]), + "tr": p2.Pxy([625.25789369, 45.87442367]), + "br": p2.Pxy([605.83119206, 633.02880323]), + "bl": p2.Pxy([16.30514864, 616.74420236]), + } + + processors = { + "target_board": TargetBoardLocatorImageProcessor.from_corners( + corners, target_width_meters=2.44, target_height_meters=2.44 + ), + "vcross_sec": ViewCrossSectionImageProcessor((1200, 1200), plot_title=""), + } + + sa = SpotAnalysis("test_targetboard_coords", list(processors.values())) + sa.set_primary_images([ft.join(self.in_dir, "09W01.png")]) + + load_dir = ft.join(self.in_dir, "test_transform_coordinates_tblocator") + save_dir = ft.join(self.out_dir, "test_transform_coordinates_tblocator") + ft.create_directories_if_necessary(save_dir) + ft.delete_files_in_directory(save_dir, "*.png") + imgs_to_compare: list[tuple[str, str]] = [] + for result in sa: + for i, visualization_image in enumerate(result.visualization_images[processors["vcross_sec"]]): + load_path = ft.join(load_dir, f"vis_{i}.png") + save_path = ft.join(save_dir, f"vis_{i}.png") + visualization_image.to_image().save(save_path) + imgs_to_compare.append((load_path, save_path)) + + for expected_path, actual_path in imgs_to_compare: + expected_img = Image.open(expected_path) + actual_img = Image.open(actual_path) + err_msg = self._build_image_comparison_error_msg(expected_path, actual_path) + npt.assert_array_equal(np.array(expected_img), np.array(actual_img), err_msg) + + csproc: ViewCrossSectionImageProcessor = processors["vcross_sec"] + csproc.close_figures() + + @unittest.skip(skip_msg) + def test_transform_coordinates_crop(self): + processors = { + "cropping": CroppingImageProcessor.by_region((100, 500, 200, 400)), + "vcross_sec": ViewCrossSectionImageProcessor((234, 122), plot_title=""), + } + + sa = SpotAnalysis("test_targetboard_coords", list(processors.values())) + sa.set_primary_images([ft.join(self.in_dir, "09W01.png")]) + + load_dir = ft.join(self.in_dir, "test_transform_coordinates_crop") + save_dir = ft.join(self.out_dir, "test_transform_coordinates_crop") + ft.create_directories_if_necessary(save_dir) + ft.delete_files_in_directory(save_dir, "*.png") + imgs_to_compare: list[tuple[str, str]] = [] + for result in sa: + for i, visualization_image in enumerate(result.visualization_images[processors["vcross_sec"]]): + load_path = ft.join(load_dir, f"vis_{i}.png") + save_path = ft.join(save_dir, f"vis_{i}.png") + visualization_image.to_image().save(save_path) + imgs_to_compare.append((load_path, save_path)) + + for expected_path, actual_path in imgs_to_compare: + expected_img = Image.open(expected_path) + actual_img = Image.open(actual_path) + err_msg = self._build_image_comparison_error_msg(expected_path, actual_path) + npt.assert_array_equal(np.array(expected_img), np.array(actual_img), err_msg) + + csproc: ViewCrossSectionImageProcessor = processors["vcross_sec"] + csproc.close_figures() + + +if __name__ == "__main__": + unittest.main() diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/09W01.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/09W01.png new file mode 120000 index 000000000..9c50a0e8d --- /dev/null +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/09W01.png @@ -0,0 +1 @@ +../../../../../../../../../../contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01.png \ No newline at end of file diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/test_transform_coordinates_crop/vis_0.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/test_transform_coordinates_crop/vis_0.png new file mode 100644 index 000000000..ca48d6a5b Binary files /dev/null and b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/test_transform_coordinates_crop/vis_0.png differ diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/test_transform_coordinates_tblocator/vis_0.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/test_transform_coordinates_tblocator/vis_0.png new file mode 100644 index 000000000..d870fc25a Binary files /dev/null and b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/View3dImageProcessor/test_transform_coordinates_tblocator/vis_0.png differ diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/09W01.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/09W01.png new file mode 120000 index 000000000..9c50a0e8d --- /dev/null +++ b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/09W01.png @@ -0,0 +1 @@ +../../../../../../../../../../contrib/common/lib/cv/spot_analysis/image_processor/test/data/input/TargetBoardLocator/09W01.png \ No newline at end of file diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_crop/vis_0.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_crop/vis_0.png new file mode 100644 index 000000000..e0cd80bde Binary files /dev/null and b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_crop/vis_0.png differ diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_crop/vis_1.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_crop/vis_1.png new file mode 100644 index 000000000..05a86b7e3 Binary files /dev/null and b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_crop/vis_1.png differ diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_tblocator/vis_0.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_tblocator/vis_0.png new file mode 100644 index 000000000..f2c641fc3 Binary files /dev/null and b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_tblocator/vis_0.png differ diff --git a/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_tblocator/vis_1.png b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_tblocator/vis_1.png new file mode 100644 index 000000000..b1fe996a2 Binary files /dev/null and b/opencsp/common/lib/cv/spot_analysis/image_processor/test/data/input/ViewCrossSectionImageProcessor/test_transform_coordinates_tblocator/vis_1.png differ diff --git a/opencsp/common/lib/cv/test/TestPerspectiveTransform.py b/opencsp/common/lib/cv/test/TestPerspectiveTransform.py new file mode 100644 index 000000000..7c273ab14 --- /dev/null +++ b/opencsp/common/lib/cv/test/TestPerspectiveTransform.py @@ -0,0 +1,201 @@ +import cv2 as cv +import numpy as np +import PIL.Image as Image +import sympy +import unittest + +from opencsp.common.lib.cv.PerspectiveTransform import PerspectiveTransform +from opencsp.common.lib.cv.spot_analysis.image_processor import * +import opencsp.common.lib.geometry.Pxy as p2 +import opencsp.common.lib.tool.file_tools as ft +import opencsp.common.lib.tool.image_tools as it + + +class TestPerspectiveTransform(unittest.TestCase): + def setUp(self) -> None: + path, _, _ = ft.path_components(__file__) + self.data_dir = ft.join(path, "data", "input", "PerspectiveTransform") + self.out_dir = ft.join(path, "data", "output", "PerspectiveTransform") + ft.create_directories_if_necessary(self.data_dir) + ft.create_directories_if_necessary(self.out_dir) + + def create_images(self): + """Creates the test images.""" + path, _, _ = ft.path_components(__file__) + data_dir = ft.join(path, "data", "input", "PerspectiveTransform") + + warped_image = np.full((105, 105, 3), fill_value=255, dtype=np.uint8) + warped_image = cv.line(warped_image, (20, 20), (90, 10), color=(0, 0, 0), thickness=2) + warped_image = cv.line(warped_image, (90, 10), (99, 99), color=(0, 0, 0), thickness=2) + warped_image = cv.line(warped_image, (99, 99), (5, 85), color=(0, 0, 0), thickness=2) + warped_image = cv.line(warped_image, (5, 85), (20, 20), color=(0, 0, 0), thickness=2) + warped_image = ConvolutionImageProcessor(kernel="gaussian", diameter=3).process_images([warped_image])[0] + Image.fromarray(warped_image).save(ft.join(data_dir, "warped_image.png")) + + def test_image_dewarp(self): + """Regression test to verify that the image transform still works.""" + # corners: TL TR BR BL + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 1, 1, 0] + meters_ys = [0, 0, 1, 1] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + warped_image = np.array(Image.open(ft.join(self.data_dir, "warped_image.png"))) + expected_dewarped_image = np.array(Image.open(ft.join(self.data_dir, "dewarped_image.png"))) + + dewarped_image = persp_xform.transform_image(warped_image) + + Image.fromarray(dewarped_image).save(ft.join(self.out_dir, "dewarped_image.png")) + np.testing.assert_array_almost_equal(dewarped_image, expected_dewarped_image) + (height, width), nchannels = it.dims_and_nchannels(dewarped_image) + self.assertEqual(width, 1000, "Expected the dewarped image to be 1000 pixels wide, to represent 1 full meter.") + self.assertEqual(height, 1000, "Expected the dewarped image to be 1000 pixels high, to represent 1 full meter.") + + def test_image_dewarp_with_border(self): + """Regression test to verify that the border option still works.""" + # corners: TL TR BR BL + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 1, 1, 0] + meters_ys = [0, 0, 1, 1] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + warped_image = np.array(Image.open(ft.join(self.data_dir, "warped_image.png"))) + expected_dewarped_image = np.array(Image.open(ft.join(self.data_dir, "dewarped_image_with_border.png"))) + + dewarped_image = persp_xform.transform_image(warped_image, buffer_width_px=5) + + Image.fromarray(dewarped_image).save(ft.join(self.out_dir, "dewarped_image_with_border.png")) + np.testing.assert_array_almost_equal(dewarped_image, expected_dewarped_image) + + def test_image_dewarp_full(self): + """Regression test to verify that the full image option still works.""" + # corners: TL TR BR BL + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 1, 1, 0] + meters_ys = [0, 0, 1, 1] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + warped_image = np.array(Image.open(ft.join(self.data_dir, "warped_image.png"))) + expected_dewarped_image = np.array(Image.open(ft.join(self.data_dir, "dewarped_image_full.png"))) + + dewarped_image = persp_xform.transform_image(warped_image, full_image=True) + + Image.fromarray(dewarped_image).save(ft.join(self.out_dir, "dewarped_image_full.png")) + np.testing.assert_array_almost_equal(dewarped_image, expected_dewarped_image) + + def test_pixels_to_meters(self): + """Tests that transformed locations can be determined on a single-point basis.""" + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 2.4, 2.4, 0] + meters_ys = [0, 0, 2.4, 2.4] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + + for i, (px, py) in enumerate(zip(pixel_xs, pixel_ys)): + mx, my = list(zip(meters_xs, meters_ys))[i] + mxy = persp_xform.pixels_to_meters(p2.Pxy([px, py])) + err_msg = f"Forward transform ({px}, {py}) -> {mxy.astuple()} instead of the expected ({mx}, {my})" + self.assertAlmostEqual(mxy.x[0], mx, msg=err_msg, delta=1e-5) + self.assertAlmostEqual(mxy.y[0], my, msg=err_msg, delta=1e-5) + + def test_coordinates_conversion_forward(self): + """Tests that transformed locations can be determined using sympy.""" + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 2.4, 2.4, 0] + meters_ys = [0, 0, 2.4, 2.4] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + cx, cy = persp_xform.pixels_to_meters_conversions() + + for i, (px, py) in enumerate(zip(pixel_xs, pixel_ys)): + mx, my = list(zip(meters_xs, meters_ys))[i] + x, y = sympy.symbols("x y") + mxy = cx.evalf(subs={x: px, y: py}), cy.evalf(subs={x: px, y: py}) + err_msg = f"Forward conversion ({px}, {py}) -> {mxy} instead of the expected ({mx}, {my})" + self.assertAlmostEqual(mxy[0], mx, msg=err_msg, delta=1e-5) + self.assertAlmostEqual(mxy[1], my, msg=err_msg, delta=1e-5) + + def test_meters_to_pixels(self): + """Tests that original locations can be determined on a single-point basis.""" + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 2.4, 2.4, 0] + meters_ys = [0, 0, 2.4, 2.4] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + + for i, (mx, my) in enumerate(zip(meters_xs, meters_ys)): + px, py = list(zip(pixel_xs, pixel_ys))[i] + pxy = persp_xform.meters_to_pixels(p2.Pxy([mx, my])) + err_msg = f"Backward transform ({mx}, {my}) -> {pxy.astuple()} instead of the expected ({px}, {py})" + self.assertAlmostEqual(pxy.x[0], px, msg=err_msg, delta=1e-5) + self.assertAlmostEqual(pxy.y[0], py, msg=err_msg, delta=1e-5) + + def test_coordinates_conversion_backward(self): + """Tests that transformed locations can be determined using sympy.""" + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 2.4, 2.4, 0] + meters_ys = [0, 0, 2.4, 2.4] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + cx, cy = persp_xform.meters_to_pixels_conversions() + + for i, (mx, my) in enumerate(zip(meters_xs, meters_ys)): + px, py = list(zip(pixel_xs, pixel_ys))[i] + x, y = sympy.symbols("x y") + pxy = cx.evalf(subs={x: mx, y: my}), cy.evalf(subs={x: mx, y: my}) + err_msg = f"Forward conversion ({mx}, {my}) -> {pxy} instead of the expected ({px}, {py})" + self.assertAlmostEqual(pxy[0], px, msg=err_msg, delta=1e-5) + self.assertAlmostEqual(pxy[1], py, msg=err_msg, delta=1e-5) + + def test_coordinates_conversion_transformed2meters(self): + """Tests that transformed locations can be determined using sympy.""" + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 2.4, 2.4, 0] + meters_ys = [0, 0, 2.4, 2.4] + transformed_xs = [0, 2400, 2400, 0] + transformed_ys = [0, 0, 2400, 2400] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + cx, cy = persp_xform.transformed_pixels_to_meters_conversions() + + for i, (mx, my) in enumerate(zip(meters_xs, meters_ys)): + tx, ty = list(zip(transformed_xs, transformed_ys))[i] + x, y = sympy.symbols("x y") + mxy = cx.evalf(subs={x: tx, y: ty}), cy.evalf(subs={x: tx, y: ty}) + err_msg = f"Forward transform ({tx}, {ty}) -> {mxy} instead of the expected ({mx}, {my})" + self.assertAlmostEqual(mxy[0], mx, msg=err_msg, delta=1e-5) + self.assertAlmostEqual(mxy[1], my, msg=err_msg, delta=1e-5) + + def test_coordinates_conversion_transformed2pixels(self): + """Tests that transformed locations can be determined using sympy.""" + pixel_xs = [20, 90, 99, 5] + pixel_ys = [20, 10, 99, 85] + meters_xs = [0, 2.4, 2.4, 0] + meters_ys = [0, 0, 2.4, 2.4] + transformed_xs = [0, 2400, 2400, 0] + transformed_ys = [0, 0, 2400, 2400] + + persp_xform = PerspectiveTransform(p2.Pxy((pixel_xs, pixel_ys)), p2.Pxy((meters_xs, meters_ys))) + cx, cy = persp_xform.transformed_pixels_to_pixels_conversions() + + for i, (px, py) in enumerate(zip(pixel_xs, pixel_ys)): + tx, ty = list(zip(transformed_xs, transformed_ys))[i] + x, y = sympy.symbols("x y") + pxy = cx.evalf(subs={x: tx, y: ty}), cy.evalf(subs={x: tx, y: ty}) + err_msg = f"Forward transform ({tx}, {ty}) -> {pxy} instead of the expected ({px}, {py})" + self.assertAlmostEqual(pxy[0], px, msg=err_msg, delta=1e-5) + self.assertAlmostEqual(pxy[1], py, msg=err_msg, delta=1e-5) + + +if __name__ == "__main__": + # t = TestPerspectiveTransform() + # t.create_images() + unittest.main() diff --git a/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image.png b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image.png new file mode 100644 index 000000000..74f4259f3 Binary files /dev/null and b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image.png differ diff --git a/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image_full.png b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image_full.png new file mode 100644 index 000000000..fc1ee0f54 Binary files /dev/null and b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image_full.png differ diff --git a/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image_with_border.png b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image_with_border.png new file mode 100644 index 000000000..218bfa344 Binary files /dev/null and b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/dewarped_image_with_border.png differ diff --git a/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/warped_image.png b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/warped_image.png new file mode 100644 index 000000000..1e4995651 Binary files /dev/null and b/opencsp/common/lib/cv/test/data/input/PerspectiveTransform/warped_image.png differ diff --git a/opencsp/common/lib/geometry/Vxy.py b/opencsp/common/lib/geometry/Vxy.py index 55bea3eb3..c1ae51d44 100644 --- a/opencsp/common/lib/geometry/Vxy.py +++ b/opencsp/common/lib/geometry/Vxy.py @@ -49,10 +49,14 @@ def __init__(self, data_in: Union[np.ndarray, tuple[float, float], tuple[list, l print(vec.y) # [2. 5. 8.] # or this equivalent method - xs = [1, 4 ,7] + xs = [1, 4, 7] ys = [2, 5, 8] vecs = Vxy((xs, ys)) + # or this equivalent method + xy_pairs = ([1, 2], [4, 5], [7, 8]) + vecs = Vxy.from_list(xy_pairs) + Parameters ---------- data_in : array-like @@ -103,12 +107,19 @@ def _from_data(cls, data, dtype=float): return cls(data, dtype) @classmethod - def from_list(cls, vals: list["Vxy"]): - """Builds a single Vxy instance from a list of Vxy instances.""" + def from_list(cls, vals: list[Union["Vxy", tuple]]): + """Builds a single Vxy instance from a list of Vxy or (x,y) instances.""" xs, ys = [], [] for val in vals: - xs += val.x.tolist() - ys += val.y.tolist() + if isinstance(val, Vxy): + xs += val.x.tolist() + ys += val.y.tolist() + elif hasattr(val[0], "__iter__"): + xs += list(val[0]) + ys += list(val[1]) + else: + xs.append(val[0]) + ys.append(val[1]) return cls((xs, ys)) @classmethod diff --git a/opencsp/common/lib/geometry/Vxyz.py b/opencsp/common/lib/geometry/Vxyz.py index be8936186..ef98108ad 100644 --- a/opencsp/common/lib/geometry/Vxyz.py +++ b/opencsp/common/lib/geometry/Vxyz.py @@ -1,15 +1,21 @@ """Three dimensional vector representation""" -from typing import Callable, Union +from typing import Callable, Union, TYPE_CHECKING import numpy as np import numpy.typing as npt from scipy.spatial.transform import Rotation from opencsp.common.lib.geometry.Vxy import Vxy -import opencsp.common.lib.render.View3d as v3d -import opencsp.common.lib.render_control.RenderControlFigureRecord as rcfr -import opencsp.common.lib.render_control.RenderControlPointSeq as rcps + +if TYPE_CHECKING: + # import while developing to enable type hints + from opencsp.common.lib.render.View3d import View3d + from opencsp.common.lib.render_control.RenderControlFigureRecord import RenderControlFigureRecord + from opencsp.common.lib.render_control.RenderControlPointSeq import RenderControlPointSeq +else: + # don't import at runtime to avoid cyclic dependencies + View3d, RenderControlFigureRecord, RenderControlPointSeq = None, None, None class Vxyz: @@ -143,13 +149,22 @@ def _from_data(cls, data, dtype=None) -> "Vxyz": return cls(data, dtype) @classmethod - def from_list(cls, vals: list["Vxyz"]): - """Builds a single Vxyz instance from a list of Vxyz instances.""" + def from_list(cls, vals: list[Union["Vxy", tuple]]): + """Builds a single Vxy instance from a list of Vxy or (x,y) instances.""" xs, ys, zs = [], [], [] for val in vals: - xs += val.x.tolist() - ys += val.y.tolist() - zs += val.z.tolist() + if isinstance(val, Vxyz): + xs += val.x.tolist() + ys += val.y.tolist() + zs += val.z.tolist() + elif hasattr(val[0], "__iter__"): + xs += list(val[0]) + ys += list(val[1]) + zs += list(val[2]) + else: + xs.append(val[0]) + ys.append(val[1]) + zs.append(val[2]) return cls((xs, ys, zs)) def _check_is_Vxyz(self, v_in): @@ -567,9 +582,9 @@ def origin(cls): def draw_line( self, - figure: rcfr.RenderControlFigureRecord | v3d.View3d, + figure: Union[RenderControlFigureRecord, View3d], close: bool = None, - style: rcps.RenderControlPointSeq = None, + style: RenderControlPointSeq = None, label: str = None, ) -> None: """ @@ -579,30 +594,33 @@ def draw_line( Parameters ---------- - figure : rcfr.RenderControlFigureRecord or v3d.View3d + figure : RenderControlFigureRecord or View3d The figure to draw to. close : bool, optional True to add the first point again at the end of the plot, thereby drawing this set of points as a closed polygon. None or False to not add another point at the end (draw_xyz_list default) - style : rcps.RenderControlPointSeq, optional + style : RenderControlPointSeq, optional The style to use for the points and lines, or None for :py:meth:`RenderControlPointSequence.default`. label : str, optional A string used to label this plot in the legend, or None for no label. """ + # import here to avoid cyclic dependencies + from opencsp.common.lib.render.View3d import View3d + kwargs = dict() for key, val in [("close", close), ("style", style), ("label", label)]: if val is not None: kwargs[key] = val - view = figure if isinstance(figure, v3d.View3d) else figure.view + view = figure if isinstance(figure, View3d) else figure.view view.draw_xyz_list(self.data.T, **kwargs) def draw_points( self, - figure: rcfr.RenderControlFigureRecord | v3d.View3d, - style: rcps.RenderControlPointSeq = None, + figure: Union[RenderControlFigureRecord, View3d], + style: RenderControlPointSeq = None, labels: list[str] = None, ) -> None: """ @@ -612,20 +630,23 @@ def draw_points( Parameters ---------- - figure : rcfr.RenderControlFigureRecord | v3d.View3d + figure : RenderControlFigureRecord | View3d The figure to draw to. close : bool, optional True to add the first point again at the end of the plot, thereby drawing this set of points as a closed polygon. None or False to not add another point at the end (draw_xyz_list default). - style : rcps.RenderControlPointSeq, optional + style : RenderControlPointSeq, optional The style to use for the points and lines, or None for :py:meth:`RenderControlPointSequence.default`. label : str, optional A string used to label this plot in the legend, or None for no label. """ + # import here to avoid cyclic dependencies + from opencsp.common.lib.render.View3d import View3d + if labels is None: labels = [None] * len(self) - view = figure if isinstance(figure, v3d.View3d) else figure.view + view = figure if isinstance(figure, View3d) else figure.view for x, y, z, label in zip(self.x, self.y, self.z, labels): view.draw_xyz((x, y, z), style, label) diff --git a/opencsp/common/lib/geometry/test/test_Vxy.py b/opencsp/common/lib/geometry/test/test_Vxy.py index 6ec2f430c..21e2a2b7d 100644 --- a/opencsp/common/lib/geometry/test/test_Vxy.py +++ b/opencsp/common/lib/geometry/test/test_Vxy.py @@ -3,6 +3,7 @@ import matplotlib.pyplot as plt import numpy as np +import numpy.testing as npt import opencsp.common.lib.geometry.angle as geo_angle from opencsp.common.lib.geometry.Vxy import Vxy @@ -80,6 +81,26 @@ def test_from_list(self): self.assertEqual(d9.x.tolist(), [0, 1, 4, 5, 6, 10, 11, 12, 13]) self.assertEqual(d9.y.tolist(), [2, 3, 7, 8, 9, 14, 15, 16, 17]) + # test mixed Vxy and non-Vxy instances + a1 = [0, 1] + b1 = Vxy([2, 3]) + c2 = Vxy.from_list([a1, b1]) + self.assertEqual(len(c2), 2) + self.assertEqual(c2.x.tolist(), [0, 2]) + self.assertEqual(c2.y.tolist(), [1, 3]) + + # test multi-valued non-Vxy instances + # points (0,1) and (2,3) + a2 = Vxy(list(zip([0, 1], [2, 3]))) + # points (4,5), (6,7) and (8,9) + b3 = [[4, 6, 8], [5, 7, 9]] + # points (10,11), (12,13), (14,15), and (16,17) + c4 = [[10, 11], [12, 13], [14, 15], [16, 17]] + d9 = Vxy.from_list([a2, b3] + c4) + self.assertEqual(len(d9), 9) + npt.assert_array_almost_equal(d9.x, np.array([0, 2, 4, 6, 8, 10, 12, 14, 16])) + npt.assert_array_almost_equal(d9.y, np.array([1, 3, 5, 7, 9, 11, 13, 15, 17])) + def test_xy(self): assert self.V1.x[0] == self.V1_array[0] assert self.V1.y[0] == self.V1_array[1] diff --git a/opencsp/common/lib/geometry/test/test_Vxyz.py b/opencsp/common/lib/geometry/test/test_Vxyz.py index 0b5dfa006..7fa164f7c 100644 --- a/opencsp/common/lib/geometry/test/test_Vxyz.py +++ b/opencsp/common/lib/geometry/test/test_Vxyz.py @@ -74,6 +74,19 @@ def test_from_list(self): self.assertEqual(d9.y.tolist(), [1, 4, 7, 10, 13, 16, 19, 22, 25]) self.assertEqual(d9.z.tolist(), [2, 5, 8, 11, 14, 17, 20, 23, 26]) + # test mixed multi-valued Vxyz and list instances + # points (0,1,2) and (3,4,5) + a2 = Vxyz(list(zip([0, 1, 2], [3, 4, 5]))) + # points (6,7,8), (9,10,11), and (12,13,14) + b3 = [[6, 9, 12], [7, 10, 13], [8, 11, 14]] + # points (15,16,17), (18,19,20), (21,22,23), and (24,25,26) + c4 = [(15, 16, 17), (18, 19, 20), (21, 22, 23), (24, 25, 26)] + d9 = Vxyz.from_list([a2, b3] + c4) + self.assertEqual(len(d9), 9) + self.assertEqual(d9.x.tolist(), [0, 3, 6, 9, 12, 15, 18, 21, 24]) + self.assertEqual(d9.y.tolist(), [1, 4, 7, 10, 13, 16, 19, 22, 25]) + self.assertEqual(d9.z.tolist(), [2, 5, 8, 11, 14, 17, 20, 23, 26]) + def test_xyz(self): assert self.V1.x[0] == self.V1_array[0] assert self.V1.y[0] == self.V1_array[1] diff --git a/opencsp/common/lib/render/View3d.py b/opencsp/common/lib/render/View3d.py index dced14a42..db7595f87 100644 --- a/opencsp/common/lib/render/View3d.py +++ b/opencsp/common/lib/render/View3d.py @@ -502,6 +502,7 @@ def draw_image( width_height: tuple[float, float] = None, cmap: str | matplotlib.colors.Colormap = None, draw_on_top: int | bool | None = True, + invert_ylim: bool = False, ): """ Draw an image on top of an existing plot. @@ -527,6 +528,10 @@ def draw_image( If True, then draw this image on top of other plots. If False, then draw below. A specific zorder can be used by setting this to be an integer. None to use the matplotlib default. Default is True. + invert_ylim : bool, optional + If True, then invert the y axis limits so that they are are in + large to small order instead of small to large order. This + effectively puts the origin for the graph at the top. """ if isinstance(path_or_array, str): img = mpimg.imread(path_or_array) @@ -564,8 +569,18 @@ def draw_image( else: zorder = None + # invert the y draw to match the inverted y limits + if invert_ylim: + ydraw = [ydraw[1], ydraw[0]] + self.axis.imshow(img, extent=[xdraw[0], xdraw[1], ydraw[0], ydraw[1]], zorder=zorder, cmap=cmap) + # invert the y limits + if invert_ylim: + ystart, ystop = self.axis.get_ylim() + if ystart < ystop: + self.axis.set_ylim(ystop, ystart) + def pcolormesh(self, *args, colorbar=False, **kwargs) -> None: """Allows plotting like imshow, with the additional option of sizing the boxes at will. Look at matplotlib.axes.Axes.pcolormesh for more information. diff --git a/opencsp/common/lib/render/__init__.py b/opencsp/common/lib/render/__init__.py index 8205fee1f..46aa2c8c8 100644 --- a/opencsp/common/lib/render/__init__.py +++ b/opencsp/common/lib/render/__init__.py @@ -1,2 +1,5 @@ +# import the cv package to work around the circular imports issue +import opencsp.common.lib.cv + # force the ImageAttributeParser to be registered with the AttributesManager import opencsp.common.lib.render.ImageAttributeParser as iap diff --git a/opencsp/test/test_DocStringsExist.py b/opencsp/test/test_DocStringsExist.py index b9c5a7e0a..20e2d5fe4 100644 --- a/opencsp/test/test_DocStringsExist.py +++ b/opencsp/test/test_DocStringsExist.py @@ -103,6 +103,7 @@ import opencsp.common.lib.cv.image_filters import opencsp.common.lib.cv.image_reshapers import opencsp.common.lib.cv.OpticalFlow +import opencsp.common.lib.cv.PerspectiveTransform import opencsp.common.lib.cv.spot_analysis.ImagesStream import opencsp.common.lib.cv.spot_analysis.SpotAnalysisImagesStream import opencsp.common.lib.cv.spot_analysis.SpotAnalysisOperable @@ -390,6 +391,7 @@ class test_Docstrings(unittest.TestCase): cv_class_list = [ opencsp.common.lib.cv.CacheableImage.CacheableImage, opencsp.common.lib.cv.OpticalFlow.OpticalFlow, + opencsp.common.lib.cv.PerspectiveTransform.PerspectiveTransform, opencsp.common.lib.cv.SpotAnalysis.SpotAnalysis, opencsp.common.lib.cv.image_filters, opencsp.common.lib.cv.image_reshapers,