diff --git a/contrib/app/sofast/SofastCommandLineInterface.py b/contrib/app/sofast/SofastCommandLineInterface.py index 02fc143c..19ded032 100644 --- a/contrib/app/sofast/SofastCommandLineInterface.py +++ b/contrib/app/sofast/SofastCommandLineInterface.py @@ -485,8 +485,8 @@ def _run_given_input(self, retval: str) -> None: image_acquisition_in.frame_size = (1626, 1236) image_acquisition_in.gain = 230 - image_projection_in = ImageProjection.load_from_hdf_and_display(file_image_projection) - image_projection_in.display_data['image_delay'] = 200 + image_projection_in = ImageProjection.load_from_hdf(file_image_projection) + image_projection_in.display_data.image_delay_ms = 200 camera_in = Camera.load_from_hdf(file_camera) facet_definition_in = DefinitionFacet.load_from_json(file_facet_definition_json) diff --git a/example/sofast_calibration/example_calibration_screen_shape.py b/example/sofast_calibration/example_calibration_screen_shape.py index 62999552..ff13d415 100644 --- a/example/sofast_calibration/example_calibration_screen_shape.py +++ b/example/sofast_calibration/example_calibration_screen_shape.py @@ -6,7 +6,7 @@ from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe from opencsp.app.sofast.lib.CalibrateDisplayShape import CalibrateDisplayShape, DataInput from opencsp.common.lib.camera.Camera import Camera -from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjectionData from opencsp.common.lib.geometry.Vxyz import Vxyz from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir import opencsp.common.lib.tool.file_tools as ft @@ -61,7 +61,7 @@ def example_screen_shape_calibration(): # Load input data camera = Camera.load_from_hdf(file_camera_distortion) - image_projection_data = ImageProjection.load_from_hdf(file_image_projection) + image_projection_data = ImageProjectionData.load_from_hdf(file_image_projection) screen_cal_point_pairs = np.loadtxt(file_screen_cal_point_pairs, delimiter=',', skiprows=1, dtype=int) # Store input data in data class diff --git a/example/sofast_fixed/example_create_dot_pattern_from_display_shape.py b/example/sofast_fixed/example_create_dot_pattern_from_display_shape.py index 30c51d47..04ae1ab7 100644 --- a/example/sofast_fixed/example_create_dot_pattern_from_display_shape.py +++ b/example/sofast_fixed/example_create_dot_pattern_from_display_shape.py @@ -2,8 +2,8 @@ from opencsp.app.sofast.lib.DotLocationsFixedPattern import DotLocationsFixedPattern from opencsp.app.sofast.lib.DisplayShape import DisplayShape -from opencsp.app.sofast.lib.SystemSofastFixed import SystemSofastFixed -from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.app.sofast.lib.PatternSofastFixed import PatternSofastFixed +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjectionData from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir import opencsp.common.lib.tool.file_tools as ft import opencsp.common.lib.tool.log_tools as lt @@ -33,7 +33,7 @@ def example_calculate_dot_locations_from_display_shape(): # Load data display = DisplayShape.load_from_hdf(file_display) - im_proj_params = ImageProjection.load_from_hdf(file_image_projection) + im_proj_params = ImageProjectionData.load_from_hdf(file_image_projection) # 2. Define dot projection object # =============================== @@ -41,7 +41,9 @@ def example_calculate_dot_locations_from_display_shape(): spacing_dot = 6 # pixels # Create fixed pattern projection object - projection = SystemSofastFixed(im_proj_params['size_x'], im_proj_params['size_y'], width_dot, spacing_dot) + projection = PatternSofastFixed( + im_proj_params.active_area_size_x, im_proj_params.active_area_size_y, width_dot, spacing_dot + ) # 3. Define DotLocationsFixedPattern object # ========================================= diff --git a/opencsp/app/sofast/SofastGUI.py b/opencsp/app/sofast/SofastGUI.py index f6a6a39c..4910410c 100644 --- a/opencsp/app/sofast/SofastGUI.py +++ b/opencsp/app/sofast/SofastGUI.py @@ -20,7 +20,7 @@ from opencsp.common.lib.camera.ImageAcquisitionAbstract import ImageAcquisitionAbstract from opencsp.common.lib.camera.image_processing import highlight_saturation from opencsp.common.lib.camera.LiveView import LiveView -from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection, ImageProjectionData from opencsp.common.lib.geometry.Vxyz import Vxyz import opencsp.common.lib.opencsp_path.opencsp_root_path as orp import opencsp.common.lib.tool.exception_tools as et @@ -46,7 +46,7 @@ def __init__(self) -> 'SofastGUI': self.root.title('SOFAST') # Set size of GUI - self.root.geometry('600x640+200+100') + self.root.geometry('600x680+200+100') # Add all buttons/widgets to window self._create_layout() @@ -136,6 +136,7 @@ def _plot_image(self, ax: plt.Axes, image: np.ndarray, title: str = '', xlabel: @functools.cached_property def cam_options(self): + """Returns all camera (ImageAcquisition) options that are compatible with Sofast""" all_cam_options = ImageAcquisitionAbstract.cam_options() ret: dict[str, type[ImageAcquisitionAbstract]] = {} @@ -187,10 +188,17 @@ def _create_layout(self) -> None: # =============== First Column - Projection controls =============== r = 0 self.btn_show_cal_image = tkinter.Button( - label_frame_projector, text='Show Calibration Image', command=self.show_calibration_image + label_frame_projector, text='Show Cal Fiducial Image', command=self.show_calibration_fiducial_image ) self.btn_show_cal_image.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') - tkt.TkToolTip(self.btn_show_cal_image, 'Shows calibration image on projection window.') + tkt.TkToolTip(self.btn_show_cal_image, 'Shows calibration fiducial image on projection window.') + r += 1 + + self.btn_show_cal_image = tkinter.Button( + label_frame_projector, text='Show Cal Marker Image', command=self.show_calibration_marker_image + ) + self.btn_show_cal_image.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') + tkt.TkToolTip(self.btn_show_cal_image, 'Shows calibration marker image on projection window.') r += 1 self.btn_show_axes = tkinter.Button(label_frame_projector, text='Show Screen Axes', command=self.show_axes) @@ -309,7 +317,10 @@ def _create_layout(self) -> None: # Camera type dropdown self.var_cam_select = tkinter.StringVar(value=list(self.cam_options.keys())[0]) lbl_camera_type = tkinter.Label(label_frame_settings, text='Select camera:', font=('calibre', 10, 'bold')) - drop_camera_type = tkinter.OptionMenu(label_frame_settings, self.var_cam_select, *list(self.cam_options.keys())) + cam_options = list(self.cam_options.keys()) + drop_camera_type = tkinter.OptionMenu( + label_frame_settings, self.var_cam_select, cam_options[0], *cam_options[1:] + ) tkt.TkToolTip(drop_camera_type, 'Select type of camera object to load.') lbl_camera_type.grid(row=r, column=1, pady=2, padx=2, sticky='nse') @@ -547,13 +558,13 @@ def load_image_projection(self) -> None: # Load file and display if file != '': - # Load data - image_projection_data = ImageProjection.load_from_hdf(file) # Close the previous projector instance with et.ignored(Exception): ImageProjection.instance().close() # Create new window projector_root = tkt.window(self.root, TopLevel=True) + # Load data + image_projection_data = ImageProjectionData.load_from_hdf(file) # Show window ImageProjection(projector_root, image_projection_data) # Build system instance @@ -565,13 +576,21 @@ def load_image_projection(self) -> None: lt.info(f'ImageProjection loaded:\n {file}') - def show_calibration_image(self) -> None: - """Shows calibration image""" + def show_calibration_fiducial_image(self) -> None: + """Shows calibration image with fiducials""" + # Check that we have a projector + if not self._check_projector_loaded('show_calibration_fiducial_image'): + return + + ImageProjection.instance().show_calibration_fiducial_image() + + def show_calibration_marker_image(self) -> None: + """Shows calibration image with Aruco markers""" # Check that we have a projector - if not self._check_projector_loaded('show_calibration_image'): + if not self._check_projector_loaded('show_calibration_marker_image'): return - ImageProjection.instance().show_calibration_image() + ImageProjection.instance().show_calibration_marker_image() def show_crosshairs(self) -> None: """Shows crosshairs""" @@ -800,6 +819,7 @@ def _projector_closed(self, image_projection: ImageProjection): self._camera_closed(None) def close_image_acquisition(self) -> None: + """Closes image acquisition""" # Close the camera with et.ignored(Exception): ImageAcquisitionAbstract.instance().close() diff --git a/opencsp/app/sofast/lib/CalibrateDisplayShape.py b/opencsp/app/sofast/lib/CalibrateDisplayShape.py index e8c43f4e..b90e33cc 100644 --- a/opencsp/app/sofast/lib/CalibrateDisplayShape.py +++ b/opencsp/app/sofast/lib/CalibrateDisplayShape.py @@ -17,7 +17,7 @@ import opencsp.app.sofast.lib.image_processing as ip from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe from opencsp.common.lib.camera.Camera import Camera -from opencsp.common.lib.deflectometry.ImageProjection import CalParams +from opencsp.common.lib.deflectometry.ImageProjection import CalParams, ImageProjectionData from opencsp.common.lib.geometry.Vxy import Vxy from opencsp.common.lib.geometry.Vxyz import Vxyz import opencsp.common.lib.photogrammetry.photogrammetry as ph @@ -41,7 +41,7 @@ class DataInput: Aruco marker corners, meters camera : Camera Camera object used to capture screen distortion data - image_projection_data : dict + image_projection_data : ImageProjectionData Image projection parameters measurements_screen : list[MeasurementSofastFringe] Screen shape Sofast measurement objects @@ -56,7 +56,7 @@ class DataInput: resolution_xy: tuple[int, int] pts_xyz_marker: Vxyz camera: Camera - image_projection_data: dict + image_projection_data: ImageProjectionData measurements_screen: list[MeasurementSofastFringe] assume_located_points: bool = True ray_intersection_threshold: float = 0.001 @@ -113,7 +113,8 @@ def __init__(self, data_input: DataInput) -> 'CalibrateDisplayShape': # Load cal params cal_pattern_params = CalParams( - self.data_input.image_projection_data['size_x'], self.data_input.image_projection_data['size_y'] + self.data_input.image_projection_data.active_area_size_x, + self.data_input.image_projection_data.active_area_size_y, ) # Initialize calculation data structure diff --git a/opencsp/app/sofast/lib/SystemSofastFixed.py b/opencsp/app/sofast/lib/SystemSofastFixed.py index 443d3076..977c8325 100644 --- a/opencsp/app/sofast/lib/SystemSofastFixed.py +++ b/opencsp/app/sofast/lib/SystemSofastFixed.py @@ -58,7 +58,7 @@ def __init__(self, image_acquisition: ImageAcquisitionAbstract) -> 'SystemSofast """The value that corresponds to pure white""" self.dot_shape: Literal['circle', 'square'] = 'circle' """The shape of the fixed pattern dots""" - self.image_delay = image_projection.display_data['image_delay'] # ms + self.image_delay = image_projection.display_data.image_delay_ms # ms """The delay after displaying the image to capturing an image, ms""" def set_pattern_parameters(self, width_pattern: int, spacing_pattern: int): @@ -76,8 +76,8 @@ def set_pattern_parameters(self, width_pattern: int, spacing_pattern: int): lt.debug(f'SystemSofastFixed fixed pattern dot width set to {width_pattern:d} pixels') lt.debug(f'SystemSofastFixed fixed pattern dot spacing set to {spacing_pattern:d} pixels') image_projection = ImageProjection.instance() - size_x = image_projection.size_x - size_y = image_projection.size_y + size_x = image_projection.display_data.active_area_size_x + size_y = image_projection.display_data.active_area_size_y self.pattern_sofast_fixed = PatternSofastFixed(size_x, size_y, width_pattern, spacing_pattern) self.pattern_image = self.pattern_sofast_fixed.get_image(self.dtype, self.max_int, self.dot_shape) diff --git a/opencsp/app/sofast/lib/SystemSofastFringe.py b/opencsp/app/sofast/lib/SystemSofastFringe.py index d4e8197b..ef553474 100644 --- a/opencsp/app/sofast/lib/SystemSofastFringe.py +++ b/opencsp/app/sofast/lib/SystemSofastFringe.py @@ -1,6 +1,3 @@ -"""Class for controlling displaying Sofast patterns and capturing images -""" - import copy import datetime as dt from typing import Callable @@ -21,6 +18,8 @@ class SystemSofastFringe: + """Class for controlling/displaying Sofast patterns and capturing images""" + def __init__( self, image_acquisition: ImageAcquisitionAbstract | list[ImageAcquisitionAbstract] = None ) -> 'SystemSofastFringe': @@ -40,7 +39,6 @@ def __init__( Either the image_acquisition or the image_projection has not been loaded. TypeError: The image_acquisition is not the correct type. - """ # Import here to avoid circular dependencies import opencsp.app.sofast.lib.sofast_common_functions as scf @@ -206,7 +204,9 @@ def _create_mask_images_to_display(self) -> None: array = np.array(image_projection.get_black_array_active_area()) self._mask_images_to_display.append(array) # Create white image - array = np.array(image_projection.get_black_array_active_area()) + image_projection.max_int + array = ( + np.array(image_projection.get_black_array_active_area()) + image_projection.display_data.projector_max_int + ) self._mask_images_to_display.append(array) def _measure_sequence_display( @@ -233,7 +233,7 @@ def _measure_sequence_display( # Wait, then capture image self.root.after( - image_projection.display_data['image_delay'], + image_projection.display_data.image_delay_ms, lambda: self._measure_sequence_capture(im_disp_list, im_cap_list, run_next), ) @@ -306,13 +306,13 @@ def create_fringe_images_from_image_calibration(self): min_display_value = self._calibration.calculate_min_display_camera_values()[0] # Get fringe range - fringe_range = (min_display_value, image_projection.display_data['projector_max_int']) + fringe_range = (min_display_value, image_projection.display_data.projector_max_int) # Get fringe base images fringe_images_base = self.fringes.get_frames( - image_projection.size_x, - image_projection.size_y, - image_projection.display_data['projector_data_type'], + image_projection.display_data.active_area_size_x, + image_projection.display_data.active_area_size_y, + image_projection.display_data.projector_data_type, fringe_range, ) @@ -541,11 +541,14 @@ def run_display_camera_response_calibration(self, res: int = 10, run_next: Calla # Generate grayscale values self._calibration_display_values = np.arange( - 0, image_projection.max_int + 1, res, dtype=image_projection.display_data['projector_data_type'] + 0, + image_projection.display_data.projector_max_int + 1, + res, + dtype=image_projection.display_data.projector_data_type, ) - if self._calibration_display_values[-1] != image_projection.max_int: + if self._calibration_display_values[-1] != image_projection.display_data.projector_max_int: self._calibration_display_values = np.concatenate( - (self._calibration_display_values, [image_projection.max_int]) + (self._calibration_display_values, [image_projection.display_data.projector_max_int]) ) # Generate grayscale images @@ -554,8 +557,12 @@ def run_display_camera_response_calibration(self, res: int = 10, run_next: Calla # Create image array = ( np.zeros( - (image_projection.size_y, image_projection.size_x, 3), - dtype=image_projection.display_data['projector_data_type'], + ( + image_projection.display_data.active_area_size_y, + image_projection.display_data.active_area_size_x, + 3, + ), + dtype=image_projection.display_data.projector_data_type, ) + dn ) diff --git a/opencsp/app/sofast/lib/sofast_common_functions.py b/opencsp/app/sofast/lib/sofast_common_functions.py index 8b0e1caf..c5c0c895 100644 --- a/opencsp/app/sofast/lib/sofast_common_functions.py +++ b/opencsp/app/sofast/lib/sofast_common_functions.py @@ -186,7 +186,9 @@ def run_cal(): if on_done is not None: on_done() - white_image = np.array(image_projection.get_black_array_active_area()) + image_projection.max_int + white_image = ( + np.array(image_projection.get_black_array_active_area()) + image_projection.display_data.projector_max_int + ) image_projection.display_image_in_active_area(white_image) image_projection.root.after(100, run_cal) diff --git a/opencsp/app/sofast/test/test_CalibrateDisplayShape.py b/opencsp/app/sofast/test/test_CalibrateDisplayShape.py index 62d100a2..e7a0e2cf 100644 --- a/opencsp/app/sofast/test/test_CalibrateDisplayShape.py +++ b/opencsp/app/sofast/test/test_CalibrateDisplayShape.py @@ -13,7 +13,7 @@ from opencsp.app.sofast.lib.CalibrateDisplayShape import CalibrateDisplayShape, DataInput from opencsp.app.sofast.lib.MeasurementSofastFringe import MeasurementSofastFringe from opencsp.common.lib.camera.Camera import Camera -from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjectionData from opencsp.common.lib.geometry.Vxyz import Vxyz from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir from opencsp.common.lib.tool.hdf5_tools import load_hdf5_datasets @@ -47,7 +47,7 @@ def setUpClass(cls): corner_ids = pts_marker_data[:, 1] screen_cal_point_pairs = np.loadtxt(file_screen_cal_point_pairs, delimiter=',', skiprows=1).astype(int) camera = Camera.load_from_hdf(file_camera_distortion) - image_projection_data = ImageProjection.load_from_hdf(file_image_projection) + image_projection_data = ImageProjectionData.load_from_hdf(file_image_projection) # Store input data in data class data_input = DataInput( diff --git a/opencsp/app/sofast/test/test_SystemSofastFixed.py b/opencsp/app/sofast/test/test_SystemSofastFixed.py index 7b30b220..7911bc9b 100644 --- a/opencsp/app/sofast/test/test_SystemSofastFixed.py +++ b/opencsp/app/sofast/test/test_SystemSofastFixed.py @@ -20,7 +20,7 @@ def test_system(self): file_im_proj = join(opencsp_code_dir(), 'test/data/sofast_common/image_projection_test.h5') # Instantiate image projection class - im_proj = ImageProjection.load_from_hdf_and_display(file_im_proj) + im_proj = ImageProjection.load_from_hdf(file_im_proj) # Instantiate image acquisition class im_aq = ImageAcquisition() diff --git a/opencsp/app/sofast/test/test_SystemSofastFringe.py b/opencsp/app/sofast/test/test_SystemSofastFringe.py index a8560c27..eda62d3c 100644 --- a/opencsp/app/sofast/test/test_SystemSofastFringe.py +++ b/opencsp/app/sofast/test/test_SystemSofastFringe.py @@ -1,10 +1,10 @@ """Unit test suite to test the System class """ -import numpy as np import os import unittest +import numpy as np import pytest import opencsp.app.sofast.lib.ImageCalibrationGlobal as icg @@ -12,8 +12,7 @@ from opencsp.app.sofast.lib.Fringes import Fringes from opencsp.app.sofast.lib.SystemSofastFringe import SystemSofastFringe from opencsp.app.sofast.test.ImageAcquisition_no_camera import ImageAcquisition -from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection -import opencsp.common.lib.deflectometry.test.test_ImageProjection as test_ip +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection, ImageProjectionData from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir import opencsp.common.lib.tool.exception_tools as et import opencsp.common.lib.tool.file_tools as ft @@ -31,6 +30,12 @@ def setUp(self): # Create fringe object self.fringes = Fringes.from_num_periods() + # Load ImageProjectionData + self.file_image_projection_input = os.path.join( + opencsp_code_dir(), 'test/data/sofast_common/image_projection_test.h5' + ) + self.image_projection_data = ImageProjectionData.load_from_hdf(self.file_image_projection_input) + # Create calibration objects projector_values = np.arange(0, 255, (255 - 0) / 9) camera_response = np.arange(5, 50, (50 - 5) / 9) @@ -53,7 +58,7 @@ def test_SystemSofastFringe(self): fringes = Fringes(periods_x, periods_y) # Instantiate image projection class - im_proj = ImageProjection.load_from_hdf_and_display(file_im_proj) + im_proj = ImageProjection.in_new_window(self.image_projection_data) # Instantiate image acquisition class im_aq = ImageAcquisition() @@ -88,7 +93,7 @@ def f2(): def test_system_all_prereqs(self): # Create mock ImageProjection and ImageAcquisition objects - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + ip = ImageProjection.in_new_window(self.image_projection_data) ia = ImageAcquisition() # Create the system instance @@ -96,7 +101,7 @@ def test_system_all_prereqs(self): def test_system_some_prereqs(self): # With just a projector - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + ip = ImageProjection.in_new_window(self.image_projection_data) with self.assertRaises(RuntimeError): sys = SystemSofastFringe() ip.close() @@ -113,7 +118,7 @@ def test_system_no_prereqs(self): sys = SystemSofastFringe() # More interesting case, things are set and then unset - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + ip = ImageProjection.in_new_window(self.image_projection_data) ia = ImageAcquisition() ip.close() ia.close() @@ -121,7 +126,7 @@ def test_system_no_prereqs(self): sys = SystemSofastFringe() def test_run_measurement_no_calibration(self): - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + ip = ImageProjection.in_new_window(self.image_projection_data) ia = ImageAcquisition() sys = SystemSofastFringe(ia) sys.set_fringes(Fringes.from_num_periods()) @@ -129,7 +134,7 @@ def test_run_measurement_no_calibration(self): sys.run_measurement() def test_run_measurement_without_on_done(self): - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + ip = ImageProjection.in_new_window(self.image_projection_data) ia = ImageAcquisition() sys = SystemSofastFringe(ia) sys.set_fringes(self.fringes) @@ -150,7 +155,7 @@ def on_done(): sys.close_all() # create the prerequisites and the system - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + ip = ImageProjection.in_new_window(self.image_projection_data) ia = ImageAcquisition() sys = SystemSofastFringe(ia) sys.set_fringes(self.fringes) @@ -170,9 +175,7 @@ def on_done(): def test_close_all_closes_acquisition_projections(self): # build system, including multiple image_acquisitions - d = test_ip._ImageProjection.display_dict - d['win_position_x'] = -640 - ip = test_ip._ImageProjection.in_new_window(d) + ip = ImageProjection.in_new_window(self.image_projection_data) ia1 = ImageAcquisition() ia2 = ImageAcquisition() sys = SystemSofastFringe([ia1, ia2]) diff --git a/opencsp/app/sofast/test/test_project_fixed_pattern_target.py b/opencsp/app/sofast/test/test_project_fixed_pattern_target.py index f32d0b0f..2b363fdb 100644 --- a/opencsp/app/sofast/test/test_project_fixed_pattern_target.py +++ b/opencsp/app/sofast/test/test_project_fixed_pattern_target.py @@ -18,9 +18,14 @@ def test_project_fixed_pattern_target(self): file_image_projection = os.path.join(opencsp_code_dir(), "test/data/sofast_common/image_projection_test.h5") # Load ImageProjection - im_proj = ImageProjection.load_from_hdf_and_display(file_image_projection) - - fixed_pattern = PatternSofastFixed(im_proj.size_x, im_proj.size_y, width_pattern=3, spacing_pattern=6) + im_proj = ImageProjection.load_from_hdf(file_image_projection) + + fixed_pattern = PatternSofastFixed( + im_proj.display_data.active_area_size_x, + im_proj.display_data.active_area_size_y, + width_pattern=3, + spacing_pattern=6, + ) image = fixed_pattern.get_image('uint8', 255, 'square') # Project image diff --git a/opencsp/app/sofast/test/test_sofast_common_functions.py b/opencsp/app/sofast/test/test_sofast_common_functions.py index 663fe2c3..aa8647a1 100644 --- a/opencsp/app/sofast/test/test_sofast_common_functions.py +++ b/opencsp/app/sofast/test/test_sofast_common_functions.py @@ -1,8 +1,8 @@ import os +import unittest import numpy as np import pytest -import unittest import opencsp.app.sofast.lib.Fringes as fr import opencsp.app.sofast.lib.ImageCalibrationGlobal as icg @@ -11,7 +11,7 @@ import opencsp.app.sofast.lib.SystemSofastFringe as ssf import opencsp.app.sofast.test.ImageAcquisition_no_camera as ianc import opencsp.common.lib.deflectometry.ImageProjection as ip -import opencsp.common.lib.deflectometry.test.test_ImageProjection as test_ip +from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir import opencsp.common.lib.tool.exception_tools as et import opencsp.common.lib.tool.file_tools as ft @@ -25,6 +25,12 @@ def setUp(self) -> None: ft.create_directories_if_necessary(self.data_dir) ft.create_directories_if_necessary(self.out_dir) + # Load ImageProjectionData + self.file_image_projection_input = os.path.join( + opencsp_code_dir(), 'test/data/sofast_common/image_projection_test.h5' + ) + self.image_projection_data = ip.ImageProjectionData.load_from_hdf(self.file_image_projection_input) + # Create fringe object periods_x = [4**idx for idx in range(4)] periods_y = [4**idx for idx in range(4)] @@ -51,7 +57,7 @@ def test_check_projector_loaded(self): scf.check_projector_loaded('test_check_projector_loaded') # Create a mock ImageProjection object - image_projection = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) # No more error! self.assertTrue(scf.check_projector_loaded('test_check_projector_loaded')) @@ -74,7 +80,7 @@ def test_check_system_loaded(self): scf.check_system_fringe_loaded(None, 'test_check_projector_loaded') # Create the prerequisites - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + im_proj = ip.ImageProjection.in_new_window(self.image_projection_data) ia = ianc.ImageAcquisition() # Still no instance loaded, should throw an error @@ -88,7 +94,7 @@ def test_check_system_loaded(self): self.assertTrue(scf.check_system_fringe_loaded(sys, 'test_check_projector_loaded')) # Release the prerequisites, should throw an error again - ip.close() + im_proj.close() ia.close() with self.assertRaises(RuntimeError): scf.check_system_fringe_loaded(None, 'test_check_projector_loaded') @@ -100,7 +106,7 @@ def test_check_calibration_loaded(self): scf.check_calibration_loaded(None, 'test_check_calibration_loaded') # Create the prerequisites and system instance - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + im_proj = ip.ImageProjection.in_new_window(self.image_projection_data) ia = ianc.ImageAcquisition() sys = ssf.SystemSofastFringe() @@ -116,7 +122,7 @@ def test_run_exposure_cal(self): global sys # Create the prerequisites and system instance - ip = test_ip._ImageProjection.in_new_window(test_ip._ImageProjection.display_dict) + im_proj = ip.ImageProjection.in_new_window(self.image_projection_data) ia = ianc.IA_No_Calibrate() sys = ssf.SystemSofastFringe() diff --git a/opencsp/common/lib/deflectometry/ImageProjection.py b/opencsp/common/lib/deflectometry/ImageProjection.py index c2b9c28e..fb2f9954 100644 --- a/opencsp/common/lib/deflectometry/ImageProjection.py +++ b/opencsp/common/lib/deflectometry/ImageProjection.py @@ -1,6 +1,7 @@ """Class handling the projection of images on a monitor/projector """ +from dataclasses import dataclass import tkinter from typing import Callable, Optional @@ -9,7 +10,7 @@ from PIL import Image, ImageTk import opencsp.common.lib.tool.exception_tools as et -import opencsp.common.lib.tool.hdf5_tools as hdf5_tools +from opencsp.common.lib.tool import hdf5_tools import opencsp.common.lib.tool.tk_tools as tkt @@ -59,20 +60,116 @@ def __init__(self, size_x: int, size_y: int) -> 'CalParams': self.y_screen: np.ndarray[float] = y_mat_screen.flatten() # Point index self.index: np.ndarray[int] = np.arange(self.x_pixel.size, dtype=int) + # Width of Aruco markers + self.marker_width: int = int(min(size_x, size_y) * 0.2) + + +@dataclass +class ImageProjectionData(hdf5_tools.HDF5_IO_Abstract): + """Dataclass containting ImageProjection parameters used to define the image/screen + geometry when projecting an image on a display. + * All position/size/shift units are screen pixels. + * The *active_area* is the region which is actively used to project images, as opposed to the + black background. + * The *main_window* is the tkinter window which holds the *active_area* region. Typically, the + main_window is the size of the screen being projected onto. Any area of the main_window + that is not filled by the active_area is filled with black. This is useful with reducing + light. + * When defining window positions, the reference point is the upper-left corner of the + window/screen. + """ + name: str + """The name of the ImageProjection configuration""" + main_window_size_x: int + """The width of the main window. (For more information on main_window, see class docstring)""" + main_window_size_y: int + """The height of the main window. (For more information on main_window, see class docstring)""" + main_window_position_x: int + """The x position of the upper-left corner of the main window relative to the upper-left corner + of the projection screen. Right is positive. (For more information on main_window, see class docstring)""" + main_window_position_y: int + """The y position of the upper-left corner of the main window relative to the upper-left corner + of the projection screen. Down is positive. (For more information on main_window, see class docstring)""" + active_area_size_x: int + """The width of the active area within the main window. (For more information on active_area, see class docstring)""" + active_area_size_y: int + """The height of the active area within the main window. (For more information on active_area, see class docstring)""" + active_area_position_x: int + """The x position of the upper-left corner of the active area relative to the upper-left corner + of the main window. Right is positive (For more information on main_window and active_area, see class docstring)""" + active_area_position_y: int + """The y position of the upper-left corner of the active area relative to the upper-left corner + of the main window. Down is positive. (For more information on main_window and active_area, see class docstring)""" + projector_data_type: str + """The data type string to be sent to the projector. In most cases, this will be an unsigned 8-bit integer ('uint8')""" + projector_max_int: int + """The integer value that corresponds to a perfectly white image on the screen. In most cases + this will be 255.""" + image_delay_ms: float + """The delay between the display being sent an image to display, and when the camera should start recording. This + is specific to each display/camera/computer setup.""" + shift_red_x: int + """The red channel x shift in an RGB display relative to green in pixels. Right is positive.""" + shift_red_y: int + """The red channel y shift in an RGB display relative to green in pixels. Down is positive.""" + shift_blue_x: int + """The blue channel x shift in an RGB display relative to green in pixels. Right is positive.""" + shift_blue_y: int + """The blue channel y shift in an RGB display relative to green in pixels. Down is positive.""" + + def save_to_hdf(self, file: str, prefix: str = '') -> None: + datasets = [] + data = [] + for name, value in self.__dict__.items(): + datasets.append(prefix + 'ImageProjection/' + name) + data.append(value) + + # Save data + hdf5_tools.save_hdf5_datasets(data, datasets, file) + + @classmethod + def load_from_hdf(cls, file: str, prefix: str = ''): + # Load data + datasets = [ + 'ImageProjection/name', + 'ImageProjection/main_window_size_x', + 'ImageProjection/main_window_size_y', + 'ImageProjection/main_window_position_x', + 'ImageProjection/main_window_position_y', + 'ImageProjection/active_area_size_x', + 'ImageProjection/active_area_size_y', + 'ImageProjection/active_area_position_x', + 'ImageProjection/active_area_position_y', + 'ImageProjection/projector_data_type', + 'ImageProjection/projector_max_int', + 'ImageProjection/image_delay_ms', + 'ImageProjection/shift_red_x', + 'ImageProjection/shift_red_y', + 'ImageProjection/shift_blue_x', + 'ImageProjection/shift_blue_y', + ] + for dataset in datasets: + dataset = prefix + dataset + + kwargs = hdf5_tools.load_hdf5_datasets(datasets, file) + + return cls(**kwargs) + + +class ImageProjection(hdf5_tools.HDF5_IO_Abstract): + """Controls projecting an image on a computer display (projector, monitor, etc.)""" -class ImageProjection(hdf5_tools.HDF5_SaveAbstract): _instance: 'ImageProjection' = None - def __init__(self, root: tkinter.Tk, display_data: dict): - """ - Image projection control. + def __init__(self, root: tkinter.Tk, display_data: ImageProjectionData) -> 'ImageProjection': + """Instantiates class Parameters ---------- root : tkinter.Tk Tk window root. - display_data : dict + display_data : ImageProjectionData Display geometry parameters """ @@ -92,8 +189,19 @@ def __init__(self, root: tkinter.Tk, display_data: dict): self.canvas.pack() self.canvas.configure(background='black', highlightthickness=0) + # Save aruco marker dictionary for calibration image generation + self.aruco_dictionary = cv.aruco.Dictionary_get(cv.aruco.DICT_4X4_1000) + # Save active area data - self.upate_window(display_data) + self._x_active_1: int + self._x_active_2: int + self._y_active_1: int + self._y_active_2: int + self._x_active_mid: int + self._y_active_mid: int + + self.display_data = display_data + self.update_window() # Make window frameless self.root.overrideredirect(1) @@ -106,7 +214,10 @@ def __init__(self, root: tkinter.Tk, display_data: dict): # Create black image image = self._format_image( - np.zeros((self.win_size_y, self.win_size_x, 3), dtype=self.display_data['projector_data_type']) + np.zeros( + (self.display_data.main_window_size_y, self.display_data.main_window_size_x, 3), + dtype=self.display_data.projector_data_type, + ) ) self.canvas_image = self.canvas.create_image(0, 0, image=image, anchor='nw') @@ -126,18 +237,18 @@ def instance(cls) -> Optional["ImageProjection"]: """ return cls._instance - def run(self): + def run(self) -> None: """Runs the Tkinter instance""" self.root.mainloop() @classmethod - def in_new_window(cls, display_data: dict): + def in_new_window(cls, display_data: ImageProjectionData) -> 'ImageProjection': """ Instantiates ImageProjection object in new window. Parameters ---------- - display_data : dict + display_data : ImageProjectionData Display data used to create window. Returns @@ -150,52 +261,26 @@ def in_new_window(cls, display_data: dict): # Instantiate class return cls(root, display_data) - def upate_window(self, display_data: dict) -> None: - """ - Updates window display data. - - Parameters - ---------- - display_data : dict - Input data for position/shift/timing/etc. - - """ - # Save display_data - self.display_data = display_data - - # Save window position data - self.win_size_x = display_data['win_size_x'] - self.win_size_y = display_data['win_size_y'] - self.win_position_x = display_data['win_position_x'] - self.win_position_y = display_data['win_position_y'] - - # Save active area position data - self.size_x = display_data['size_x'] - self.size_y = display_data['size_y'] - self.position_x = display_data['position_x'] - self.position_y = display_data['position_y'] - - # Save maximum projector data integer - self.max_int = display_data['projector_max_int'] - self.dtype = display_data['projector_data_type'] - + def update_window(self) -> None: + """Updates window display data.""" # Calculate active area extents - self.x_active_1 = self.position_x - self.x_active_2 = self.position_x + self.size_x - self.y_active_1 = self.position_y - self.y_active_2 = self.position_y + self.size_y + self._x_active_1 = self.display_data.active_area_position_x + self._x_active_2 = self.display_data.active_area_position_x + self.display_data.active_area_size_x + self._y_active_1 = self.display_data.active_area_position_y + self._y_active_2 = self.display_data.active_area_position_y + self.display_data.active_area_size_y # Calculate center of active area - self.x_active_mid = int(self.size_x / 2) - self.y_active_mid = int(self.size_y / 2) + self._x_active_mid = int(self.display_data.active_area_size_x / 2) + self._y_active_mid = int(self.display_data.active_area_size_y / 2) # Resize window self.root.geometry( - '{:d}x{:d}+{:d}+{:d}'.format(self.win_size_x, self.win_size_y, self.win_position_x, self.win_position_y) + f'{self.display_data.main_window_size_x:d}x{self.display_data.main_window_size_y:d}' + + f'+{self.display_data.main_window_position_x:d}+{self.display_data.main_window_position_y:d}' ) # Resize canvas size - self.canvas.configure(width=self.win_size_x, height=self.win_size_y) + self.canvas.configure(width=self.display_data.main_window_size_x, height=self.display_data.main_window_size_y) def show_crosshairs(self) -> None: """ @@ -203,18 +288,24 @@ def show_crosshairs(self) -> None: """ # Add white active region - array = np.ones((self.size_y, self.size_x, 3), dtype=self.display_data['projector_data_type']) * self.max_int + array = ( + np.ones( + (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3), + dtype=self.display_data.projector_data_type, + ) + * self.display_data.projector_max_int + ) # Add crosshairs vertical - array[:, self.x_active_mid, :] = 0 - array[self.y_active_mid, :, :] = 0 + array[:, self._x_active_mid, :] = 0 + array[self._y_active_mid, :, :] = 0 # Add crosshairs diagonal - width = np.min([self.size_x, self.size_y]) - xd1 = int(self.x_active_mid - width / 4) - xd2 = int(self.x_active_mid + width / 4) - yd1 = int(self.y_active_mid - width / 4) - yd2 = int(self.y_active_mid + width / 4) + width = np.min([self.display_data.active_area_size_x, self.display_data.active_area_size_y]) + xd1 = int(self._x_active_mid - width / 4) + xd2 = int(self._x_active_mid + width / 4) + yd1 = int(self._y_active_mid - width / 4) + yd2 = int(self._y_active_mid + width / 4) xds = np.arange(xd1, xd2, dtype=int) yds = np.arange(yd1, yd2, dtype=int) array[yds, xds, :] = 0 @@ -229,22 +320,28 @@ def show_axes(self) -> None: """ # Add white active region - array = np.ones((self.size_y, self.size_x, 3), dtype=self.display_data['projector_data_type']) * self.max_int + array = ( + np.ones( + (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3), + dtype=self.display_data.projector_data_type, + ) + * self.display_data.projector_max_int + ) # Add arrows - width = int(np.min([self.size_x, self.size_y]) / 4) + width = int(np.min([self.display_data.active_area_size_x, self.display_data.active_area_size_y]) / 4) thickness = 5 # Add green X axis arrow - start_point = (self.x_active_mid, self.y_active_mid) - end_point = (self.x_active_mid - width, self.y_active_mid) - color = (int(self.max_int), 0, 0) # RGB + start_point = (self._x_active_mid, self._y_active_mid) + end_point = (self._x_active_mid - width, self._y_active_mid) + color = (int(self.display_data.projector_max_int), 0, 0) # RGB array = cv.arrowedLine(array, start_point, end_point, color, thickness) # Add red Y axis arrow - start_point = (self.x_active_mid, self.y_active_mid) - end_point = (self.x_active_mid, self.y_active_mid + width) - color = (0, int(self.max_int), 0) # RGB + start_point = (self._x_active_mid, self._y_active_mid) + end_point = (self._x_active_mid, self._y_active_mid + width) + color = (0, int(self.display_data.projector_max_int), 0) # RGB array = cv.arrowedLine(array, start_point, end_point, color, thickness) # Add X text @@ -252,10 +349,10 @@ def show_axes(self) -> None: array = cv.putText( array, 'X', - (self.x_active_mid - width - 20, self.y_active_mid + 20), + (self._x_active_mid - width - 20, self._y_active_mid + 20), font, 6, - (int(self.max_int), 0, 0), + (int(self.display_data.projector_max_int), 0, 0), 2, bottomLeftOrigin=True, ) @@ -263,29 +360,38 @@ def show_axes(self) -> None: # Add Y text font = cv.FONT_HERSHEY_PLAIN array = cv.putText( - array, 'Y', (self.x_active_mid + 20, self.y_active_mid + width + 20), font, 6, (0, int(self.max_int), 0), 2 + array, + 'Y', + (self._x_active_mid + 20, self._y_active_mid + width + 20), + font, + 6, + (0, int(self.display_data.projector_max_int), 0), + 2, ) # Display image self.display_image_in_active_area(array) - def show_calibration_image(self): - """Shows a calibration image with N fiducials. Fiducials are black dots + def show_calibration_fiducial_image(self): + """Shows a calibration image with N fiducials. Fiducials are green dots on a white background. Fiducial locations measured from center of dots. """ # Create base image to show - array = np.ones((self.size_y, self.size_x, 3), dtype=self.dtype) + array = np.zeros( + (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3), + dtype=self.display_data.projector_data_type, + ) # Get calibration pattern parameters - pattern_params = CalParams(self.size_x, self.size_y) + pattern_params = CalParams(self.display_data.active_area_size_x, self.display_data.active_area_size_y) # Add fiducials for x_loc, y_loc, idx in zip(pattern_params.x_pixel, pattern_params.y_pixel, pattern_params.index): # Place fiducial - array[y_loc, x_loc, 1] = self.max_int + array[y_loc, x_loc, 1] = self.display_data.projector_max_int # Place label (offset so label is in view) - x_pt_to_center = float(self.size_x) / 2 - x_loc - y_pt_to_center = float(self.size_y) / 2 - y_loc + x_pt_to_center = float(self.display_data.active_area_size_x) / 2 - x_loc + y_pt_to_center = float(self.display_data.active_area_size_y) / 2 - y_loc if x_pt_to_center >= 0: dx = 15 else: @@ -300,17 +406,67 @@ def show_calibration_image(self): # Display with black border self.display_image_in_active_area(array) + def show_calibration_marker_image(self): + """Shows a calibration image with N Aruco markers. Markers are black + on a white background. + """ + # Create base image to show + array = ( + np.ones( + (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3), + dtype=self.display_data.projector_data_type, + ) + * self.display_data.projector_max_int + ) + + # Get calibration pattern parameters + pattern_params = CalParams(self.display_data.active_area_size_x, self.display_data.active_area_size_y) + w = pattern_params.marker_width + + # Add markers + for x_loc, y_loc, idx in zip(pattern_params.x_pixel, pattern_params.y_pixel, pattern_params.index): + # Make marker + img_mkr = self._make_aruco_marker(w, idx) + # Place marker and label + x_pt_to_center = float(self.display_data.active_area_size_x) / 2 - x_loc + y_pt_to_center = float(self.display_data.active_area_size_y) / 2 - y_loc + + if (x_pt_to_center >= 0) and (y_pt_to_center >= 0): # upper left quadrant + array[y_loc : y_loc + w, x_loc : x_loc + w, :] = img_mkr + dy = int(w / 2) + dx = w + 5 + elif x_pt_to_center >= 0: # lower left quadrant + array[y_loc - w : y_loc, x_loc : x_loc + w, :] = np.rot90(img_mkr, 1) + dy = -int(w / 2) + dx = w + 5 + elif y_pt_to_center >= 0: # top right quadrant + array[y_loc : y_loc + w, x_loc - w : x_loc, :] = np.rot90(img_mkr, 3) + dy = int(w / 2) + dx = -w - 15 + else: # bottom right quadrant + array[y_loc - w : y_loc, x_loc - w : x_loc, :] = np.rot90(img_mkr, 2) + dy = -int(w / 2) + dx = -w - 15 + # Draw text + cv.putText(array, f'{idx:d}', (x_loc + dx, y_loc + dy), cv.FONT_HERSHEY_PLAIN, 1, (0, 0, 0)) + + # Display with black border + self.display_image_in_active_area(array) + def get_black_array_active_area(self) -> np.ndarray: """ - Creates a black image to fill the active area of self.size_y by self.size_x pixels. + Creates a black image to fill the active area of self.display_data.active_area_size_y by self.display_data.active_area_size_x pixels. Returns: -------- image : np.ndarray - A 2D image with shape (self.size_y, self.size_x, 3), filled with zeros + A 2D image with shape (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3), filled with zeros """ # Create black image - black_image = np.zeros((self.size_y, self.size_x, 3), dtype=self.display_data['projector_data_type']) + black_image = np.zeros( + (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3), + dtype=self.display_data.projector_data_type, + ) return black_image @@ -321,7 +477,7 @@ def display_image(self, array: np.ndarray) -> None: Parameters ---------- array : ndarray - NxMx3 image array. Data must be int ranging from 0 to self.max_int. + NxMx3 image array. Data must be int ranging from 0 to self.display_data.projector_max_int. Array XY shape must match window size in pixels. """ @@ -330,29 +486,32 @@ def display_image(self, array: np.ndarray) -> None: raise ValueError('Input array must have 3 dimensions and dimension 2 must be length 3.') # Check array is correct xy shape - if array.shape[0] != self.win_size_y or array.shape[1] != self.win_size_x: + if ( + array.shape[0] != self.display_data.main_window_size_y + or array.shape[1] != self.display_data.main_window_size_x + ): raise ValueError( - 'Input image incorrect size. Input image size is {}, but frame size is {}.'.format( - array.shape[:2], (self.win_size_y, self.win_size_x) - ) + f'Input image incorrect size. Input image size is {array.shape[:2]:d},' + + f' but frame size is {self.display_data.main_window_size_y:d}x' + + f'{self.display_data.main_window_size_x:d}.' ) # Format image image = self._format_image(array) # Display image - self.canvas.imgref = image + self.canvas.imgref = image # So garbage collector doesn't collect `image` self.canvas.itemconfig(self.canvas_image, image=image) def display_image_in_active_area(self, array: np.ndarray) -> None: """Formats and displays input numpy array in active area only. Input - array must have size (self.size_y, self.size_x, 3) and is displayed + array must have size (self.display_data.active_area_size_y, self.display_data.active_area_size_x, 3) and is displayed with a black border to fill entire window area. Parameters ---------- array : ndarray - NxMx3 image array. Data must be int ranging from 0 to self.max_int. + NxMx3 image array. Data must be int ranging from 0 to self.display_data.projector_max_int. Array XY shape must match active window area size in pixels. """ @@ -361,16 +520,22 @@ def display_image_in_active_area(self, array: np.ndarray) -> None: raise ValueError('Input array must have 3 dimensions and dimension 2 must be length 3.') # Check array is correct xy shape - if array.shape[0] != self.size_y or array.shape[1] != self.size_x: + if ( + array.shape[0] != self.display_data.active_area_size_y + or array.shape[1] != self.display_data.active_area_size_x + ): raise ValueError( - 'Input image incorrect size. Input image size is {}, but frame size is {}.'.format( - array.shape[:2], (self.size_y, self.size_x) - ) + f'Input image incorrect size. Input image size is {array.shape[:2]:d},' + + f' but frame size is {self.display_data.active_area_size_y:d}x' + + f'{self.display_data.active_area_size_x:d}.' ) # Create black image and place array in correct position - array_out = np.zeros((self.win_size_y, self.win_size_x, 3), dtype=self.display_data['projector_data_type']) - array_out[self.y_active_1 : self.y_active_2, self.x_active_1 : self.x_active_2, :] = array + array_out = np.zeros( + (self.display_data.main_window_size_y, self.display_data.main_window_size_x, 3), + dtype=self.display_data.projector_data_type, + ) + array_out[self._y_active_1 : self._y_active_2, self._x_active_1 : self._x_active_2, :] = array # Display image self.display_image(array_out) @@ -391,64 +556,19 @@ def _format_image(self, array: np.ndarray) -> ImageTk.PhotoImage: """ # Shift red channel - array[..., 0] = np.roll(array[..., 0], self.display_data['shift_red_x'], 1) - array[..., 0] = np.roll(array[..., 0], self.display_data['shift_red_y'], 0) + array[..., 0] = np.roll(array[..., 0], self.display_data.shift_red_x, 1) + array[..., 0] = np.roll(array[..., 0], self.display_data.shift_red_y, 0) # Shift blue channel - array[..., 2] = np.roll(array[..., 2], self.display_data['shift_blue_x'], 1) - array[..., 2] = np.roll(array[..., 2], self.display_data['shift_blue_y'], 0) + array[..., 2] = np.roll(array[..., 2], self.display_data.shift_blue_x, 1) + array[..., 2] = np.roll(array[..., 2], self.display_data.shift_blue_y, 0) # Format array into tkinter format image = Image.fromarray(array, 'RGB') return ImageTk.PhotoImage(image) @classmethod - def load_from_hdf(cls, file: str, prefix: str = '') -> dict[str, int | str]: - """ - Loads ImageProjection data from the given HDF file. Assumes data is stored as: PREFIX + Folder/Field_1 - - Note: does NOT return an ImageProjection instance, since that would require creating a new tkinter window. - - Parameters - ---------- - file : str - HDF file to load from - prefix : str, optional - Prefix to append to folder path within HDF file (folders must be separated by "/"). - Default is empty string ''. - - Returns - ------- - display_data: dict[str, int | str] - The display data which can be used to create an instance of the ImageProjection class. - - """ - # Load data - datasets = [ - 'ImageProjection/name', - 'ImageProjection/win_size_x', - 'ImageProjection/win_size_y', - 'ImageProjection/win_position_x', - 'ImageProjection/win_position_y', - 'ImageProjection/size_x', - 'ImageProjection/size_y', - 'ImageProjection/position_x', - 'ImageProjection/position_y', - 'ImageProjection/projector_data_type', - 'ImageProjection/projector_max_int', - 'ImageProjection/image_delay', - 'ImageProjection/shift_red_x', - 'ImageProjection/shift_red_y', - 'ImageProjection/shift_blue_x', - 'ImageProjection/shift_blue_y', - 'ImageProjection/ui_position_x', - ] - for i in range(len(datasets)): - datasets[i] = prefix + datasets[i] - return hdf5_tools.load_hdf5_datasets(datasets, file) - - @classmethod - def load_from_hdf_and_display(cls, file: str, prefix: str = '') -> 'ImageProjection': + def load_from_hdf(cls, file: str, prefix: str = '') -> 'ImageProjection': """Loads display_data from the given file into a new image_projection window. Assumes data is stored as: PREFIX + Folder/Field_1 Parameters @@ -465,36 +585,12 @@ def load_from_hdf_and_display(cls, file: str, prefix: str = '') -> 'ImageProject The new ImageProjection instance, opened in a new tkinter window. """ # Load data - display_data = cls.load_from_hdf(file, prefix) + display_data = ImageProjectionData.load_from_hdf(file, prefix) # Open window return cls.in_new_window(display_data) - @staticmethod - def save_params_to_hdf(display_data: dict, file: str, prefix: str = ''): - """Saves image projection display_data parameters to given file. Data is stored as: PREFIX + Folder/Field_1 - - Parameters - ---------- - display_data: dict - The values to save to the given file. Should be the display_data dict from an ImageProjection instance. - file : str - HDF file to save to - prefix : str, optional - Prefix to append to folder path within HDF file (folders must be separated by "/"). - Default is empty string ''. - """ - # Extract data entries - datasets = [] - data = [] - for field in display_data.keys(): - datasets.append(prefix + 'ImageProjection/' + field) - data.append(display_data[field]) - - # Save data - hdf5_tools.save_hdf5_datasets(data, datasets, file) - - def save_to_hdf(self, file: str, prefix: str = ''): + def save_to_hdf(self, file: str, prefix: str = '') -> None: """Saves image projection display_data parameters to given file. Data is stored as: PREFIX + Folder/Field_1 Parameters @@ -505,7 +601,7 @@ def save_to_hdf(self, file: str, prefix: str = ''): Prefix to append to folder path within HDF file (folders must be separated by "/"). Default is empty string ''. """ - self.save_params_to_hdf(self.display_data, file, prefix) + self.display_data.save_to_hdf(file, prefix) def close(self): """Closes all windows""" @@ -525,3 +621,12 @@ def close(self): with et.ignored(Exception): self.root.destroy() + + def _make_aruco_marker(self, width: int, id_: int) -> np.ndarray: + """Returns NxMx3 aruco marker array""" + # Define marker image + img_2d = np.ones((width, width), dtype='uint8') * self.display_data.projector_max_int + + # Create marker image + cv.aruco.drawMarker(self.aruco_dictionary, id_, width, img_2d) + return np.concatenate([img_2d[:, :, None]] * 3, axis=2) diff --git a/opencsp/common/lib/deflectometry/ImageProjectionSetupGUI.py b/opencsp/common/lib/deflectometry/ImageProjectionSetupGUI.py index cd4e2049..4cdb4fad 100644 --- a/opencsp/common/lib/deflectometry/ImageProjectionSetupGUI.py +++ b/opencsp/common/lib/deflectometry/ImageProjectionSetupGUI.py @@ -1,59 +1,37 @@ -"""Graphical User Interface (GUI) used for setting up the physical layout -of a computer display (ImageProjection class). To run GUI, use the following: -```python ImageProjectionGUI.py``` - -""" - import tkinter from tkinter import messagebox from tkinter.filedialog import askopenfilename from tkinter.filedialog import asksaveasfilename -from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection +from opencsp.common.lib.deflectometry.ImageProjection import ImageProjection, ImageProjectionData import opencsp.common.lib.tool.tk_tools as tkt +from opencsp.common.lib.tool.hdf5_tools import HDF5_IO_Abstract -class ImageProjectionGUI: - """ - GUI for setting the physical layout parameters: - 'name' - 'win_size_x' - 'win_size_y' - 'win_position_x' - 'win_position_y' - 'size_x' - 'size_y' - 'position_x' - 'position_y' - 'projector_data_type' - 'projector_max_int' - 'shift_red_x' - 'shift_red_y' - 'shift_blue_x' - 'shift_blue_y' +class ImageProjectionGUI(HDF5_IO_Abstract): + """Graphical User Interface (GUI) used for setting up the physical layout + of a computer display (ImageProjection class). To run GUI, run `python ImageProjectionGUI.py` """ def __init__(self): - """Instantiates a new instance of the ImageProjection setup GUI in a new window""" # Define data names self.data_names = [ 'name', - 'win_size_x', - 'win_size_y', - 'win_position_x', - 'win_position_y', - 'size_x', - 'size_y', - 'position_x', - 'position_y', + 'main_window_size_x', + 'main_window_size_y', + 'main_window_position_x', + 'main_window_position_y', + 'active_area_size_x', + 'active_area_size_y', + 'active_area_position_x', + 'active_area_position_y', 'projector_data_type', 'projector_max_int', - 'image_delay', + 'image_delay_ms', 'shift_red_x', 'shift_red_y', 'shift_blue_x', 'shift_blue_y', - 'ui_position_x', ] self.data_labels = [ 'Name', @@ -72,13 +50,12 @@ def __init__(self): 'Red Shift Y', 'Blue Shift X', 'Blue Shift Y', - 'GUI X Position', ] - self.data_types = [str, int, int, int, int, int, int, int, int, str, int, int, int, int, int, int, int] + self.data_types = [str, int, int, int, int, int, int, int, int, str, int, int, int, int, int, int] # Declare variables self.projector: ImageProjection - self.display_data: dict + self.display_data: ImageProjectionData # Create tkinter object self.root = tkt.window() @@ -96,15 +73,15 @@ def __init__(self): self.load_defaults() # Place window - self.update_window_size() + self.set_window_size() # Run window infinitely self.root.mainloop() - def update_window_size(self): + def set_window_size(self): """Updates the window size to current set value""" # Set size and position of window - self.root.geometry(f'500x650+{self.display_data["ui_position_x"]:d}+100') + self.root.geometry('500x670+100+100') def create_layout(self): """Creates GUI widgets""" @@ -118,12 +95,12 @@ def create_layout(self): self.data_cells.append(e) # Show projector button - self.btn_show_proj = tkinter.Button(self.root, text='Show Display', command=self.show_projector) + self.btn_show_proj = tkinter.Button(self.root, text='Show Display', command=self.show_projection_window) r += 1 self.btn_show_proj.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') # Update projector button - self.btn_update_proj = tkinter.Button(self.root, text='Update All', command=self.update_windows) + self.btn_update_proj = tkinter.Button(self.root, text='Update All', command=self.update_projection_window) r += 1 self.btn_update_proj.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') @@ -133,7 +110,7 @@ def create_layout(self): self.btn_close_proj.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') # Show crosshairs - self.btn_crosshairs = tkinter.Button(self.root, text='Show Crosshairs', command=self.update_windows) + self.btn_crosshairs = tkinter.Button(self.root, text='Show Crosshairs', command=self.update_projection_window) r += 1 self.btn_crosshairs.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') @@ -143,9 +120,18 @@ def create_layout(self): self.btn_axes.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') # Show calibration image button - self.btn_calib = tkinter.Button(self.root, text='Show calibration image', command=self.show_calibration_image) + self.btn_calib_fid = tkinter.Button( + self.root, text='Show calibration fiducial image', command=self.show_calibration_fiducial_image + ) r += 1 - self.btn_calib.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') + self.btn_calib_fid.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') + + # Show calibration image button + self.btn_calib_mkr = tkinter.Button( + self.root, text='Show calibration marker image', command=self.show_calibration_marker_image + ) + r += 1 + self.btn_calib_mkr.grid(row=r, column=0, pady=2, padx=2, sticky='nesw') # Save as button self.btn_save = tkinter.Button(self.root, text='Save as HDF...', command=self.save_as) @@ -188,19 +174,14 @@ def activate_btns(self, active: bool): self.btn_close_proj['state'] = active_projector self.btn_axes['state'] = active_projector self.btn_crosshairs['state'] = active_projector - self.btn_calib['state'] = active_projector - - def show_projector(self): - """ - Opens the projector window. + self.btn_calib_fid['state'] = active_projector + self.btn_calib_mkr['state'] = active_projector - """ + def show_projection_window(self): + """Opens the ImageProjection window.""" # Get user data self.get_user_data() - # Update GUI size - self.update_window_size() - # Create a new Toplevel window projector_root = tkt.window(self.root, TopLevel=True) self.projector = ImageProjection(projector_root, self.display_data) @@ -208,39 +189,41 @@ def show_projector(self): # Activate buttons self.activate_btns(True) - def update_windows(self): - """ - Updates the projector active area data, updates the window size, and - shows crosshairs. - - """ + def update_projection_window(self): + """Updates the projector active area data, updates the window size, and shows crosshairs.""" # Read data from entry cells self.get_user_data() # Update size of window - self.projector.upate_window(self.display_data) + self.projector.display_data = self.display_data + self.projector.update_window() # Show crosshairs self.projector.show_crosshairs() - # Update position of UI window - self.update_window_size() - def show_axes(self): """Shows axis labels.""" # Update active area of projector - self.update_windows() + self.update_projection_window() # Show X/Y axes self.projector.show_axes() - def show_calibration_image(self): + def show_calibration_fiducial_image(self): """Shows calibration image""" # Update active area of projector - self.update_windows() + self.update_projection_window() # Show cal image - self.projector.show_calibration_image() + self.projector.show_calibration_fiducial_image() + + def show_calibration_marker_image(self): + """Shows calibration Aruco marker image""" + # Update active area of projection + self.update_projection_window() + + # Show cal image + self.projector.show_calibration_marker_image() def close_projector(self): """ @@ -279,8 +262,7 @@ def load_from(self): def get_user_data(self): """ - Reads user input data and saves in dictionary. - + Reads user input data and saves in ImageProjectionData class (`self.display_data`). """ # Check inputs format if not self.check_inputs(): @@ -288,9 +270,9 @@ def get_user_data(self): # Gets data from user input boxes and saves in class data = {} - for dtype, name, entry in zip(self.data_types, self.data_names, self.data_cells): + for dtype, name, entry in zip(self.data_types, self.data_names, self.data_cells, strict=True): data.update({name: dtype(entry.get())}) - self.display_data = data + self.display_data = ImageProjectionData(**data) def check_inputs(self) -> bool: """ @@ -303,7 +285,7 @@ def check_inputs(self) -> bool: """ # Checks inputs are correct - for name, dtype, entry in zip(self.data_labels, self.data_types, self.data_cells): + for name, dtype, entry in zip(self.data_labels, self.data_types, self.data_cells, strict=True): try: dtype(entry.get()) except ValueError: @@ -312,71 +294,47 @@ def check_inputs(self) -> bool: return True def set_user_data(self): - """ - Sets the loaded user data in the user input boxes - - """ + """Sets the loaded user data in the user input boxes""" for name, entry in zip(self.data_names, self.data_cells): entry.delete(0, tkinter.END) - entry.insert(0, self.display_data[name]) + entry.insert(0, self.display_data.__dict__[name]) def load_defaults(self): - """ - Sets default values. - - """ - self.display_data = { + """Sets default values.""" + kwargs = { 'name': 'Default Image Projection', - 'win_size_x': 800, - 'win_size_y': 500, - 'win_position_x': 0, - 'win_position_y': 0, - 'size_x': 700, - 'size_y': 400, - 'position_x': 50, - 'position_y': 50, + 'main_window_size_x': 800, + 'main_window_size_y': 500, + 'main_window_position_x': 0, + 'main_window_position_y': 0, + 'active_area_size_x': 700, + 'active_area_size_y': 400, + 'active_area_position_x': 50, + 'active_area_position_y': 50, 'projector_data_type': 'uint8', 'projector_max_int': 255, - 'image_delay': 400, + 'image_delay_ms': 400, 'shift_red_x': 0, 'shift_red_y': 0, 'shift_blue_x': 0, 'shift_blue_y': 0, - 'ui_position_x': 100, } + self.display_data = ImageProjectionData(**kwargs) self.set_user_data() - def load_from_hdf(self, file: str): - """ - Loads saved user data from HDF file - - Parameters - ---------- - file : str - File to load. - - """ + def load_from_hdf(self, file: str, prefix: str = ''): # Load data - self.display_data = ImageProjection.load_from_hdf(file) + self.display_data = ImageProjectionData.load_from_hdf(file, prefix) # Set data in input fields self.set_user_data() - def save_to_hdf(self, file: str): - """ - Saves user data to HDF file. - - Parameters - ---------- - file : str - File to save to. - - """ + def save_to_hdf(self, file: str, prefix: str = ''): # Load user data self.get_user_data() # Save as HDF file - ImageProjection.save_params_to_hdf(self.display_data, file) + self.display_data.save_to_hdf(file, prefix) def close(self): """ diff --git a/opencsp/common/lib/deflectometry/test/test_ImageProjection.py b/opencsp/common/lib/deflectometry/test/test_ImageProjection.py index aca00941..14bc45a9 100644 --- a/opencsp/common/lib/deflectometry/test/test_ImageProjection.py +++ b/opencsp/common/lib/deflectometry/test/test_ImageProjection.py @@ -1,12 +1,13 @@ import os +import unittest import numpy as np import pytest -import unittest import opencsp.common.lib.deflectometry.ImageProjection as ip import opencsp.common.lib.tool.exception_tools as et import opencsp.common.lib.tool.file_tools as ft +from opencsp.common.lib.opencsp_path.opencsp_root_path import opencsp_code_dir @pytest.mark.no_xvfb @@ -18,15 +19,28 @@ def setUp(self) -> None: ft.create_directories_if_necessary(self.data_dir) ft.create_directories_if_necessary(self.out_dir) + # Load display data + self.file_image_projection_input = os.path.join( + opencsp_code_dir(), 'test/data/sofast_common/image_projection_test.h5' + ) + self.image_projection_data = ip.ImageProjectionData.load_from_hdf(self.file_image_projection_input) + + # Load display data + self.file_image_projection_input = os.path.join( + opencsp_code_dir(), 'test/data/sofast_common/image_projection_test.h5' + ) + self.image_projection_data = ip.ImageProjectionData.load_from_hdf(self.file_image_projection_input) + def tearDown(self): with et.ignored(Exception): ip.ImageProjection.instance().close() def test_set_image_projection(self): + """Test setting/unsetting ImageProjection instances""" self.assertIsNone(ip.ImageProjection.instance()) # Create a mock ImageProjection object - image_projection = _ImageProjection.in_new_window(_ImageProjection.display_dict) + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) # Test that the instance was set self.assertEqual(image_projection, ip.ImageProjection.instance()) @@ -36,6 +50,7 @@ def test_set_image_projection(self): self.assertIsNone(ip.ImageProjection.instance()) def test_on_close(self): + """Tests closing callback""" global close_count close_count = 0 @@ -44,73 +59,62 @@ def close_count_inc(image_projection): close_count += 1 # Create a mock ImageProjection object with single on_close callback - image_projection = _ImageProjection.in_new_window(_ImageProjection.display_dict) + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) image_projection.on_close.append(close_count_inc) image_projection.close() self.assertEqual(close_count, 1) # Create a mock ImageProjection object with multiple on_close callback - image_projection = _ImageProjection.in_new_window(_ImageProjection.display_dict) + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) image_projection.on_close.append(close_count_inc) image_projection.on_close.append(close_count_inc) image_projection.close() self.assertEqual(close_count, 3) # Create a mock ImageProjection object without an on_close callback - image_projection = _ImageProjection.in_new_window(_ImageProjection.display_dict) + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) image_projection.close() self.assertEqual(close_count, 3) def test_zeros(self): + """Tests the shape of the 'zeros' array to fill active area""" # Create a mock ImageProjection object - image_projection = _ImageProjection.in_new_window(_ImageProjection.display_dict) + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) + image_projection_data = ip.ImageProjectionData.load_from_hdf(self.file_image_projection_input) # Get the zeros array and verify its shape and values zeros = image_projection.get_black_array_active_area() - self.assertEqual((480, 640, 3), zeros.shape) + self.assertEqual( + (image_projection_data.active_area_size_y, image_projection_data.active_area_size_x, 3), zeros.shape + ) ones = zeros + 1 - self.assertEqual(np.sum(ones), 640 * 480 * 3) + self.assertEqual( + np.sum(ones), image_projection_data.active_area_size_y * image_projection_data.active_area_size_x * 3 + ) def test_to_from_hdf(self): - h5file = os.path.join(self.out_dir, "test_to_from_hdf.h5") + """Loads and saves from/to HDF5 files""" + # Load from HDF + image_projection = ip.ImageProjection.load_from_hdf(self.file_image_projection_input) - # Create a mock ImageProjection object - image_projection = _ImageProjection.in_new_window(_ImageProjection.display_dict) + # Create output file + file_h5_output = os.path.join(self.out_dir, "test_to_from_hdf.h5") # Save to HDF - ft.delete_file(h5file, error_on_not_exists=False) - self.assertFalse(ft.file_exists(h5file)) - image_projection.save_to_hdf(h5file) - self.assertTrue(ft.file_exists(h5file)) + ft.delete_file(file_h5_output, error_on_not_exists=False) + self.assertFalse(ft.file_exists(file_h5_output)) + image_projection.save_to_hdf(file_h5_output) + self.assertTrue(ft.file_exists(file_h5_output)) # Close, so that we don't have multiple windows open at a time image_projection.close() + def test_run_wait_close(self): + """Loads, waits, closes ImageProjection window""" # Load from HDF - image_projection2 = _ImageProjection.load_from_hdf_and_display(h5file) - self.assertEqual(image_projection2.display_data, _ImageProjection.display_dict) - - -class _ImageProjection(ip.ImageProjection): - display_dict = { - 'name': 'smoll_for_unit_tests', - 'win_size_x': 640, - 'win_size_y': 480, - 'win_position_x': 0, - 'win_position_y': 0, - 'size_x': 640, - 'size_y': 480, - 'position_x': 0, - 'position_y': 0, - 'projector_max_int': 255, - 'projector_data_type': "uint8", - 'shift_red_x': 0, - 'shift_red_y': 0, - 'shift_blue_x': 0, - 'shift_blue_y': 0, - 'image_delay': 1, - 'ui_position_x': 0, - } + image_projection = ip.ImageProjection.in_new_window(self.image_projection_data) + image_projection.root.after(500, image_projection.close) + image_projection.run() if __name__ == '__main__': diff --git a/opencsp/test/data/sofast_common/image_projection.h5 b/opencsp/test/data/sofast_common/image_projection.h5 index 7e811bce..51b6000c 100644 Binary files a/opencsp/test/data/sofast_common/image_projection.h5 and b/opencsp/test/data/sofast_common/image_projection.h5 differ diff --git a/opencsp/test/data/sofast_common/image_projection_test.h5 b/opencsp/test/data/sofast_common/image_projection_test.h5 index c2f66fdd..9c448a4b 100644 Binary files a/opencsp/test/data/sofast_common/image_projection_test.h5 and b/opencsp/test/data/sofast_common/image_projection_test.h5 differ