From 3d22835b41fe702b0ba8bcc5df61101af617bb3d Mon Sep 17 00:00:00 2001 From: builder Date: Mon, 16 Mar 2026 17:14:52 +0000 Subject: [PATCH] Samples: Automatic updates to public repository Remember to do the following: 1. Ensure that modified/deleted/new files are correct 2. Make this commit message relevant for the changes 3. Force push 4. Delete branch after PR is merged If this commit is an update from one SDK version to another, make sure to create a release tag for previous version. --- .../zividsamples/gui/calibration/__init__.py | 0 .../calibration/calibration_buttons_widget.py | 53 ++ .../hand_eye_calibration_gui.py | 162 ++++- .../hand_eye_settings_tester.py | 16 +- .../calibration/pose_pair_selection_widget.py | 646 +++++++++++++++++ .../{ => calibration}/set_fixed_objects.py | 10 +- modules/zividsamples/gui/hand_eye_app.py | 665 ++++++++++++++++++ .../gui/pose_pair_selection_widget.py | 374 ---------- .../zividsamples/gui/preparation/__init__.py | 0 ...nfield_correction_data_selection_widget.py | 92 ++- .../infield_correction_gui.py | 26 +- .../infield_correction_result_widget.py | 0 .../gui/{ => preparation}/warmup_gui.py | 4 +- modules/zividsamples/gui/qt_application.py | 38 +- modules/zividsamples/gui/robot/__init__.py | 0 .../gui/{ => robot}/robot_control.py | 2 +- .../gui/{ => robot}/robot_control_robodk.py | 4 +- .../robot_control_ur_rtde_read_only.py | 4 +- .../gui/{ => robot}/robot_control_widget.py | 8 +- .../zividsamples/gui/verification/__init__.py | 0 .../capture_at_pose_selection_widget.py | 180 ++++- .../hand_eye_verification_gui.py | 20 +- .../gui/{ => verification}/stitch_gui.py | 23 +- .../gui/{ => verification}/touch_gui.py | 22 +- .../verification_buttons_widget.py | 45 ++ modules/zividsamples/gui/widgets/__init__.py | 0 .../gui/{ => widgets}/aspect_ratio_label.py | 0 .../camera_buttons_widget.py} | 98 +-- .../gui/{ => widgets}/cv2_handler.py | 2 +- .../{ => widgets}/detection_visualization.py | 4 +- modules/zividsamples/gui/{ => widgets}/fov.py | 0 .../gui/{ => widgets}/image_viewer.py | 0 .../gui/{ => widgets}/live_2d_widget.py | 2 +- .../{ => widgets}/pointcloud_visualizer.py | 0 .../gui/{ => widgets}/pose_widget.py | 4 +- .../gui/{ => widgets}/show_yaml_dialog.py | 0 .../gui/{ => widgets}/tab_content_widget.py | 27 +- .../{ => widgets}/tab_with_robot_support.py | 6 +- .../gui/{ => widgets}/tutorial_widget.py | 0 modules/zividsamples/gui/wizard/__init__.py | 0 .../gui/{ => wizard}/camera_selection.py | 0 .../gui/{ => wizard}/data_directory.py | 38 +- .../{ => wizard}/hand_eye_configuration.py | 0 .../marker_configuration.py} | 4 +- .../gui/{ => wizard}/robot_configuration.py | 0 .../rotation_format_configuration.py | 0 .../gui/{ => wizard}/settings_selector.py | 12 +- .../gui/{ => wizard}/touch_configuration.py | 0 modules/zividsamples/images/LogoZBlue.png | Bin 0 -> 5319 bytes .../hand_eye_calibration/hand_eye_gui.py | 641 ++--------------- .../pose_conversion_gui.py | 4 +- 51 files changed, 2010 insertions(+), 1226 deletions(-) create mode 100644 modules/zividsamples/gui/calibration/__init__.py create mode 100644 modules/zividsamples/gui/calibration/calibration_buttons_widget.py rename modules/zividsamples/gui/{ => calibration}/hand_eye_calibration_gui.py (70%) rename modules/zividsamples/gui/{ => calibration}/hand_eye_settings_tester.py (96%) create mode 100644 modules/zividsamples/gui/calibration/pose_pair_selection_widget.py rename modules/zividsamples/gui/{ => calibration}/set_fixed_objects.py (97%) create mode 100644 modules/zividsamples/gui/hand_eye_app.py delete mode 100644 modules/zividsamples/gui/pose_pair_selection_widget.py create mode 100644 modules/zividsamples/gui/preparation/__init__.py rename modules/zividsamples/gui/{ => preparation}/infield_correction_data_selection_widget.py (92%) rename modules/zividsamples/gui/{ => preparation}/infield_correction_gui.py (91%) rename modules/zividsamples/gui/{ => preparation}/infield_correction_result_widget.py (100%) rename modules/zividsamples/gui/{ => preparation}/warmup_gui.py (99%) create mode 100644 modules/zividsamples/gui/robot/__init__.py rename modules/zividsamples/gui/{ => robot}/robot_control.py (96%) rename modules/zividsamples/gui/{ => robot}/robot_control_robodk.py (98%) rename modules/zividsamples/gui/{ => robot}/robot_control_ur_rtde_read_only.py (96%) rename modules/zividsamples/gui/{ => robot}/robot_control_widget.py (98%) create mode 100644 modules/zividsamples/gui/verification/__init__.py rename modules/zividsamples/gui/{ => verification}/capture_at_pose_selection_widget.py (60%) rename modules/zividsamples/gui/{ => verification}/hand_eye_verification_gui.py (96%) rename modules/zividsamples/gui/{ => verification}/stitch_gui.py (91%) rename modules/zividsamples/gui/{ => verification}/touch_gui.py (91%) create mode 100644 modules/zividsamples/gui/verification/verification_buttons_widget.py create mode 100644 modules/zividsamples/gui/widgets/__init__.py rename modules/zividsamples/gui/{ => widgets}/aspect_ratio_label.py (100%) rename modules/zividsamples/gui/{buttons_widget.py => widgets/camera_buttons_widget.py} (50%) rename modules/zividsamples/gui/{ => widgets}/cv2_handler.py (99%) rename modules/zividsamples/gui/{ => widgets}/detection_visualization.py (97%) rename modules/zividsamples/gui/{ => widgets}/fov.py (100%) rename modules/zividsamples/gui/{ => widgets}/image_viewer.py (100%) rename modules/zividsamples/gui/{ => widgets}/live_2d_widget.py (99%) rename modules/zividsamples/gui/{ => widgets}/pointcloud_visualizer.py (100%) rename modules/zividsamples/gui/{ => widgets}/pose_widget.py (99%) rename modules/zividsamples/gui/{ => widgets}/show_yaml_dialog.py (100%) rename modules/zividsamples/gui/{ => widgets}/tab_content_widget.py (62%) rename modules/zividsamples/gui/{ => widgets}/tab_with_robot_support.py (74%) rename modules/zividsamples/gui/{ => widgets}/tutorial_widget.py (100%) create mode 100644 modules/zividsamples/gui/wizard/__init__.py rename modules/zividsamples/gui/{ => wizard}/camera_selection.py (100%) rename modules/zividsamples/gui/{ => wizard}/data_directory.py (91%) rename modules/zividsamples/gui/{ => wizard}/hand_eye_configuration.py (100%) rename modules/zividsamples/gui/{marker_widget.py => wizard/marker_configuration.py} (99%) rename modules/zividsamples/gui/{ => wizard}/robot_configuration.py (100%) rename modules/zividsamples/gui/{ => wizard}/rotation_format_configuration.py (100%) rename modules/zividsamples/gui/{ => wizard}/settings_selector.py (99%) rename modules/zividsamples/gui/{ => wizard}/touch_configuration.py (100%) create mode 100644 modules/zividsamples/images/LogoZBlue.png diff --git a/modules/zividsamples/gui/calibration/__init__.py b/modules/zividsamples/gui/calibration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/zividsamples/gui/calibration/calibration_buttons_widget.py b/modules/zividsamples/gui/calibration/calibration_buttons_widget.py new file mode 100644 index 00000000..b01bfbdc --- /dev/null +++ b/modules/zividsamples/gui/calibration/calibration_buttons_widget.py @@ -0,0 +1,53 @@ +from typing import List + +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QApplication, QCheckBox, QGroupBox, QHBoxLayout, QPushButton, QWidget + + +class HandEyeCalibrationButtonsWidget(QWidget): + calibrate_button_clicked = pyqtSignal() + use_fixed_objects_toggled = pyqtSignal(bool) + + def __init__(self, parent=None): + super().__init__(parent) + + # Define buttons + self.calibrate_button = QPushButton("Calibrate") + self.calibrate_button.setObjectName("HandEye-calibrate_button") + self.use_fixed_objects_checkbox = QCheckBox("Fixed Objects - for low DOF systems") + self.use_fixed_objects_checkbox.setObjectName("HandEye-fixed_objects_checkbox") + + # Connect signals + self.calibrate_button.clicked.connect(self.on_calibrate_button_clicked) + self.use_fixed_objects_checkbox.toggled.connect(self.on_use_fixed_objects_toggled) + + # Add buttons to layout + calibrate_group_box = QGroupBox("Calibrate") + calibrate_group_box_layout = QHBoxLayout() + calibrate_group_box.setLayout(calibrate_group_box_layout) + + calibrate_group_box_layout.addWidget(self.calibrate_button) + calibrate_group_box_layout.addWidget(self.use_fixed_objects_checkbox) + + buttons_layout = QHBoxLayout() + buttons_layout.addWidget(calibrate_group_box) + + self.setLayout(buttons_layout) + + def on_calibrate_button_clicked(self): + self.calibrate_button.setStyleSheet("background-color: yellow;") + QApplication.processEvents() + self.calibrate_button_clicked.emit() + self.calibrate_button.setStyleSheet("") + + def on_use_fixed_objects_toggled(self, checked: bool): + self.use_fixed_objects_toggled.emit(checked) + + def disable_buttons(self): + self.calibrate_button.setEnabled(False) + + def enable_buttons(self): + self.calibrate_button.setEnabled(True) + + def get_tab_widgets_in_order(self) -> List[QWidget]: + return [self.calibrate_button] diff --git a/modules/zividsamples/gui/hand_eye_calibration_gui.py b/modules/zividsamples/gui/calibration/hand_eye_calibration_gui.py similarity index 70% rename from modules/zividsamples/gui/hand_eye_calibration_gui.py rename to modules/zividsamples/gui/calibration/hand_eye_calibration_gui.py index 19846f57..e6c6b972 100644 --- a/modules/zividsamples/gui/hand_eye_calibration_gui.py +++ b/modules/zividsamples/gui/calibration/hand_eye_calibration_gui.py @@ -8,7 +8,7 @@ import copy from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple import numpy as np import zivid @@ -16,21 +16,27 @@ from PyQt5.QtCore import QSignalBlocker, pyqtSignal from PyQt5.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QVBoxLayout, QWidget from zivid.experimental.hand_eye_low_dof import calibrate_eye_in_hand_low_dof, calibrate_eye_to_hand_low_dof -from zividsamples.gui.buttons_widget import HandEyeCalibrationButtonsWidget -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.detection_visualization import DetectionVisualizationWidget -from zividsamples.gui.hand_eye_configuration import CalibrationObject, HandEyeConfiguration -from zividsamples.gui.marker_widget import MarkerConfiguration -from zividsamples.gui.pose_pair_selection_widget import PosePair, PosePairSelectionWidget -from zividsamples.gui.pose_widget import PoseWidget, PoseWidgetDisplayMode -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.rotation_format_configuration import RotationInformation -from zividsamples.gui.set_fixed_objects import FixedCalibrationObjectsData, set_fixed_objects -from zividsamples.gui.settings_selector import SettingsPixelMappingIntrinsics -from zividsamples.gui.show_yaml_dialog import show_yaml_dialog -from zividsamples.gui.tab_with_robot_support import TabWidgetWithRobotSupport -from zividsamples.save_load_transformation_matrix import load_transformation_matrix, save_transformation_matrix +from zividsamples.gui.calibration.calibration_buttons_widget import HandEyeCalibrationButtonsWidget +from zividsamples.gui.calibration.pose_pair_selection_widget import ( + PosePair, + PosePairSelectionWidget, + SessionCalibrationConfig, + load_calibration_config, + save_calibration_config, +) +from zividsamples.gui.calibration.set_fixed_objects import FixedCalibrationObjectsData, set_fixed_objects +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.detection_visualization import DetectionVisualizationWidget +from zividsamples.gui.widgets.pose_widget import PoseWidget, PoseWidgetDisplayMode +from zividsamples.gui.widgets.show_yaml_dialog import show_yaml_dialog +from zividsamples.gui.widgets.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject, HandEyeConfiguration +from zividsamples.gui.wizard.marker_configuration import MarkerConfiguration +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation +from zividsamples.gui.wizard.settings_selector import SettingsPixelMappingIntrinsics +from zividsamples.save_load_transformation_matrix import save_transformation_matrix from zividsamples.save_residuals import save_residuals from zividsamples.transformation_matrix import TransformationMatrix @@ -45,6 +51,7 @@ class HandEyeCalibrationGUI(TabWidgetWithRobotSupport): checkerboard_pose_in_camera_frame: Optional[TransformationMatrix] = None minimum_pose_pairs_for_calibration: int = 6 calibration_finished = pyqtSignal(TransformationMatrix) + loading_finished = pyqtSignal() instructions_updated: pyqtSignal = pyqtSignal() description: List[str] fixed_objects: FixedCalibrationObjectsData @@ -139,6 +146,13 @@ def connect_signals(self): self.robot_pose_widget.pose_updated.connect(self.on_robot_pose_manually_updated) self.pose_pair_selection_widget.pose_pair_clicked.connect(self.on_pose_pair_clicked) self.pose_pair_selection_widget.pose_pairs_updated.connect(self.on_pose_pairs_update) + self.pose_pair_selection_widget.loading_finished.connect(self._on_pose_pairs_loading_finished) + + def _on_pose_pairs_loading_finished(self) -> None: + self.loading_finished.emit() + if self.pose_pair_selection_widget.number_of_active_pose_pairs() >= self.minimum_pose_pairs_for_calibration: + save_to_disk = self.pose_pair_selection_widget.last_operation_was_reprocess + self._calibrate(save_to_disk=save_to_disk) def update_instructions(self, has_detection_result: bool, robot_pose_confirmed: bool, calibrated: bool): self.has_confirmed_robot_pose = robot_pose_confirmed @@ -165,16 +179,68 @@ def update_instructions(self, has_detection_result: bool, robot_pose_confirmed: ) self.confirm_robot_pose_button.setChecked(self.has_confirmed_robot_pose) + def _resolve_config_from_saved_session( + self, + saved_config: SessionCalibrationConfig, + calibration_object: CalibrationObject, + marker_configuration: MarkerConfiguration, + ) -> Tuple[CalibrationObject, MarkerConfiguration]: + """If saved config differs from current, ask user and return (calibration_object, marker_configuration).""" + mismatches = [] + if saved_config.calibration_object != calibration_object: + mismatches.append( + f"Calibration object: session={saved_config.calibration_object.name}, " + f"current={calibration_object.name}" + ) + if saved_config.eye_in_hand != self.hand_eye_configuration.eye_in_hand: + saved_label = "Eye-In-Hand" if saved_config.eye_in_hand else "Eye-To-Hand" + current_label = "Eye-In-Hand" if self.hand_eye_configuration.eye_in_hand else "Eye-To-Hand" + mismatches.append(f"Configuration: session={saved_label}, current={current_label}") + if not mismatches: + return (calibration_object, marker_configuration) + reply = QMessageBox.question( + self, + "Configuration Mismatch", + "The saved session was captured with a different configuration:\n\n" + + "\n".join(f" - {m}" for m in mismatches) + + "\n\nLoad with saved configuration?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return (calibration_object, marker_configuration) + new_marker_config = marker_configuration + if saved_config.marker_ids is not None and saved_config.marker_dictionary is not None: + new_marker_config = MarkerConfiguration( + id_list=saved_config.marker_ids, dictionary=saved_config.marker_dictionary + ) + return (saved_config.calibration_object, new_marker_config) + def on_pending_changes(self): - if self.data_directory_has_data(): - self.pose_pair_selection_widget.set_directory(self.data_directory) - self.pose_pair_selection_widget.load_pose_pairs( - calibration_object=self.hand_eye_configuration.calibration_object, - marker_configuration=self.marker_configuration, + self.pose_pair_selection_widget.clear() + self.pose_pair_selection_widget.set_directory(self.data_directory) + if not self.data_directory_has_data(): + return + calibration_object = self.hand_eye_configuration.calibration_object + marker_configuration = self.marker_configuration + if self.session_info is not None: + saved_config = load_calibration_config(self.session_info) + if saved_config is not None and self.is_current_tab(): + calibration_object, marker_configuration = self._resolve_config_from_saved_session( + saved_config, calibration_object, marker_configuration + ) + save_calibration_config( + self.session_info, + calibration_object, + self.hand_eye_configuration.eye_in_hand, + marker_configuration, ) - self.calibration_finished.emit(load_transformation_matrix(self.data_directory / "hand_eye_transform.yaml")) - else: - self.pose_pair_selection_widget.set_directory(self.data_directory) + self.pose_pair_selection_widget.load_pose_pairs( + calibration_object=calibration_object, + marker_configuration=marker_configuration, + ) + + def is_loading(self) -> bool: + return self.pose_pair_selection_widget.is_loading() def on_tab_visibility_changed(self, is_current: bool): pass @@ -187,10 +253,35 @@ def hand_eye_configuration_update(self, hand_eye_configuration: HandEyeConfigura 4 if self.hand_eye_configuration.calibration_object == CalibrationObject.Checkerboard else 6 ) self.fixed_objects.update_hand_eye_configuration(self.hand_eye_configuration) + self._prompt_reprocess_if_needed() def marker_configuration_update(self, marker_configuration: MarkerConfiguration): self.marker_configuration = marker_configuration self.fixed_objects.update_marker_configuration(self.marker_configuration) + self._prompt_reprocess_if_needed() + + def _prompt_reprocess_if_needed(self) -> None: + if self.pose_pair_selection_widget.number_of_active_pose_pairs() == 0: + return + reply = QMessageBox.question( + self, + "Reprocess Pose Pairs", + "Do you want to reload data with the new configuration?\n\n" + "Detection results will be recalculated from the captured frames.", + QMessageBox.Yes | QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.pose_pair_selection_widget.reprocess_pose_pairs( + self.hand_eye_configuration.calibration_object, + self.marker_configuration, + ) + if self.session_info is not None: + save_calibration_config( + self.session_info, + self.hand_eye_configuration.calibration_object, + self.hand_eye_configuration.eye_in_hand, + self.marker_configuration, + ) def rotation_format_update(self, rotation_information: RotationInformation): self.robot_pose_widget.set_rotation_format(rotation_information) @@ -289,6 +380,13 @@ def process_capture(self, frame: zivid.Frame, rgba: NDArray[Shape["N, M, 4"], UI def use_data(self): self.pose_pair_selection_widget.add_pose_pair(self.pose_pair) + if self.session_info is not None: + save_calibration_config( + self.session_info, + self.hand_eye_configuration.calibration_object, + self.hand_eye_configuration.eye_in_hand, + self.marker_configuration, + ) self.update_instructions( has_detection_result=False, robot_pose_confirmed=False, @@ -305,6 +403,9 @@ def on_use_fixed_objects_toggled(self, checked: bool): self.fixed_objects = updated_fixed_objects def on_calibrate_button_clicked(self): + self._calibrate(save_to_disk=True) + + def _calibrate(self, save_to_disk: bool = True) -> None: try: detection_results = self.pose_pair_selection_widget.get_detection_results() calibration_result = ( @@ -324,14 +425,17 @@ def on_calibrate_button_clicked(self): print("Hand-Eye calibration OK") print(f"Result:\n{calibration_result}") hand_eye_transformation_matrix = TransformationMatrix.from_matrix(calibration_result.transform()) - hand_eye_transform_path = self.data_directory / "hand_eye_transform.yaml" - save_transformation_matrix(hand_eye_transformation_matrix, hand_eye_transform_path) - hand_eye_residuals_path = self.data_directory / "hand_eye_residuals.yaml" - save_residuals(calibration_result.residuals(), hand_eye_residuals_path) + if save_to_disk: + hand_eye_transform_path = self.data_directory / "hand_eye_transform.yaml" + save_transformation_matrix(hand_eye_transformation_matrix, hand_eye_transform_path) + hand_eye_residuals_path = self.data_directory / "hand_eye_residuals.yaml" + save_residuals(calibration_result.residuals(), hand_eye_residuals_path) self.pose_pair_selection_widget.set_residuals(calibration_result.residuals()) - show_yaml_dialog(hand_eye_transform_path, "Hand Eye Calibration Transform") + if save_to_disk: + hand_eye_transform_path = self.data_directory / "hand_eye_transform.yaml" + show_yaml_dialog(hand_eye_transform_path, "Hand Eye Calibration Transform") self.update_instructions( has_detection_result=False, robot_pose_confirmed=False, diff --git a/modules/zividsamples/gui/hand_eye_settings_tester.py b/modules/zividsamples/gui/calibration/hand_eye_settings_tester.py similarity index 96% rename from modules/zividsamples/gui/hand_eye_settings_tester.py rename to modules/zividsamples/gui/calibration/hand_eye_settings_tester.py index f1913c24..0fb38f64 100644 --- a/modules/zividsamples/gui/hand_eye_settings_tester.py +++ b/modules/zividsamples/gui/calibration/hand_eye_settings_tester.py @@ -27,15 +27,15 @@ from zivid.calibration import DetectionResult, DetectionResultFiducialMarkers, MarkerShape from zivid.experimental import PixelMapping from zividsamples.display import display_pointcloud -from zividsamples.gui.buttons_widget import CameraButtonsWidget -from zividsamples.gui.camera_selection import select_camera -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.hand_eye_configuration import CalibrationObject, HandEyeButtonsWidget, HandEyeConfiguration -from zividsamples.gui.image_viewer import ImageViewer, ImageViewerDialog -from zividsamples.gui.live_2d_widget import Live2DWidget -from zividsamples.gui.marker_widget import MarkersWidget from zividsamples.gui.qt_application import ZividQtApplication -from zividsamples.gui.settings_selector import SettingsForHandEyeGUI, select_settings_for_hand_eye +from zividsamples.gui.widgets.camera_buttons_widget import CameraButtonsWidget +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.image_viewer import ImageViewer, ImageViewerDialog +from zividsamples.gui.widgets.live_2d_widget import Live2DWidget +from zividsamples.gui.wizard.camera_selection import select_camera +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject, HandEyeButtonsWidget, HandEyeConfiguration +from zividsamples.gui.wizard.marker_configuration import MarkersWidget +from zividsamples.gui.wizard.settings_selector import SettingsForHandEyeGUI, select_settings_for_hand_eye from zividsamples.transformation_matrix import TransformationMatrix diff --git a/modules/zividsamples/gui/calibration/pose_pair_selection_widget.py b/modules/zividsamples/gui/calibration/pose_pair_selection_widget.py new file mode 100644 index 00000000..b065a7c1 --- /dev/null +++ b/modules/zividsamples/gui/calibration/pose_pair_selection_widget.py @@ -0,0 +1,646 @@ +import threading +from collections import OrderedDict +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np +import zivid +from nptyping import Float32, NDArray, Shape +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal +from PyQt5.QtGui import QFontMetrics, QImage +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QSizePolicy, + QSpacerItem, + QVBoxLayout, + QWidget, +) +from zivid.experimental import PixelMapping, calibration +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.wizard.data_directory import SessionInfo +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject +from zividsamples.gui.wizard.marker_configuration import MarkerConfiguration +from zividsamples.transformation_matrix import TransformationMatrix + +_HE_CONFIG_KEY = "hand_eye_configuration" + + +def save_calibration_config( + session_info: SessionInfo, + calibration_object: CalibrationObject, + eye_in_hand: bool, + marker_configuration: Optional[MarkerConfiguration] = None, +) -> None: + he_config: Dict[str, Any] = { + "calibration_object": calibration_object.name, + "eye_in_hand": eye_in_hand, + } + if marker_configuration is not None and calibration_object == CalibrationObject.Markers: + he_config["marker_dictionary"] = marker_configuration.dictionary + he_config["marker_ids"] = marker_configuration.id_list + session_info.set_section(_HE_CONFIG_KEY, he_config) + session_info.save() + + +@dataclass +class SessionCalibrationConfig: + calibration_object: CalibrationObject + eye_in_hand: bool + marker_dictionary: Optional[str] = None + marker_ids: Optional[List[int]] = None + + +def load_calibration_config(session_info: SessionInfo) -> Optional[SessionCalibrationConfig]: + he_config = session_info.get_section(_HE_CONFIG_KEY) + if he_config is None or "calibration_object" not in he_config: + return None + return SessionCalibrationConfig( + calibration_object=CalibrationObject[he_config["calibration_object"]], + eye_in_hand=he_config.get("eye_in_hand", True), + marker_dictionary=he_config.get("marker_dictionary"), + marker_ids=he_config.get("marker_ids"), + ) + + +def _label_width_vector(label: QLabel, size: int) -> int: + font_metrics = QFontMetrics(label.font()) + return font_metrics.width(" -9999.9 " + ", -9999.9" * (size - 1)) + + +def _residual_label_width(label: QLabel) -> int: + font_metrics = QFontMetrics(label.font()) + return font_metrics.width(" -999.9 ( -359.9° )") + + +class ButtonWithLabels(QPushButton): + def __init__(self, labels: List[QLabel], parent=None): + super().__init__(parent) + + self.labels = labels + + layout = QHBoxLayout(self) + for index, label in enumerate(self.labels): + text_alignment = Qt.AlignCenter if index < 2 else Qt.AlignRight | Qt.AlignVCenter + if index == 2: + label.setMinimumWidth(_residual_label_width(label)) + else: + label.setMinimumWidth(_label_width_vector(label=label, size=3)) + label.setAlignment(text_alignment) + layout.addWidget(label) + combined_width = sum(label.sizeHint().width() for label in self.labels) + self.setMinimumWidth(combined_width) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + +@dataclass +class PosePair: + robot_pose: TransformationMatrix + camera_frame: zivid.Frame + qimage_rgba: QImage + detection_result: zivid.calibration.DetectionResult + camera_pose: Optional[TransformationMatrix] = None + + +class PosePairWidget(QWidget): + pose_pair: PosePair + + # pylint: disable=too-many-positional-arguments + def __init__(self, poseID: int, directory: Path, pose_pair: PosePair, save_to_disk: bool = True, parent=None): + super().__init__(parent) + + self.poseID = poseID + self.directory = directory + self.robot_pose_yaml_path: Path = self.directory / f"robot_pose_{self.poseID}.yaml" + self.camera_pose_yaml_path: Path = self.directory / f"checkerboard_pose_in_camera_frame_{self.poseID}.yaml" + self.camera_frame_path: Path = self.directory / f"calibration_object_pose_{self.poseID}.zdf" + self.camera_image_path: Path = self.directory / f"calibration_object_pose_{self.poseID}.png" + self.pose_pair = pose_pair + + self.selected_checkbox = QCheckBox(f"{self.poseID:>2}") + self.selected_checkbox.setLayoutDirection(Qt.RightToLeft) + self.selected_checkbox.setChecked(True) + self.camera_pose_label = QLabel() + self.robot_pose_label = QLabel() + self.clickable_labels = ButtonWithLabels( + [ + self.robot_pose_label, + self.camera_pose_label, + QLabel("NA"), + ] + ) + self.clickable_labels.setCheckable(True) + self.clickable_labels.setChecked(False) + + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + + pose_pair_layout = QHBoxLayout() + pose_pair_layout.addWidget(self.selected_checkbox) + pose_pair_layout.addWidget(self.clickable_labels) # , stretch=1) + self.setLayout(pose_pair_layout) + + self.update_information(pose_pair, save_to_disk=save_to_disk) + + def update_information(self, pose_pair: PosePair, save_to_disk: bool = True): + self.pose_pair = pose_pair + if save_to_disk: + zivid.Matrix4x4(self.pose_pair.robot_pose.as_matrix()).save(self.robot_pose_yaml_path) + self.pose_pair.camera_frame.save(self.camera_frame_path) + if self.pose_pair.camera_pose is not None: + zivid.Matrix4x4(self.pose_pair.camera_pose.as_matrix()).save(self.camera_pose_yaml_path) + self.pose_pair.qimage_rgba.save(str(self.camera_image_path)) + self.camera_pose_label.setText( + "NA" + if self.pose_pair.camera_pose is None + else self._translation_to_string(self.pose_pair.camera_pose.translation) + ) + self.robot_pose_label.setText(self._translation_to_string(self.pose_pair.robot_pose.translation)) + + def camera_pose_yaml_text(self) -> str: + if self.pose_pair.camera_pose is None: + return "" + return self.camera_pose_yaml_path.read_text(encoding="utf-8") + + def robot_pose_yaml_text(self) -> str: + return self.robot_pose_yaml_path.read_text(encoding="utf-8") + + def _translation_to_string(self, translation: NDArray[Shape["3"], Float32]) -> str: # type: ignore + return f"{translation[0]:>8.1f}, {translation[1]:>8.1f}, {translation[2]:.1f}" + + +def directory_has_pose_pair_data(directory: Path) -> bool: + return ( + len(list(directory.glob("robot_pose_*.yaml"))) > 0 + and len(list(directory.glob("calibration_object_pose_*.zdf"))) > 0 + ) + + +class _PosePairLoadWorker(QObject): + """Loads pose pair data from disk in a background thread.""" + + pose_pair_loaded = pyqtSignal(int, int, object) + finished = pyqtSignal(int) + + # pylint: disable=too-many-positional-arguments + def __init__(self, generation, directory, pose_ids, calibration_object, marker_configuration): + super().__init__() + self._generation = generation + self._directory = directory + self._pose_ids = pose_ids + self._calibration_object = calibration_object + self._marker_configuration = marker_configuration + self._cancel_event = threading.Event() + self._cv2_handler = CV2Handler() + + def cancel(self): + self._cancel_event.set() + + def run(self): + for poseID in self._pose_ids: + if self._cancel_event.is_set(): + break + try: + robot_pose_yaml_path = self._directory / f"robot_pose_{poseID}.yaml" + camera_pose_yaml_path = self._directory / f"checkerboard_pose_in_camera_frame_{poseID}.yaml" + camera_frame_path = self._directory / f"calibration_object_pose_{poseID}.zdf" + camera_image_path = self._directory / f"calibration_object_pose_{poseID}.png" + + robot_pose = TransformationMatrix.from_matrix(np.asarray(zivid.Matrix4x4(robot_pose_yaml_path))) + camera_frame = zivid.Frame(camera_frame_path) + + if self._cancel_event.is_set(): + break + + detection_result = ( + zivid.calibration.detect_feature_points(camera_frame.point_cloud()) + if self._calibration_object == CalibrationObject.Checkerboard + else zivid.calibration.detect_markers( + camera_frame, self._marker_configuration.id_list, self._marker_configuration.dictionary + ) + ) + + camera_pose = None + if camera_pose_yaml_path.exists(): + camera_pose = TransformationMatrix.from_matrix(np.asarray(zivid.Matrix4x4(camera_pose_yaml_path))) + elif self._calibration_object == CalibrationObject.Checkerboard and detection_result.valid(): + camera_pose_zivid = detection_result.pose().to_matrix() + zivid.Matrix4x4(camera_pose_zivid).save(camera_pose_yaml_path.as_posix()) + camera_pose = TransformationMatrix.from_matrix(np.asarray(camera_pose_zivid)) + + if camera_image_path.exists() and detection_result.valid(): + qimage_rgba = QImage(str(camera_image_path)) + else: + rgba = camera_frame.point_cloud().copy_data("rgba_srgb") + rgb = rgba[:, :, :3].copy().astype(np.uint8) + if self._calibration_object == CalibrationObject.Markers and detection_result.valid(): + rgba[:, :, :3] = self._cv2_handler.draw_detected_markers( + detection_result.detected_markers(), rgb, PixelMapping() + ) + elif camera_pose is not None: + intrinsics = calibration.estimate_intrinsics(camera_frame) + rgba[:, :, :3] = self._cv2_handler.draw_projected_axis_cross(intrinsics, rgb, camera_pose) + qimage_rgba = QImage(rgba.data, rgba.shape[1], rgba.shape[0], QImage.Format_RGBA8888).copy() + + pose_pair = PosePair( + robot_pose=robot_pose, + camera_frame=camera_frame, + qimage_rgba=qimage_rgba, + camera_pose=camera_pose, + detection_result=detection_result, + ) + self.pose_pair_loaded.emit(self._generation, poseID, pose_pair) + except FileNotFoundError: + continue + self.finished.emit(self._generation) + + +class _PosePairReprocessWorker(QObject): + """Recomputes detection results from in-memory frames in a background thread.""" + + pose_pair_reprocessed = pyqtSignal(int, int, object) + finished = pyqtSignal(int) + + def __init__(self, generation, frames, calibration_object, marker_configuration): + super().__init__() + self._generation = generation + self._frames = frames + self._calibration_object = calibration_object + self._marker_configuration = marker_configuration + self._cancel_event = threading.Event() + self._cv2_handler = CV2Handler() + + def cancel(self): + self._cancel_event.set() + + def run(self): + for poseID, camera_frame in self._frames: + if self._cancel_event.is_set(): + break + + detection_result = ( + zivid.calibration.detect_feature_points(camera_frame.point_cloud()) + if self._calibration_object == CalibrationObject.Checkerboard + else zivid.calibration.detect_markers( + camera_frame, self._marker_configuration.id_list, self._marker_configuration.dictionary + ) + ) + + camera_pose = None + if self._calibration_object == CalibrationObject.Checkerboard and detection_result.valid(): + camera_pose = TransformationMatrix.from_matrix(np.asarray(detection_result.pose().to_matrix())) + + rgba = camera_frame.point_cloud().copy_data("rgba_srgb") + rgb = rgba[:, :, :3].copy().astype(np.uint8) + if self._calibration_object == CalibrationObject.Markers and detection_result.valid(): + rgba[:, :, :3] = self._cv2_handler.draw_detected_markers( + detection_result.detected_markers(), rgb, PixelMapping() + ) + elif camera_pose is not None: + intrinsics = calibration.estimate_intrinsics(camera_frame) + rgba[:, :, :3] = self._cv2_handler.draw_projected_axis_cross(intrinsics, rgb, camera_pose) + qimage_rgba = QImage(rgba.data, rgba.shape[1], rgba.shape[0], QImage.Format_RGBA8888).copy() + + self.pose_pair_reprocessed.emit(self._generation, poseID, (detection_result, camera_pose, qimage_rgba)) + self.finished.emit(self._generation) + + +class PosePairSelectionWidget(QWidget): + directory: Path + pose_pair_clicked = pyqtSignal(PosePair) + pose_pairs_updated = pyqtSignal(int) + loading_finished = pyqtSignal() + + def __init__(self, directory: Path, parent=None): + super().__init__(parent) + + self.cv2_handler = CV2Handler() + self._load_generation = 0 + self._loader_thread: Optional[QThread] = None + self._loader_worker: Optional[_PosePairLoadWorker] = None + self._reprocess_thread: Optional[QThread] = None + self._reprocess_worker: Optional[_PosePairReprocessWorker] = None + self._last_operation_was_reprocess = False + self._loaded_from_disk = False + + self.pose_pair_widgets: OrderedDict[int, PosePairWidget] = OrderedDict() + + self.create_widgets() + self.setup_layout() + self.connect_signals() + + self.set_directory(directory) + + def create_widgets(self): + self.pose_pair_container = QWidget() + + self.pose_pairs_group_box = QGroupBox("Pose Pairs") + self.pose_pair_scrollable_area = QScrollArea() + self.pose_pair_scrollable_area.setWidgetResizable(True) + self.pose_pair_scrollable_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.pose_pair_scrollable_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.pose_pair_scrollable_area.setWidget(self.pose_pair_container) + + self.clear_pose_pairs_button = QPushButton("Clear") + + def setup_layout(self): + self.pose_pairs_group_box_layout = QVBoxLayout() + self.pose_pairs_group_box_layout.setAlignment(Qt.AlignTop) + self.pose_pairs_group_box.setLayout(self.pose_pairs_group_box_layout) + + self.pose_pairs_layout = QVBoxLayout(self.pose_pair_container) + self.pose_pairs_layout.setAlignment(Qt.AlignTop) + + button_layout = QHBoxLayout() + button_layout.addWidget(self.clear_pose_pairs_button) + + self.pose_pairs_group_box_layout.addLayout(self.create_title_row()) + self.pose_pairs_group_box_layout.addWidget(self.pose_pair_scrollable_area) + self.pose_pairs_group_box_layout.addLayout(button_layout) + + layout = QVBoxLayout() + layout.addWidget(self.pose_pairs_group_box) + self.setLayout(layout) + + def connect_signals(self): + self.clear_pose_pairs_button.clicked.connect(self.on_clear_button_clicked) + + def set_directory(self, directory: Path): + self.directory = directory + + def load_pose_pairs(self, calibration_object: CalibrationObject, marker_configuration: MarkerConfiguration): + if len(self.pose_pair_widgets) > 0: + message_box = QMessageBox() + message_box.setText( + """\ +Overwrite collected data? + +Note! This will not remove files from disk, but potentially reload them, and analyze with new Hand Eye configuration." +""" + ) + message_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + message_box.setDefaultButton(QMessageBox.No) + if message_box.exec() == QMessageBox.No: + return + + self.cancel_loading() + self._last_operation_was_reprocess = False + self._set_loaded_from_disk(False) + self._clear_layout(self.pose_pairs_layout) + self.pose_pair_widgets.clear() + self.pose_pairs_updated.emit(0) + + pose_ids = [ + pid + for pid in range(20) + if (self.directory / f"robot_pose_{pid}.yaml").exists() + and (self.directory / f"calibration_object_pose_{pid}.zdf").exists() + ] + if not pose_ids: + return + + self._set_loaded_from_disk(True) + + self._load_generation += 1 + generation = self._load_generation + + self.pose_pairs_group_box.setStyleSheet(r"QGroupBox {border: 2px solid yellow;}") + self.pose_pairs_group_box.setTitle("Pose Pairs (loading...)") + self.pose_pairs_group_box.setVisible(True) + + thread = QThread() + worker = _PosePairLoadWorker( + generation=generation, + directory=self.directory, + pose_ids=pose_ids, + calibration_object=calibration_object, + marker_configuration=marker_configuration, + ) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.pose_pair_loaded.connect(self._on_pose_pair_loaded) + worker.finished.connect(self._on_loading_finished) + worker.finished.connect(thread.quit) + self._loader_thread = thread + self._loader_worker = worker + thread.start() + + def cancel_loading(self): + if self._loader_worker is not None: + self._loader_worker.cancel() + if self._loader_thread is not None and self._loader_thread.isRunning(): + self._loader_thread.quit() + self._loader_thread.wait(10000) + self._loader_worker = None + self._loader_thread = None + self._cancel_reprocessing() + + def _cancel_reprocessing(self): + if self._reprocess_worker is not None: + self._reprocess_worker.cancel() + if self._reprocess_thread is not None and self._reprocess_thread.isRunning(): + self._reprocess_thread.quit() + self._reprocess_thread.wait(10000) + self._reprocess_worker = None + self._reprocess_thread = None + + def _on_pose_pair_loaded(self, generation: int, poseID: int, pose_pair: PosePair): + if generation != self._load_generation: + return + if poseID in self.pose_pair_widgets: + return + pose_pair_widget = PosePairWidget( + poseID=poseID, directory=self.directory, pose_pair=pose_pair, save_to_disk=False + ) + pose_pair_widget.clickable_labels.clicked.connect(lambda: self.on_pose_pair_widget_clicked(pose_pair_widget)) + self.pose_pairs_layout.insertWidget(pose_pair_widget.poseID, pose_pair_widget) + self.pose_pair_widgets[poseID] = pose_pair_widget + self.pose_pairs_updated.emit(len(self.pose_pair_widgets)) + self.pose_pair_clicked.emit(pose_pair_widget.pose_pair) + + def _on_loading_finished(self, generation: int): + if generation != self._load_generation: + return + self.pose_pairs_group_box.setStyleSheet("") + self.pose_pairs_group_box.setTitle("Pose Pairs") + self.loading_finished.emit() + + def reprocess_pose_pairs( + self, calibration_object: CalibrationObject, marker_configuration: MarkerConfiguration + ) -> None: + if not self.pose_pair_widgets: + return + self._cancel_reprocessing() + self._last_operation_was_reprocess = True + + frames = [(poseID, widget.pose_pair.camera_frame) for poseID, widget in self.pose_pair_widgets.items()] + + self._load_generation += 1 + generation = self._load_generation + + self.pose_pairs_group_box.setStyleSheet(r"QGroupBox {border: 2px solid yellow;}") + self.pose_pairs_group_box.setTitle("Pose Pairs (reprocessing...)") + + thread = QThread() + worker = _PosePairReprocessWorker( + generation=generation, + frames=frames, + calibration_object=calibration_object, + marker_configuration=marker_configuration, + ) + worker.moveToThread(thread) + thread.started.connect(worker.run) + worker.pose_pair_reprocessed.connect(self._on_pose_pair_reprocessed) + worker.finished.connect(self._on_reprocessing_finished) + worker.finished.connect(thread.quit) + self._reprocess_thread = thread + self._reprocess_worker = worker + thread.start() + + def _on_pose_pair_reprocessed(self, generation: int, poseID: int, result: object) -> None: + if generation != self._load_generation: + return + if poseID not in self.pose_pair_widgets: + return + assert isinstance(result, tuple) + detection_result, camera_pose, qimage_rgba = result + widget = self.pose_pair_widgets[poseID] + widget.pose_pair.detection_result = detection_result + widget.pose_pair.camera_pose = camera_pose + widget.pose_pair.qimage_rgba = qimage_rgba + widget.update_information(widget.pose_pair, save_to_disk=False) + widget.clickable_labels.labels[2].setText("NA") + + def _on_reprocessing_finished(self, generation: int) -> None: + if generation != self._load_generation: + return + self.pose_pairs_group_box.setStyleSheet("") + self.pose_pairs_group_box.setTitle("Pose Pairs") + self.loading_finished.emit() + + def on_pose_pair_widget_clicked(self, pose_pair_widget: PosePairWidget): + for clickable_area in [p.clickable_labels for p in self.pose_pair_widgets.values()]: + if clickable_area is not pose_pair_widget.clickable_labels: + clickable_area.setChecked(False) + QApplication.processEvents() + self.pose_pair_clicked.emit(pose_pair_widget.pose_pair) + + def on_clear_button_clicked(self): + self.clear() + + def create_title_row(self) -> QHBoxLayout: + checkbox_and_poseID_spacer = QSpacerItem(75, 40, QSizePolicy.Fixed, QSizePolicy.Minimum) + title_labels = ButtonWithLabels([QLabel("Robot"), QLabel("Camera"), QLabel("Residual")]) + title_layout = QHBoxLayout() + title_layout.addItem(checkbox_and_poseID_spacer) + title_layout.addWidget(title_labels) + return title_layout + + def is_loading(self) -> bool: + if self._loader_thread is not None and self._loader_thread.isRunning(): + return True + if self._reprocess_thread is not None and self._reprocess_thread.isRunning(): + return True + return False + + @property + def last_operation_was_reprocess(self) -> bool: + return self._last_operation_was_reprocess + + @property + def loaded_from_disk(self) -> bool: + return self._loaded_from_disk + + def _set_loaded_from_disk(self, value: bool) -> None: + self._loaded_from_disk = value + self.clear_pose_pairs_button.setVisible(not value) + + def add_pose_pair(self, pose_pair) -> Optional[PosePairWidget]: + if self.is_loading(): + return None + poseID = self.get_current_poseID() + if poseID in self.pose_pair_widgets: + reply = QMessageBox.question( + self, + "Replace Pose Pair", + "This will replace the selected Pose Pair. Do you want to proceed?", + QMessageBox.Yes | QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.pose_pair_widgets[poseID].update_information(pose_pair) + return self.pose_pair_widgets[poseID] + return None + + pose_pair_widget = PosePairWidget(poseID=poseID, directory=self.directory, pose_pair=pose_pair) + pose_pair_widget.clickable_labels.clicked.connect(lambda: self.on_pose_pair_widget_clicked(pose_pair_widget)) + + self.pose_pairs_layout.insertWidget(pose_pair_widget.poseID, pose_pair_widget) + self.pose_pair_widgets[poseID] = pose_pair_widget + self.pose_pairs_updated.emit(len(self.pose_pair_widgets)) + return pose_pair_widget + + def get_current_poseID(self) -> int: + for pose_pair_widget in self.pose_pair_widgets.values(): + if pose_pair_widget.clickable_labels.isChecked(): + return pose_pair_widget.poseID + return len(self.pose_pair_widgets) + + def number_of_active_pose_pairs(self) -> int: + return len( + [ + pose_pair_widget + for pose_pair_widget in self.pose_pair_widgets.values() + if pose_pair_widget.selected_checkbox.isChecked() + ] + ) + + def get_detection_results(self) -> List[zivid.calibration.HandEyeInput]: + return [ + zivid.calibration.HandEyeInput( + zivid.calibration.Pose(pose_pair_widget.pose_pair.robot_pose.as_matrix()), + pose_pair_widget.pose_pair.detection_result, + ) + for pose_pair_widget in self.pose_pair_widgets.values() + if pose_pair_widget.selected_checkbox.isChecked() + ] + + def set_residuals(self, residuals: List[Any]): + checked_pose_pairs = [ + pose_pair_widget + for pose_pair_widget in self.pose_pair_widgets.values() + if pose_pair_widget.selected_checkbox.isChecked() + ] + for pose_pair_widget, residual in zip(checked_pose_pairs, residuals): # noqa: B905 + pose_pair_widget.clickable_labels.labels[2].setText( + f"{residual.translation():.2f} ({residual.rotation():.2f}°)" + ) + unchecked_pose_pairs = [ + pose_pair_widget + for pose_pair_widget in self.pose_pair_widgets.values() + if not pose_pair_widget.selected_checkbox.isChecked() + ] + for pose_pair_widget in unchecked_pose_pairs: + pose_pair_widget.clickable_labels.labels[2].setText("NA") + + def _clear_layout(self, layout): + while layout.count(): + item = layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + sublayout = item.layout() + if sublayout: + self._clear_layout(sublayout) + + def clear(self): + self.cancel_loading() + self._set_loaded_from_disk(False) + self._clear_layout(self.pose_pairs_layout) + self.pose_pair_widgets.clear() + self.pose_pairs_updated.emit(0) diff --git a/modules/zividsamples/gui/set_fixed_objects.py b/modules/zividsamples/gui/calibration/set_fixed_objects.py similarity index 97% rename from modules/zividsamples/gui/set_fixed_objects.py rename to modules/zividsamples/gui/calibration/set_fixed_objects.py index 852a9607..7bfc7f1e 100644 --- a/modules/zividsamples/gui/set_fixed_objects.py +++ b/modules/zividsamples/gui/calibration/set_fixed_objects.py @@ -30,12 +30,12 @@ FixedPlacementOfFiducialMarker, FixedPlacementOfFiducialMarkers, ) -from zividsamples.gui.aspect_ratio_label import AspectRatioLabel -from zividsamples.gui.hand_eye_configuration import CalibrationObject, HandEyeConfiguration -from zividsamples.gui.marker_widget import MarkerConfiguration -from zividsamples.gui.pose_widget import PoseWidget, PoseWidgetDisplayMode, TransformationMatrix from zividsamples.gui.qt_application import ZividQtApplication -from zividsamples.gui.rotation_format_configuration import RotationInformation +from zividsamples.gui.widgets.aspect_ratio_label import AspectRatioLabel +from zividsamples.gui.widgets.pose_widget import PoseWidget, PoseWidgetDisplayMode, TransformationMatrix +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject, HandEyeConfiguration +from zividsamples.gui.wizard.marker_configuration import MarkerConfiguration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation from zividsamples.paths import get_image_file_path diff --git a/modules/zividsamples/gui/hand_eye_app.py b/modules/zividsamples/gui/hand_eye_app.py new file mode 100644 index 00000000..e6419a3b --- /dev/null +++ b/modules/zividsamples/gui/hand_eye_app.py @@ -0,0 +1,665 @@ +"""Base class for the Hand-Eye Calibration GUI application. + +Contains all event handling, signal wiring, toolbar creation, auto-run state machine, +projection logic, and camera management. Subclass this and implement the "essence" +methods (configuration_wizard, create_widgets, setup_layout) to build the GUI. +""" + +import functools +import time +from enum import Enum +from typing import Dict, List, Optional + +import zivid +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QCloseEvent, QKeyEvent +from PyQt5.QtWidgets import ( + QAction, + QApplication, + QFileDialog, + QMainWindow, + QMessageBox, + QTabWidget, + QWidget, +) +from zividsamples.gui.calibration.hand_eye_calibration_gui import HandEyeCalibrationGUI +from zividsamples.gui.preparation.infield_correction_gui import InfieldCorrectionGUI +from zividsamples.gui.preparation.warmup_gui import WarmUpGUI +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.robot.robot_control_widget import RobotControlWidget +from zividsamples.gui.verification.hand_eye_verification_gui import HandEyeVerificationGUI +from zividsamples.gui.verification.stitch_gui import StitchGUI +from zividsamples.gui.verification.touch_gui import TouchGUI +from zividsamples.gui.widgets.camera_buttons_widget import CameraButtonsWidget +from zividsamples.gui.widgets.live_2d_widget import Live2DWidget +from zividsamples.gui.widgets.tutorial_widget import TutorialWidget +from zividsamples.gui.wizard.camera_selection import select_camera +from zividsamples.gui.wizard.data_directory import DataDirectoryManager +from zividsamples.gui.wizard.hand_eye_configuration import ( + HandEyeConfiguration, + select_hand_eye_configuration, +) +from zividsamples.gui.wizard.marker_configuration import MarkerConfiguration, select_marker_configuration +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration, select_robot_configuration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation, select_rotation_format +from zividsamples.gui.wizard.settings_selector import SettingsForHandEyeGUI, select_settings_for_hand_eye +from zividsamples.transformation_matrix import TransformationMatrix + + +class AutoRunState(Enum): + INACTIVE = 0 + HOMING = 1 + RUNNING = 2 + CALIBRATING = 3 + STOPPING = 4 + + +# pylint: disable=too-many-instance-attributes,too-many-public-methods +class HandEyeAppBase(QMainWindow): + """Base class providing all event handling and business logic for the Hand-Eye GUI. + + Subclasses must implement: + - configuration_wizard() -- run startup configuration dialogs + - create_widgets() -- create tab widgets, live2d, robot control, etc. + - setup_layout() -- arrange widgets in the window + """ + + # Attributes set by subclass in create_widgets / configuration_wizard + zivid_app: zivid.Application + camera: Optional[zivid.Camera] + data_directory_manager: DataDirectoryManager + settings: SettingsForHandEyeGUI + hand_eye_configuration: HandEyeConfiguration + marker_configuration: MarkerConfiguration + robot_configuration: RobotConfiguration + rotation_information: RotationInformation + auto_run_state: AutoRunState + robot_pose: TransformationMatrix + projection_handle: Optional[zivid.projection.ProjectedImage] + last_frame: Optional[zivid.Frame] + common_instructions: Dict[str, bool] + projection_error_dialog: QMessageBox + tab_widgets: List[QWidget] + + # Toolbar actions (created in create_toolbar) + directory_load_session_action: QAction + directory_new_session_action: QAction + save_frame_action: QAction + select_hand_eye_configuration_action: QAction + select_marker_configuration_action: QAction + set_fixed_objects_action: QAction + select_hand_eye_settings_action: QAction + select_robot_configuration_action: QAction + select_rotation_format_action: QAction + toggle_advanced_view_action: QAction + + _SESSION_DATA_LOADED_TOOLTIP = "Start a new session to capture new Hand Eye Calibration data" + + # Widgets (created by subclass) + main_tab_widget: QTabWidget + preparation_tab_widget: QTabWidget + verification_tab_widget: QTabWidget + warmup_gui: WarmUpGUI + infield_correction_gui: InfieldCorrectionGUI + hand_eye_calibration_gui: HandEyeCalibrationGUI + hand_eye_verification_gui: HandEyeVerificationGUI + touch_gui: TouchGUI + stitch_gui: StitchGUI + live2d_widget: Live2DWidget + robot_control_widget: RobotControlWidget + camera_buttons: CameraButtonsWidget + tutorial_widget: TutorialWidget + central_widget: QWidget + current_tab_widget: QWidget + previous_tab_widget: Optional[QWidget] + + def initialize(self) -> None: + """Call after configuration_wizard, create_widgets, and setup_layout.""" + self.static_configuration() + self.create_toolbar() + self.connect_signals() + self.current_tab_widget = self.hand_eye_calibration_gui + self.on_instructions_updated() + for widget in self.tab_widgets: + self.data_directory_manager.register_tab_widget(widget, widget.objectName()) + + if self.camera: + self.live2d_widget.update_settings_2d(self.settings.production.settings_2d3d.color, self.camera.info.model) + self.live2d_widget.start_live_2d() + + QTimer.singleShot(0, functools.partial(self.main_tab_widget.setCurrentWidget, self.hand_eye_calibration_gui)) + QTimer.singleShot(100, self.update_tab_order) + + def static_configuration(self) -> None: + self.auto_run_state = AutoRunState.INACTIVE + self.robot_pose = TransformationMatrix() + self.projection_handle = None + self.last_frame = None + self.common_instructions = {} + + def update_tab_order(self) -> None: + tab_widgets = ( + self.current_tab_widget.get_tab_widgets_in_order() + + self.camera_buttons.get_tab_widgets_in_order() + + self.robot_control_widget.get_tab_widgets_in_order() + ) + for i in range(len(tab_widgets) - 1): + self.setTabOrder(tab_widgets[i], tab_widgets[i + 1]) + tab_widgets[0].setFocus() + + def create_toolbar(self) -> None: + file_menu = self.menuBar().addMenu("File") + self.directory_load_session_action = QAction("Load Session", self) + self.directory_new_session_action = QAction("New Session", self) + file_menu.addAction(self.directory_load_session_action) + file_menu.addAction(self.directory_new_session_action) + self.save_frame_action = QAction("Save last capture", self) + self.save_frame_action.setEnabled(False) + self.save_frame_action.setToolTip("Save the last captured frame") + self.save_frame_action.setShortcut("Ctrl+S") + file_menu.addAction(self.save_frame_action) + close_action = QAction("Close", self) + close_action.triggered.connect(self.close) + file_menu.addAction(close_action) + + config_menu = self.menuBar().addMenu("Configuration") + hand_eye_submenu = config_menu.addMenu("Hand-Eye") + self.select_hand_eye_configuration_action = QAction("Hand-Eye", self) + hand_eye_submenu.addAction(self.select_hand_eye_configuration_action) + self.select_marker_configuration_action = QAction("Markers", self) + hand_eye_submenu.addAction(self.select_marker_configuration_action) + self.set_fixed_objects_action = QAction("Fixed Objects", self) + hand_eye_submenu.addAction(self.set_fixed_objects_action) + + camera_submenu = config_menu.addMenu("Camera") + self.select_hand_eye_settings_action = QAction("Settings", self) + camera_submenu.addAction(self.select_hand_eye_settings_action) + + robot_submenu = config_menu.addMenu("Robot") + self.select_robot_configuration_action = QAction("Control Option", self) + robot_submenu.addAction(self.select_robot_configuration_action) + self.select_rotation_format_action = QAction("Rotation Format", self) + robot_submenu.addAction(self.select_rotation_format_action) + + view_menu = self.menuBar().addMenu("View") + self.toggle_advanced_view_action = QAction("Advanced", self, checkable=True) + self.toggle_advanced_view_action.setChecked(False) + view_menu.addAction(self.toggle_advanced_view_action) + + def connect_signals(self) -> None: + self.live2d_widget.camera_disconnected.connect(self.on_camera_disconnected) + self.main_tab_widget.currentChanged.connect(self.on_tab_changed) + self.preparation_tab_widget.currentChanged.connect(self.on_tab_changed) + self.verification_tab_widget.currentChanged.connect(self.on_tab_changed) + self.directory_load_session_action.triggered.connect(self.on_data_directory_load_session_action_triggered) + self.directory_new_session_action.triggered.connect(self.on_data_directory_new_session_action_triggered) + self.save_frame_action.triggered.connect(self.on_save_last_frame_action_triggered) + self.select_hand_eye_configuration_action.triggered.connect(self.hand_eye_configuration_action_triggered) + self.select_marker_configuration_action.triggered.connect(self.on_select_marker_configuration) + self.select_hand_eye_settings_action.triggered.connect(self.on_select_hand_eye_settings_action_triggered) + self.select_rotation_format_action.triggered.connect(self.on_select_rotation_format) + self.set_fixed_objects_action.triggered.connect(self.on_select_fixed_objects_action_triggered) + self.toggle_advanced_view_action.triggered.connect(self.on_toggle_advanced_view_action_triggered) + self.select_robot_configuration_action.triggered.connect(self.on_select_robot_configuration_action_triggered) + self.camera_buttons.capture_button_clicked.connect(self.on_capture_button_clicked) + self.camera_buttons.connect_button_clicked.connect(self.on_connect_button_clicked) + self.warmup_gui.warmup_finished.connect(self.on_warmup_finished) + self.warmup_gui.warmup_start_requested.connect(self.on_warmup_start_requested) + self.warmup_gui.instructions_updated.connect(self.on_instructions_updated) + self.infield_correction_gui.apply_correction_button_clicked.connect(self.on_apply_correction_button_clicked) + self.infield_correction_gui.loading_finished.connect(self._on_tab_loading_finished) + self.infield_correction_gui.instructions_updated.connect(self.on_instructions_updated) + self.infield_correction_gui.update_projection.connect(self.update_projection) + self.hand_eye_calibration_gui.calibration_finished.connect(self.on_calibration_finished) + self.hand_eye_calibration_gui.loading_finished.connect(self._on_tab_loading_finished) + self.hand_eye_calibration_gui.instructions_updated.connect(self.on_instructions_updated) + self.hand_eye_verification_gui.update_projection.connect(self.update_projection) + self.hand_eye_verification_gui.instructions_updated.connect(self.on_instructions_updated) + self.stitch_gui.instructions_updated.connect(self.on_instructions_updated) + self.stitch_gui.loading_finished.connect(self._on_tab_loading_finished) + self.touch_gui.instructions_updated.connect(self.on_instructions_updated) + self.touch_gui.touch_pose_updated.connect(self.on_touch_pose_updated) + self.robot_control_widget.robot_connected.connect(self.on_robot_connected) + self.robot_control_widget.auto_run_toggled.connect(self.on_auto_run_toggled) + self.robot_control_widget.target_pose_updated.connect(self.on_target_pose_updated) + self.robot_control_widget.actual_pose_updated.connect(self.on_actual_pose_updated) + + def tab_widgets_with_robot_support(self) -> List[QWidget]: + return [ + self.infield_correction_gui, + self.hand_eye_calibration_gui, + self.hand_eye_verification_gui, + self.stitch_gui, + self.touch_gui, + ] + + def get_currently_selected_tab_widget(self) -> QWidget: + if self.main_tab_widget.currentWidget() == self.preparation_tab_widget: + return self.preparation_tab_widget.currentWidget() + if self.main_tab_widget.currentWidget() == self.verification_tab_widget: + return self.verification_tab_widget.currentWidget() + return self.main_tab_widget.currentWidget() + + def keyPressEvent(self, a0: QKeyEvent) -> None: # pylint: disable=invalid-name + if a0 is not None and a0.key() == Qt.Key_F5: + if self.camera and self.camera.state.connected: + self.camera_buttons.on_capture_button_clicked() + else: + super().keyPressEvent(a0) + + def configure_settings(self, show_anyway: bool = False) -> None: + if self.camera: + current_settings = self.settings if hasattr(self, "settings") else None + self.settings = select_settings_for_hand_eye(self.camera, current_settings, show_anyway) + + def setup_instructions(self) -> None: + self.common_instructions = { + "Connect Camera": self.camera is not None and self.camera.state.connected, + } + if self.robot_configuration.can_get_pose(): + self.common_instructions.update( + { + "Connect Robot": self.robot_control_widget.connected, + } + ) + + def on_capture_button_clicked(self) -> None: + assert self.camera is not None + self.live2d_widget.stop_live_2d() + try: + if self.robot_configuration.can_control(): + while self.robot_control_widget.robot_is_moving(): + time.sleep(0.1) + was_projecting = False + if self.current_tab_widget in [self.hand_eye_verification_gui, self.infield_correction_gui]: + if self.projection_handle and self.projection_handle.active(): + self.projection_handle.stop() + was_projecting = True + settings = ( + self.settings.hand_eye + if self.current_tab_widget in [self.hand_eye_calibration_gui, self.hand_eye_verification_gui] + else ( + self.settings.infield_correction + if self.current_tab_widget in [self.warmup_gui, self.infield_correction_gui] + else self.settings.production + ) + ) + frame = ( + zivid.calibration.capture_calibration_board(self.camera) + if self.current_tab_widget in [self.warmup_gui, self.infield_correction_gui] + else self.camera.capture_2d_3d(settings.settings_2d3d) + ) + self.last_frame = frame + self.save_frame_action.setEnabled(True) + frame_2d = frame.frame_2d() + assert frame_2d + rgba = frame_2d.image_srgb().copy_data() + self.current_tab_widget.process_capture(frame, rgba, settings) + if self.current_tab_widget in [self.hand_eye_verification_gui]: + if was_projecting: + self.live2d_widget.set_capture_function(self.camera.capture_2d) + self.update_projection(True) + rgba = self.live2d_widget.get_current_rgba() + self.current_tab_widget.process_capture(frame, rgba, self.settings.production) + if not self.live2d_widget.is_active(): + self.live2d_widget.start_live_2d() + if self.robot_configuration.can_control() and self.auto_run_state == AutoRunState.RUNNING: + QApplication.processEvents() + self.robot_control_widget.on_move_to_next_target(blocking=False) + except RuntimeError as ex: + if self.camera.state.connected: + if self.robot_configuration.can_control() and self.auto_run_state != AutoRunState.INACTIVE: + self.finish_auto_run() + else: + self.on_camera_disconnected(str(ex)) + if not self.live2d_widget.is_active(): + self.live2d_widget.start_live_2d() + + def on_warmup_start_requested(self) -> None: + self.camera_buttons.disable_buttons() + self.live2d_widget.stop_live_2d() + self.warmup_gui.start_warmup(self.camera, self.settings.production.settings_2d3d) + + def on_warmup_finished(self, success: bool) -> None: + self.camera_buttons.enable_buttons() + self.live2d_widget.start_live_2d() + if success: + dialog = QMessageBox(self) + dialog.setWindowTitle("Warmup Finished") + warn_about_trueness_str = f"\n{self.warmup_gui.get_warn_about_trueness_str(self.camera)}" + dialog.setText("Warmup is finished. What would you like to do next?" + warn_about_trueness_str) + dialog.addButton("Stay in Warmup", QMessageBox.RejectRole) + skip_to_calibration_button = dialog.addButton("Hand Eye Calibration", QMessageBox.AcceptRole) + move_to_infield_button = dialog.addButton("Infield Correction", QMessageBox.YesRole) + dialog.exec() + if dialog.clickedButton() == move_to_infield_button: + self.main_tab_widget.setCurrentWidget(self.preparation_tab_widget) + self.preparation_tab_widget.setCurrentWidget(self.infield_correction_gui) + elif dialog.clickedButton() == skip_to_calibration_button: + self.main_tab_widget.setCurrentWidget(self.hand_eye_calibration_gui) + + def on_apply_correction_button_clicked(self) -> None: + self.infield_correction_gui.apply_correction(self.camera) + + def on_calibration_finished(self, transformation_matrix: TransformationMatrix) -> None: + if self.robot_configuration.can_control() and self.auto_run_state == AutoRunState.CALIBRATING: + self.finish_auto_run() + if not transformation_matrix.is_identity(): + self.hand_eye_verification_gui.set_hand_eye_transformation_matrix(transformation_matrix) + self.touch_gui.set_hand_eye_transformation_matrix(transformation_matrix) + self.stitch_gui.set_hand_eye_transformation_matrix(transformation_matrix) + + def on_auto_run_toggled(self) -> None: + if self.auto_run_state == AutoRunState.INACTIVE: + self.start_auto_run() + else: + self.auto_run_state = AutoRunState.STOPPING + + def start_auto_run(self) -> None: + self.camera_buttons.disable_buttons() + self.robot_control_widget.set_auto_run_active(True) + if self.robot_control_widget.robot_is_home(): + self.auto_run_state = AutoRunState.RUNNING + if self.current_tab_widget == self.hand_eye_calibration_gui: + if self.hand_eye_calibration_gui.on_start_auto_run(): + self.on_capture_button_clicked() + else: + self.finish_auto_run() + elif self.current_tab_widget == self.hand_eye_verification_gui: + self.robot_control_widget.on_move_to_next_target(blocking=False) + else: + self.auto_run_state = AutoRunState.HOMING + self.robot_control_widget.on_move_home() + + def finish_auto_run(self) -> None: + self.auto_run_state = AutoRunState.INACTIVE + self.robot_control_widget.set_auto_run_active(False) + self.camera_buttons.enable_buttons() + + def get_transformation_matrix(self) -> TransformationMatrix: + return self.robot_pose + + def on_instructions_updated(self) -> None: + self.tutorial_widget.set_title("Steps") + self.tutorial_widget.clear_steps() + self.tutorial_widget.add_steps(self.common_instructions) + self.tutorial_widget.add_steps(self.current_tab_widget.instruction_steps) + self.tutorial_widget.set_description(self.current_tab_widget.description) + self.tutorial_widget.update_text() + + def on_robot_connected(self) -> None: + self.setup_instructions() + self.on_instructions_updated() + + def on_actual_pose_updated(self, robot_target: RobotTarget) -> None: + self.robot_pose = robot_target.pose + if self.current_tab_widget in self.tab_widgets_with_robot_support(): + self.current_tab_widget.on_actual_pose_updated(robot_target) + if self.robot_control_widget.robot_is_home(): + if self.auto_run_state == AutoRunState.HOMING: + self.auto_run_state = AutoRunState.RUNNING + elif self.auto_run_state == AutoRunState.RUNNING: + if self.current_tab_widget == self.hand_eye_calibration_gui: + self.auto_run_state = AutoRunState.CALIBRATING + self.hand_eye_calibration_gui.on_calibrate_button_clicked() + elif self.current_tab_widget == self.hand_eye_verification_gui: + self.auto_run_state = AutoRunState.STOPPING + if self.auto_run_state == AutoRunState.STOPPING: + self.finish_auto_run() + elif self.auto_run_state == AutoRunState.RUNNING: + if self.current_tab_widget == self.hand_eye_calibration_gui: + self.on_capture_button_clicked() + elif self.current_tab_widget == self.hand_eye_verification_gui: + time.sleep(2) + self.robot_control_widget.on_move_to_next_target(blocking=False) + elif self.auto_run_state != AutoRunState.INACTIVE: + error_message = ( + f"Expected to be home now, but arrived at {robot_target.name} {robot_target.pose}" + if self.auto_run_state == AutoRunState.HOMING + else f"Invalid state {self.auto_run_state} when we got pose update from robot." + ) + QMessageBox.critical(self, "Auto-Run Error", error_message) + self.finish_auto_run() + + def on_target_pose_updated(self, robot_target: RobotTarget) -> None: + if self.current_tab_widget == self.hand_eye_calibration_gui: + self.hand_eye_calibration_gui.on_target_pose_updated(robot_target) + elif self.current_tab_widget == self.hand_eye_verification_gui: + self.hand_eye_verification_gui.on_target_pose_updated(robot_target) + + def on_touch_pose_updated(self, touch_target: TransformationMatrix) -> None: + self.robot_control_widget.set_touch_target(touch_target) + + def update_projection(self, project: bool = True) -> None: + if ( + self.current_tab_widget in [self.hand_eye_verification_gui, self.infield_correction_gui] + and self.camera is not None + and self.camera.state.connected + ): + self.live2d_widget.stop_live_2d() + if project and self.current_tab_widget.has_features_to_project(): + self.robot_control_widget.enable_disable_buttons(auto_run=True, touch=False) + error_msg = None + try: + if self.camera is None: + raise RuntimeError("No camera connected.") + try: + projector_image = self.current_tab_widget.generate_projector_image(self.camera) + except ValueError as ex: + error_msg = f"Failed to generate projector image: {ex}. Most likely the estimated position of the calibration object is out of view." + raise ValueError(ex) from ex + self.projection_handle = zivid.projection.show_image_bgra(self.camera, projector_image) + assert self.projection_handle is not None + self.live2d_widget.set_capture_function(self.projection_handle.capture) + except (RuntimeError, ValueError, AssertionError) as ex: + if not error_msg: + error_msg = f"Failed to project: {ex}" + if not self.projection_error_dialog.isVisible(): + self.projection_error_dialog.setText(error_msg) + self.projection_error_dialog.show() + if self.camera is not None: + self.live2d_widget.set_capture_function(self.camera.capture_2d) + elif self.camera is not None: + self.live2d_widget.set_capture_function(self.camera.capture_2d) + self.live2d_widget.start_live_2d() + + def on_tab_changed(self, _: int) -> None: + if self.auto_run_state != AutoRunState.INACTIVE: + self.auto_run_state = AutoRunState.STOPPING + self.previous_tab_widget = self.current_tab_widget + self.current_tab_widget = self.get_currently_selected_tab_widget() + if self.previous_tab_widget == self.warmup_gui: + self.camera_buttons.enable_buttons() + if (self.previous_tab_widget in [self.hand_eye_verification_gui, self.infield_correction_gui]) and ( + self.current_tab_widget not in [self.hand_eye_verification_gui, self.infield_correction_gui] + ): + if self.projection_handle and self.projection_handle.active(): + self.live2d_widget.stop_live_2d() + self.projection_handle.stop() + if self.camera is not None: + self.live2d_widget.set_capture_function(self.camera.capture_2d) + self.live2d_widget.start_live_2d() + if self.current_tab_widget == self.infield_correction_gui: + if self.infield_correction_gui.infield_correction_input_data is not None: + self.update_projection(True) + self.robot_control_widget.enable_disable_buttons(auto_run=False, touch=False) + self.robot_control_widget.show_buttons(auto_run=False, touch=False) + if self.camera is not None: + self.infield_correction_gui.check_correction(self.camera) + elif self.current_tab_widget == self.hand_eye_calibration_gui: + self.robot_control_widget.enable_disable_buttons(auto_run=True, touch=False) + self.robot_control_widget.show_buttons(auto_run=True, touch=False) + elif self.current_tab_widget == self.hand_eye_verification_gui: + self.update_projection(True) + if self.robot_configuration.can_control(): + self.robot_control_widget.enable_disable_buttons(auto_run=True, touch=False) + self.robot_control_widget.show_buttons(auto_run=True, touch=False) + elif self.current_tab_widget == self.stitch_gui: + self.robot_control_widget.enable_disable_buttons(auto_run=False, touch=False) + self.robot_control_widget.show_buttons(auto_run=False, touch=False) + elif self.current_tab_widget == self.touch_gui: + self.robot_control_widget.enable_disable_buttons( + auto_run=False, touch=self.robot_configuration.can_control() + ) + self.robot_control_widget.show_buttons(auto_run=False, touch=self.robot_configuration.can_control()) + self.robot_control_widget.set_get_pose_interval( + fast=(self.current_tab_widget != self.hand_eye_verification_gui) + ) + self.current_tab_widget.notify_current_tab(self.current_tab_widget) + for widget in self.tab_widgets: + if widget is not self.current_tab_widget: + widget.notify_current_tab(self.current_tab_widget) + if self.current_tab_widget.is_loading(): + self.camera_buttons.disable_buttons() + elif self._calibration_tab_has_disk_data(): + self.camera_buttons.disable_buttons(capture_tooltip=self._SESSION_DATA_LOADED_TOOLTIP) + else: + self.camera_buttons.enable_buttons() + self.on_instructions_updated() + self.update_tab_order() + + def _calibration_tab_has_disk_data(self) -> bool: + return ( + self.current_tab_widget == self.hand_eye_calibration_gui + and self.hand_eye_calibration_gui.pose_pair_selection_widget.loaded_from_disk + ) + + def _on_tab_loading_finished(self) -> None: + if self.auto_run_state != AutoRunState.INACTIVE: + return + if self._calibration_tab_has_disk_data(): + self.camera_buttons.disable_buttons(capture_tooltip=self._SESSION_DATA_LOADED_TOOLTIP) + elif not self.current_tab_widget.is_loading(): + self.camera_buttons.enable_buttons() + + def on_data_directory_load_session_action_triggered(self) -> None: + self.data_directory_manager.select_folder() + self.current_tab_widget.notify_current_tab(self.current_tab_widget) + for widget in self.tab_widgets: + if widget is not self.current_tab_widget: + widget.notify_current_tab(self.current_tab_widget) + if self.current_tab_widget.is_loading(): + self.camera_buttons.disable_buttons() + + def on_data_directory_new_session_action_triggered(self) -> None: + self.data_directory_manager.start_new_session() + self.current_tab_widget.notify_current_tab(self.current_tab_widget) + for widget in self.tab_widgets: + if widget is not self.current_tab_widget: + widget.notify_current_tab(self.current_tab_widget) + if self.current_tab_widget.is_loading(): + self.camera_buttons.disable_buttons() + + def on_save_last_frame_action_triggered(self) -> None: + if self.last_frame is not None: + file_name = QFileDialog.getSaveFileName( + caption="Save Capture", + directory=self.current_tab_widget.data_directory.joinpath("last_capture.zdf").resolve().as_posix(), + filter="Zivid Frame (*.zdf *.ply *.pcd *.xyz)", + )[0] + self.last_frame.save(file_name) + else: + QMessageBox.warning(self, "Save Capture", "No capture to save.") + + def on_select_hand_eye_settings_action_triggered(self) -> None: + self.configure_settings(show_anyway=True) + if self.camera: + self.live2d_widget.update_settings_2d(self.settings.production.settings_2d3d.color, self.camera.info.model) + + def hand_eye_configuration_action_triggered(self) -> None: + self.hand_eye_configuration = select_hand_eye_configuration(self.hand_eye_configuration, show_anyway=True) + self.hand_eye_calibration_gui.hand_eye_configuration_update(self.hand_eye_configuration) + self.hand_eye_verification_gui.hand_eye_configuration_update(self.hand_eye_configuration) + + def on_select_marker_configuration(self) -> None: + self.marker_configuration = select_marker_configuration(self.marker_configuration, show_anyway=True) + self.hand_eye_calibration_gui.marker_configuration_update(self.marker_configuration) + self.hand_eye_verification_gui.marker_configuration_update(self.marker_configuration) + + def on_select_rotation_format(self) -> None: + self.rotation_information = select_rotation_format( + current_rotation_information=self.rotation_information, show_anyway=True + ) + if self.rotation_information is not None: + for widget in self.tab_widgets_with_robot_support(): + widget.rotation_format_update(self.rotation_information) + + def on_select_fixed_objects_action_triggered(self) -> None: + self.hand_eye_calibration_gui.on_select_fixed_objects_action_triggered() + + def on_toggle_advanced_view_action_triggered(self, checked: bool) -> None: + self.hand_eye_calibration_gui.toggle_advanced_view(checked) + self.hand_eye_verification_gui.toggle_advanced_view(checked) + + def on_select_robot_configuration_action_triggered(self) -> None: + selected_robot = select_robot_configuration(self.robot_configuration, show_anyway=True) + if self.robot_configuration.robot_type == selected_robot: + return + self.robot_configuration = selected_robot + self.setup_instructions() + self.on_instructions_updated() + self.robot_control_widget.robot_configuration_update(self.robot_configuration) + if self.robot_configuration.can_control(): + if self.verification_tab_widget.indexOf(self.touch_gui) == -1: + self.verification_tab_widget.addTab(self.touch_gui, "by Touching") + else: + self.verification_tab_widget.removeTab(self.verification_tab_widget.indexOf(self.touch_gui)) + for widget in self.tab_widgets: + widget.robot_configuration_update(self.robot_configuration) + + def on_connect_button_clicked(self) -> None: + if self.camera is not None and self.camera.state.connected: + self.live2d_widget.stop_live_2d() + self.live2d_widget.hide() + self.camera.disconnect() + self.camera_buttons.set_connection_status(self.camera) + self.live2d_widget.camera = None + else: + self.camera = select_camera(self.zivid_app, connect=True) + self.live2d_widget.camera = self.camera + self.camera_buttons.set_connection_status(self.camera) + if self.camera: + self.configure_settings() + self.live2d_widget.setMinimumHeight( + int(self.main_tab_widget.height() / 2), + aspect_ratio=self.settings.production.intrinsics.camera_matrix.cx + / self.settings.production.intrinsics.camera_matrix.cy, + ) + if self.camera.state.connected: + self.live2d_widget.set_capture_function(self.camera.capture_2d) + self.live2d_widget.update_settings_2d( + self.settings.production.settings_2d3d.color, self.camera.info.model + ) + self.live2d_widget.show() + self.live2d_widget.start_live_2d() + self.setup_instructions() + self.on_instructions_updated() + + def on_camera_disconnected(self, error_message: str) -> None: + print(f"Camera disconnected signal received {error_message}") + if self.camera is not None: + self.camera.disconnect() + self.camera_buttons.set_connection_status(self.camera) + self.live2d_widget.stop_live_2d() + self.live2d_widget.hide() + + if self.robot_configuration.can_control() and self.auto_run_state != AutoRunState.INACTIVE: + self.finish_auto_run() + if self.camera is not None: + QMessageBox.critical( + self, + "Camera Disconnected", + f"Camera disconnected unexpectedly ({self.camera.state.status})\n{error_message}", + ) + self.camera = None + self.setup_instructions() + self.on_instructions_updated() + + def closeEvent(self, event: QCloseEvent) -> None: # pylint: disable=C0103 + for widget in self.tab_widgets: + widget.closeEvent(event) + self.live2d_widget.closeEvent(event) + self.robot_control_widget.disconnect() + self.data_directory_manager.close_session() + self.stitch_gui.closeEvent(event) + super().closeEvent(event) diff --git a/modules/zividsamples/gui/pose_pair_selection_widget.py b/modules/zividsamples/gui/pose_pair_selection_widget.py deleted file mode 100644 index 81530832..00000000 --- a/modules/zividsamples/gui/pose_pair_selection_widget.py +++ /dev/null @@ -1,374 +0,0 @@ -from collections import OrderedDict -from dataclasses import dataclass -from pathlib import Path -from typing import Any, List, Optional - -import numpy as np -import zivid -from nptyping import Float32, NDArray, Shape -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QFontMetrics, QImage -from PyQt5.QtWidgets import ( - QApplication, - QCheckBox, - QGroupBox, - QHBoxLayout, - QLabel, - QMessageBox, - QPushButton, - QScrollArea, - QSizePolicy, - QSpacerItem, - QVBoxLayout, - QWidget, -) -from zivid.experimental import PixelMapping, calibration -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.hand_eye_configuration import CalibrationObject -from zividsamples.gui.marker_widget import MarkerConfiguration -from zividsamples.transformation_matrix import TransformationMatrix - - -def _label_width(label: QLabel, numbers: int) -> int: - font_metrics = QFontMetrics(label.font()) - return ( - font_metrics.width(" -9999.9 ") - if numbers == 1 - else font_metrics.width(" -9999.9 " + ", -9999.9" * (numbers - 1)) - ) - - -class ButtonWithLabels(QPushButton): - def __init__(self, labels: List[QLabel], parent=None): - super().__init__(parent) - - self.labels = labels - - layout = QHBoxLayout(self) - for index, label in enumerate(self.labels): - num_numbers = 3 if index < 2 else 1 - text_alignment = Qt.AlignCenter if index < 2 else Qt.AlignRight | Qt.AlignVCenter - label.setMinimumWidth(_label_width(label, num_numbers)) - label.setAlignment(text_alignment) - layout.addWidget(label) - combined_width = sum(label.sizeHint().width() for label in self.labels) - self.setMinimumWidth(combined_width) - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - -@dataclass -class PosePair: - robot_pose: TransformationMatrix - camera_frame: zivid.Frame - qimage_rgba: QImage - detection_result: zivid.calibration.DetectionResult - camera_pose: Optional[TransformationMatrix] = None - - -class PosePairWidget(QWidget): - pose_pair: PosePair - - def __init__(self, poseID: int, directory: Path, pose_pair: PosePair, parent=None): - super().__init__(parent) - - self.poseID = poseID - self.directory = directory - self.robot_pose_yaml_path: Path = self.directory / f"robot_pose_{self.poseID}.yaml" - self.camera_pose_yaml_path: Path = self.directory / f"checkerboard_pose_in_camera_frame_{self.poseID}.yaml" - self.camera_frame_path: Path = self.directory / f"calibration_object_pose_{self.poseID}.zdf" - self.camera_image_path: Path = self.directory / f"calibration_object_pose_{self.poseID}.png" - self.pose_pair = pose_pair - - self.selected_checkbox = QCheckBox(f"{self.poseID:>2}") - self.selected_checkbox.setLayoutDirection(Qt.RightToLeft) - self.selected_checkbox.setChecked(True) - self.camera_pose_label = QLabel() - self.robot_pose_label = QLabel() - self.clickable_labels = ButtonWithLabels( - [ - self.robot_pose_label, - self.camera_pose_label, - QLabel("NA"), - ] - ) - self.clickable_labels.setCheckable(True) - self.clickable_labels.setChecked(False) - - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - - pose_pair_layout = QHBoxLayout() - pose_pair_layout.addWidget(self.selected_checkbox) - pose_pair_layout.addWidget(self.clickable_labels) # , stretch=1) - self.setLayout(pose_pair_layout) - - self.update_information(pose_pair) - - def update_information(self, pose_pair: PosePair): - self.pose_pair = pose_pair - zivid.Matrix4x4(self.pose_pair.robot_pose.as_matrix()).save(self.robot_pose_yaml_path) - self.pose_pair.camera_frame.save(self.camera_frame_path) - if self.pose_pair.camera_pose is not None: - zivid.Matrix4x4(self.pose_pair.camera_pose.as_matrix()).save(self.camera_pose_yaml_path) - self.pose_pair.qimage_rgba.save(str(self.camera_image_path)) - self.camera_pose_label.setText( - "NA" - if self.pose_pair.camera_pose is None - else self._translation_to_string(self.pose_pair.camera_pose.translation) - ) - self.robot_pose_label.setText(self._translation_to_string(self.pose_pair.robot_pose.translation)) - - def camera_pose_yaml_text(self) -> str: - if self.pose_pair.camera_pose is None: - return "" - return self.camera_pose_yaml_path.read_text(encoding="utf-8") - - def robot_pose_yaml_text(self) -> str: - return self.robot_pose_yaml_path.read_text(encoding="utf-8") - - def _translation_to_string(self, translation: NDArray[Shape["3"], Float32]) -> str: # type: ignore - return f"{translation[0]:>8.1f}, {translation[1]:>8.1f}, {translation[2]:.1f}" - - -def directory_has_pose_pair_data(directory: Path) -> bool: - return ( - len(list(directory.glob("robot_pose_*.yaml"))) > 0 - and len(list(directory.glob("calibration_object_pose_*.zdf"))) > 0 - ) - - -class PosePairSelectionWidget(QWidget): - directory: Path - pose_pair_clicked = pyqtSignal(PosePair) - pose_pairs_updated = pyqtSignal(int) - - def __init__(self, directory: Path, parent=None): - super().__init__(parent) - - self.cv2_handler = CV2Handler() - - self.pose_pair_widgets: OrderedDict[int, PosePairWidget] = OrderedDict() - - self.create_widgets() - self.setup_layout() - self.connect_signals() - - self.set_directory(directory) - - def create_widgets(self): - self.pose_pair_container = QWidget() - - self.pose_pairs_group_box = QGroupBox("Pose Pairs") - self.pose_pair_scrollable_area = QScrollArea() - self.pose_pair_scrollable_area.setWidgetResizable(True) - self.pose_pair_scrollable_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.pose_pair_scrollable_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) - self.pose_pair_scrollable_area.setWidget(self.pose_pair_container) - - self.clear_pose_pairs_button = QPushButton("Clear") - - def setup_layout(self): - self.pose_pairs_group_box_layout = QVBoxLayout() - self.pose_pairs_group_box_layout.setAlignment(Qt.AlignTop) - self.pose_pairs_group_box.setLayout(self.pose_pairs_group_box_layout) - - self.pose_pairs_layout = QVBoxLayout(self.pose_pair_container) - self.pose_pairs_layout.setAlignment(Qt.AlignTop) - - button_layout = QHBoxLayout() - button_layout.addWidget(self.clear_pose_pairs_button) - - self.pose_pairs_group_box_layout.addLayout(self.create_title_row()) - self.pose_pairs_group_box_layout.addWidget(self.pose_pair_scrollable_area) - self.pose_pairs_group_box_layout.addLayout(button_layout) - - layout = QVBoxLayout() - layout.addWidget(self.pose_pairs_group_box) - self.setLayout(layout) - - def connect_signals(self): - self.clear_pose_pairs_button.clicked.connect(self.on_clear_button_clicked) - - def set_directory(self, directory: Path): - self.directory = directory - - def load_pose_pairs(self, calibration_object: CalibrationObject, marker_configuration: MarkerConfiguration): - if len(self.pose_pair_widgets) > 0: - message_box = QMessageBox() - message_box.setText( - """\ -Overwrite collected data? - -Note! This will not remove files from disk, but potentially reload them, and analyze with new Hand Eye configuration." -""" - ) - message_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - message_box.setDefaultButton(QMessageBox.No) - if message_box.exec() == QMessageBox.No: - return - self.pose_pair_widgets.clear() - self.pose_pairs_group_box.setStyleSheet(r"QGroupBox {border: 2px solid yellow;}") - self.pose_pairs_group_box.setTitle("Pose Pairs (loading...)") - self.pose_pairs_group_box.setVisible(True) - QApplication.processEvents() - for poseID in range(20): - try: - # Load from files - try: - robot_pose_yaml_path: Path = self.directory / f"robot_pose_{poseID}.yaml" - camera_pose_yaml_path: Path = self.directory / f"checkerboard_pose_in_camera_frame_{poseID}.yaml" - camera_frame_path: Path = self.directory / f"calibration_object_pose_{poseID}.zdf" - camera_image_path: Path = self.directory / f"calibration_object_pose_{poseID}.png" - robot_pose = TransformationMatrix.from_matrix(np.asarray(zivid.Matrix4x4(robot_pose_yaml_path))) - camera_frame = zivid.Frame(camera_frame_path) - detection_result = ( - zivid.calibration.detect_feature_points(camera_frame.point_cloud()) - if calibration_object == CalibrationObject.Checkerboard - else zivid.calibration.detect_markers( - camera_frame, marker_configuration.id_list, marker_configuration.dictionary - ) - ) - if camera_pose_yaml_path.exists(): - camera_pose = TransformationMatrix.from_matrix( - np.asarray(zivid.Matrix4x4(camera_pose_yaml_path)) - ) - elif calibration_object == CalibrationObject.Checkerboard and detection_result.valid(): - camera_pose_zivid = detection_result.pose().to_matrix() - zivid.Matrix4x4(camera_pose_zivid).save(camera_pose_yaml_path.as_posix()) - camera_pose = TransformationMatrix.from_matrix(np.asarray(camera_pose_zivid)) - else: - camera_pose = None - if camera_image_path.exists() and detection_result.valid(): - qimage_rgba = QImage(str(camera_image_path)) - else: - rgba = camera_frame.point_cloud().copy_data("rgba_srgb") - rgb = rgba[:, :, :3].copy().astype(np.uint8) - if calibration_object == CalibrationObject.Markers and detection_result.valid(): - rgba[:, :, :3] = self.cv2_handler.draw_detected_markers( - detection_result.detected_markers(), rgb, PixelMapping() - ) - elif camera_pose is not None: - intrinsics = calibration.estimate_intrinsics(camera_frame) - rgba[:, :, :3] = self.cv2_handler.draw_projected_axis_cross(intrinsics, rgb, camera_pose) - qimage_rgba = QImage( - rgba.data, - rgba.shape[1], - rgba.shape[0], - QImage.Format_RGBA8888, - ) - pose_pair_widget = self.add_pose_pair( - pose_pair=PosePair( - robot_pose=robot_pose, - camera_frame=camera_frame, - qimage_rgba=qimage_rgba, - camera_pose=camera_pose, - detection_result=detection_result, - ) - ) - if pose_pair_widget is not None: - self.pose_pair_clicked.emit(pose_pair_widget.pose_pair) - - except (FileNotFoundError, RuntimeError) as ex: - raise FileNotFoundError(f"Failed to load pose pair from {self.directory}: {ex}") from ex - except FileNotFoundError: - continue - QApplication.processEvents() - self.pose_pairs_group_box.setStyleSheet("") - self.pose_pairs_group_box.setTitle("Pose Pairs") - - def on_pose_pair_widget_clicked(self, pose_pair_widget: PosePairWidget): - for clickable_area in [p.clickable_labels for p in self.pose_pair_widgets.values()]: - if clickable_area is not pose_pair_widget.clickable_labels: - clickable_area.setChecked(False) - QApplication.processEvents() - self.pose_pair_clicked.emit(pose_pair_widget.pose_pair) - - def on_clear_button_clicked(self): - self.clear() - - def create_title_row(self) -> QHBoxLayout: - checkbox_and_poseID_spacer = QSpacerItem(75, 40, QSizePolicy.Fixed, QSizePolicy.Minimum) - title_labels = ButtonWithLabels([QLabel("Robot"), QLabel("Camera"), QLabel("Residual")]) - title_layout = QHBoxLayout() - title_layout.addItem(checkbox_and_poseID_spacer) - title_layout.addWidget(title_labels) - return title_layout - - def add_pose_pair(self, pose_pair) -> Optional[PosePairWidget]: - poseID = self.get_current_poseID() - if poseID in self.pose_pair_widgets: - reply = QMessageBox.question( - self, - "Replace Pose Pair", - "This will replace the selected Pose Pair. Do you want to proceed?", - QMessageBox.Yes | QMessageBox.No, - ) - if reply == QMessageBox.Yes: - self.pose_pair_widgets[poseID].update_information(pose_pair) - return self.pose_pair_widgets[poseID] - return None - - pose_pair_widget = PosePairWidget(poseID=poseID, directory=self.directory, pose_pair=pose_pair) - pose_pair_widget.clickable_labels.clicked.connect(lambda: self.on_pose_pair_widget_clicked(pose_pair_widget)) - - self.pose_pairs_layout.insertWidget(pose_pair_widget.poseID, pose_pair_widget) - self.pose_pair_widgets[poseID] = pose_pair_widget - self.pose_pairs_updated.emit(len(self.pose_pair_widgets)) - return pose_pair_widget - - def get_current_poseID(self) -> int: - for pose_pair_widget in self.pose_pair_widgets.values(): - if pose_pair_widget.clickable_labels.isChecked(): - return pose_pair_widget.poseID - return len(self.pose_pair_widgets) - - def number_of_active_pose_pairs(self) -> int: - return len( - [ - pose_pair_widget - for pose_pair_widget in self.pose_pair_widgets.values() - if pose_pair_widget.selected_checkbox.isChecked() - ] - ) - - def get_detection_results(self) -> List[zivid.calibration.HandEyeInput]: - return [ - zivid.calibration.HandEyeInput( - zivid.calibration.Pose(pose_pair_widget.pose_pair.robot_pose.as_matrix()), - pose_pair_widget.pose_pair.detection_result, - ) - for pose_pair_widget in self.pose_pair_widgets.values() - if pose_pair_widget.selected_checkbox.isChecked() - ] - - def set_residuals(self, residuals: List[Any]): - checked_pose_pairs = [ - pose_pair_widget - for pose_pair_widget in self.pose_pair_widgets.values() - if pose_pair_widget.selected_checkbox.isChecked() - ] - for pose_pair_widget, residual in zip(checked_pose_pairs, residuals): # noqa: B905 - pose_pair_widget.clickable_labels.labels[2].setText( - f"{residual.translation():.2f} ({residual.rotation():.2f}°)" - ) - unchecked_pose_pairs = [ - pose_pair_widget - for pose_pair_widget in self.pose_pair_widgets.values() - if not pose_pair_widget.selected_checkbox.isChecked() - ] - for pose_pair_widget in unchecked_pose_pairs: - pose_pair_widget.clickable_labels.labels[2].setText("NA") - - def _clear_layout(self, layout): - while layout.count(): - item = layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - sublayout = item.layout() - if sublayout: - self._clear_layout(sublayout) - - def clear(self): - self._clear_layout(self.pose_pairs_layout) - self.pose_pair_widgets.clear() - self.pose_pairs_updated.emit(0) diff --git a/modules/zividsamples/gui/preparation/__init__.py b/modules/zividsamples/gui/preparation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/zividsamples/gui/infield_correction_data_selection_widget.py b/modules/zividsamples/gui/preparation/infield_correction_data_selection_widget.py similarity index 92% rename from modules/zividsamples/gui/infield_correction_data_selection_widget.py rename to modules/zividsamples/gui/preparation/infield_correction_data_selection_widget.py index f6cbfb09..bec8e62c 100644 --- a/modules/zividsamples/gui/infield_correction_data_selection_widget.py +++ b/modules/zividsamples/gui/preparation/infield_correction_data_selection_widget.py @@ -1,14 +1,15 @@ import json +import threading from collections import OrderedDict from pathlib import Path -from typing import List, Union +from typing import List, Optional, Union import numpy as np import zivid from matplotlib import pyplot as plt from nptyping import Float32, NDArray, Shape from numpy import uint8 as UInt8 -from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal from PyQt5.QtGui import QFontMetrics, QImage from PyQt5.QtWidgets import ( QApplication, @@ -24,8 +25,8 @@ QVBoxLayout, QWidget, ) -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.fov import CameraFOV, FOVThresholds, PointsOfInterest, PointsOfInterest2D, PositionInFOV +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.fov import CameraFOV, FOVThresholds, PointsOfInterest, PointsOfInterest2D, PositionInFOV def _label_width(label: QLabel, numbers: int) -> int: @@ -542,16 +543,48 @@ def update_gui(self): self.trueness_label.setText(self.infield_correction_input_data.local_trueness_as_string()) +class _InfieldLoadWorker(QObject): + """Loads infield correction data from disk in a background thread.""" + + item_loaded = pyqtSignal(int, object) + finished = pyqtSignal(int) + + def __init__(self, generation, files): + super().__init__() + self._generation = generation + self._files = files + self._cancel_event = threading.Event() + self._cv2_handler = CV2Handler() + + def cancel(self): + self._cancel_event.set() + + def run(self): + for file in self._files: + if self._cancel_event.is_set(): + break + try: + data = InfieldCorrectionInputDataCore.from_path(file, self._cv2_handler) + self.item_loaded.emit(self._generation, data) + except Exception as e: # pylint: disable=broad-except + print(f"Failed to load: {e}") + self.finished.emit(self._generation) + + class InfieldCorrectionDataSelectionWidget(QWidget): data_directory: Path infield_input_data_clicked = pyqtSignal(InfieldCorrectionInputDataCore) infield_input_data_updated = pyqtSignal(int) + loading_finished = pyqtSignal() def __init__(self, directory: Path, parent=None): super().__init__(parent) self.cv2_handler = CV2Handler() self.data_directory = directory + self._load_generation = 0 + self._loader_thread: Optional[QThread] = None + self._loader_worker: Optional[_InfieldLoadWorker] = None self.infield_input_data_widgets: OrderedDict[int, InfieldCorrectionInputWidget] = OrderedDict() self.create_widgets() @@ -625,19 +658,48 @@ def update_data_directory(self, data_directory: Path): self.load_existing_data(existing_files) def load_existing_data(self, existing_files: List[Path]): + self.cancel_loading() + + self._load_generation += 1 + generation = self._load_generation + self.infield_input_group_box.setStyleSheet(r"QGroupBox {border: 2px solid yellow;}") self.infield_input_group_box.setTitle("Infield Correction Input Data (loading...)") self.setVisible(True) - QApplication.processEvents() - for file in sorted(existing_files): - try: - infield_input_data = InfieldCorrectionInputDataCore.from_path(file, self.cv2_handler) - self.add_infield_input_data(infield_input_data) - except Exception as e: - print(f"Failed to load: {e}") - QApplication.processEvents() + + self._loader_thread = QThread() + self._loader_worker = _InfieldLoadWorker( + generation=generation, + files=sorted(existing_files), + ) + self._loader_worker.moveToThread(self._loader_thread) + assert self._loader_thread is not None + self._loader_thread.started.connect(self._loader_worker.run) + self._loader_worker.item_loaded.connect(self._on_item_loaded) + self._loader_worker.finished.connect(self._on_loading_finished) + self._loader_worker.finished.connect(self._loader_thread.quit) + self._loader_thread.start() + + def cancel_loading(self): + if self._loader_worker is not None: + self._loader_worker.cancel() + if self._loader_thread is not None and self._loader_thread.isRunning(): + self._loader_thread.quit() + self._loader_thread.wait(10000) + self._loader_worker = None + self._loader_thread = None + + def _on_item_loaded(self, generation: int, data: InfieldCorrectionInputDataCore): + if generation != self._load_generation: + return + self.add_infield_input_data(data) + + def _on_loading_finished(self, generation: int): + if generation != self._load_generation: + return self.infield_input_group_box.setStyleSheet("") self.infield_input_group_box.setTitle("Infield Correction Input Data") + self.loading_finished.emit() def on_infield_input_data_widget_selection_box_clicked(self): self.infield_input_data_updated.emit(self.can_calculate_correction()) @@ -678,9 +740,14 @@ def show_as_busy(self, active: bool): ) QApplication.processEvents() + def is_loading(self) -> bool: + return self._loader_thread is not None and self._loader_thread.isRunning() + def add_infield_input_data( self, infield_input_data: Union[InfieldCorrectionInputData, InfieldCorrectionInputDataCore] ) -> None: + if self.is_loading(): + return poseID = self.get_current_poseID() if poseID in self.infield_input_data_widgets: reply = QMessageBox.question( @@ -756,6 +823,7 @@ def _clear_layout(self, layout): self._clear_layout(sublayout) def clear_gui(self): + self.cancel_loading() self._clear_layout(self.infield_input_layout) self.infield_input_data_widgets.clear() self.infield_input_data_updated.emit(self.can_calculate_correction()) diff --git a/modules/zividsamples/gui/infield_correction_gui.py b/modules/zividsamples/gui/preparation/infield_correction_gui.py similarity index 91% rename from modules/zividsamples/gui/infield_correction_gui.py rename to modules/zividsamples/gui/preparation/infield_correction_gui.py index 12e06772..cef48c41 100644 --- a/modules/zividsamples/gui/infield_correction_gui.py +++ b/modules/zividsamples/gui/preparation/infield_correction_gui.py @@ -14,20 +14,20 @@ from nptyping import NDArray, Shape, UInt8 from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QVBoxLayout, QWidget -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.detection_visualization import DetectionVisualizationWidget -from zividsamples.gui.hand_eye_configuration import CalibrationObject, HandEyeConfiguration -from zividsamples.gui.infield_correction_data_selection_widget import ( +from zividsamples.gui.preparation.infield_correction_data_selection_widget import ( InfieldCorrectionDataSelectionWidget, InfieldCorrectionInputData, ) -from zividsamples.gui.infield_correction_result_widget import InfieldCorrectionResultWidget +from zividsamples.gui.preparation.infield_correction_result_widget import InfieldCorrectionResultWidget from zividsamples.gui.qt_application import styled_link -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.rotation_format_configuration import RotationInformation -from zividsamples.gui.settings_selector import SettingsPixelMappingIntrinsics -from zividsamples.gui.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.detection_visualization import DetectionVisualizationWidget +from zividsamples.gui.widgets.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject, HandEyeConfiguration +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation +from zividsamples.gui.wizard.settings_selector import SettingsPixelMappingIntrinsics from zividsamples.transformation_matrix import TransformationMatrix @@ -39,6 +39,7 @@ class InfieldCorrectionGUI(TabWidgetWithRobotSupport): hand_eye_configuration: HandEyeConfiguration checkerboard_pose_in_camera_frame: Optional[TransformationMatrix] = None correction_finished = pyqtSignal(TransformationMatrix) + loading_finished = pyqtSignal() instructions_updated: pyqtSignal = pyqtSignal() apply_correction_button_clicked: pyqtSignal = pyqtSignal() update_projection = pyqtSignal(bool) @@ -121,6 +122,7 @@ def connect_signals(self): self.project_fov_hint_button.clicked.connect(self.on_project_fov_hint_button_clicked) self.infield_input_data_selection_widget.infield_input_data_clicked.connect(self.on_infield_input_data_clicked) self.infield_input_data_selection_widget.infield_input_data_updated.connect(self.on_infield_input_data_updated) + self.infield_input_data_selection_widget.loading_finished.connect(self.loading_finished) def update_instructions(self, has_detection_result: bool, applied_correction: bool): self.has_detection_result = has_detection_result @@ -147,7 +149,11 @@ def robot_configuration_update(self, robot_configuration: RobotConfiguration): def on_actual_pose_updated(self, robot_target: RobotTarget): pass + def is_loading(self) -> bool: + return self.infield_input_data_selection_widget.is_loading() + def on_pending_changes(self): + self.infield_input_data_selection_widget.clear_gui() self.infield_input_data_selection_widget.update_data_directory(self.data_directory) def on_tab_visibility_changed(self, is_current: bool): diff --git a/modules/zividsamples/gui/infield_correction_result_widget.py b/modules/zividsamples/gui/preparation/infield_correction_result_widget.py similarity index 100% rename from modules/zividsamples/gui/infield_correction_result_widget.py rename to modules/zividsamples/gui/preparation/infield_correction_result_widget.py diff --git a/modules/zividsamples/gui/warmup_gui.py b/modules/zividsamples/gui/preparation/warmup_gui.py similarity index 99% rename from modules/zividsamples/gui/warmup_gui.py rename to modules/zividsamples/gui/preparation/warmup_gui.py index 9eea75a4..53397347 100644 --- a/modules/zividsamples/gui/warmup_gui.py +++ b/modules/zividsamples/gui/preparation/warmup_gui.py @@ -35,8 +35,8 @@ capture_and_measure, capture_and_measure_from_frame, ) -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.tab_content_widget import TabContentWidget +from zividsamples.gui.widgets.tab_content_widget import TabContentWidget +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration from zividsamples.paths import get_data_file_path diff --git a/modules/zividsamples/gui/qt_application.py b/modules/zividsamples/gui/qt_application.py index 98a4884e..f991c6e8 100644 --- a/modules/zividsamples/gui/qt_application.py +++ b/modules/zividsamples/gui/qt_application.py @@ -2,6 +2,7 @@ import os import sys from dataclasses import dataclass +from pathlib import Path from typing import Tuple from PyQt5.QtGui import QColor, QFont, QIcon @@ -11,6 +12,35 @@ os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1" os.environ["QT_SCALE_FACTOR_ROUNDING_POLICY"] = "PassThrough" +_LINUX_APP_ID = "zivid-hand-eye-gui" + + +def _ensure_linux_desktop_entry(icon_source: Path) -> None: + local_share = Path.home() / ".local" / "share" + + icon_dir = local_share / "icons" / "hicolor" / "256x256" / "apps" + icon_dir.mkdir(parents=True, exist_ok=True) + icon_dest = icon_dir / f"{_LINUX_APP_ID}.png" + if not icon_dest.exists() or icon_dest.read_bytes() != icon_source.read_bytes(): + import shutil # pylint: disable=import-outside-toplevel + + shutil.copy2(icon_source, icon_dest) + + desktop_dir = local_share / "applications" + desktop_dir.mkdir(parents=True, exist_ok=True) + desktop_file = desktop_dir / f"{_LINUX_APP_ID}.desktop" + content = ( + "[Desktop Entry]\n" + "Type=Application\n" + "Name=Zivid Hand Eye GUI\n" + f"Exec={sys.executable}\n" + f"Icon={_LINUX_APP_ID}\n" + f"StartupWMClass={_LINUX_APP_ID}\n" + "Terminal=false\n" + ) + if not desktop_file.exists() or desktop_file.read_text(encoding="utf-8") != content: + desktop_file.write_text(content, encoding="utf-8") + def _color_text(color: Tuple[int, int, int], opacity: float) -> str: return f"rgba({color[0]}, {color[1]}, {color[2]}, {opacity})" @@ -244,6 +274,8 @@ def __init__(self, use_zivid_app: bool = True): raise RuntimeError( "When using a ZividQtApplication you cannot directly load/import cv2. It has conflicting versions of Qt on some platforms. Instead, add functionality to zividsamples.cv2_handler" ) + if sys.platform != "win32": + sys.argv[0] = _LINUX_APP_ID super().__init__(sys.argv) self.setStyleSheet( MAIN_STYLE @@ -266,6 +298,10 @@ def __init__(self, use_zivid_app: bool = True): def run(self, win, title: str = "Zivid Qt Application"): icon_path = get_image_file_path("LogoZBlue.ico") self.setWindowIcon(QIcon(icon_path.absolute().as_posix())) + if sys.platform == "win32": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("zivid.app.qt_application") + else: + _ensure_linux_desktop_entry(get_image_file_path("LogoZBlue.png")) win.setWindowTitle(title) win.show() screen_geometry = QDesktopWidget().availableGeometry() @@ -276,8 +312,6 @@ def run(self, win, title: str = "Zivid Qt Application"): win.setGeometry(50, 50, window_width, window_height) win.resize(window_width, window_height) win.move((screen_width - window_width) // 2, (screen_height - window_height) // 2) - if sys.platform == "win32": - ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID("zivid.app.qt_application") return self.exec_() diff --git a/modules/zividsamples/gui/robot/__init__.py b/modules/zividsamples/gui/robot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/zividsamples/gui/robot_control.py b/modules/zividsamples/gui/robot/robot_control.py similarity index 96% rename from modules/zividsamples/gui/robot_control.py rename to modules/zividsamples/gui/robot/robot_control.py index 59c7c0e9..92e91fcd 100644 --- a/modules/zividsamples/gui/robot_control.py +++ b/modules/zividsamples/gui/robot/robot_control.py @@ -3,7 +3,7 @@ from typing import Dict from PyQt5.QtCore import QObject, pyqtSignal -from zividsamples.gui.robot_configuration import RobotConfiguration +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration from zividsamples.transformation_matrix import TransformationMatrix diff --git a/modules/zividsamples/gui/robot_control_robodk.py b/modules/zividsamples/gui/robot/robot_control_robodk.py similarity index 98% rename from modules/zividsamples/gui/robot_control_robodk.py rename to modules/zividsamples/gui/robot/robot_control_robodk.py index 319c3f64..8616fc65 100644 --- a/modules/zividsamples/gui/robot_control_robodk.py +++ b/modules/zividsamples/gui/robot/robot_control_robodk.py @@ -8,8 +8,8 @@ from PyQt5.QtWidgets import QFileDialog, QMessageBox from robodk.robolink import ITEM_TYPE_ROBOT, RUNMODE_RUN_ROBOT, Item, Robolink, TargetReachError from robodk.robomath import Mat -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotControl, RobotTarget +from zividsamples.gui.robot.robot_control import RobotControl, RobotTarget +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration from zividsamples.transformation_matrix import Distance, TransformationMatrix diff --git a/modules/zividsamples/gui/robot_control_ur_rtde_read_only.py b/modules/zividsamples/gui/robot/robot_control_ur_rtde_read_only.py similarity index 96% rename from modules/zividsamples/gui/robot_control_ur_rtde_read_only.py rename to modules/zividsamples/gui/robot/robot_control_ur_rtde_read_only.py index a7bdade1..b1adb3e9 100644 --- a/modules/zividsamples/gui/robot_control_ur_rtde_read_only.py +++ b/modules/zividsamples/gui/robot/robot_control_ur_rtde_read_only.py @@ -6,8 +6,8 @@ import numpy as np from rtde import rtde, rtde_config from scipy.spatial.transform import Rotation -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotControlReadOnly, RobotTarget +from zividsamples.gui.robot.robot_control import RobotControlReadOnly, RobotTarget +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration from zividsamples.transformation_matrix import TransformationMatrix UR_RTDE_RECIPE = """\ diff --git a/modules/zividsamples/gui/robot_control_widget.py b/modules/zividsamples/gui/robot/robot_control_widget.py similarity index 98% rename from modules/zividsamples/gui/robot_control_widget.py rename to modules/zividsamples/gui/robot/robot_control_widget.py index d142618c..cd1c8378 100644 --- a/modules/zividsamples/gui/robot_control_widget.py +++ b/modules/zividsamples/gui/robot/robot_control_widget.py @@ -15,10 +15,10 @@ QVBoxLayout, QWidget, ) -from zividsamples.gui.robot_configuration import RobotConfiguration, RobotEnum -from zividsamples.gui.robot_control import RobotControl, RobotTarget -from zividsamples.gui.robot_control_robodk import RobotControlRoboDK -from zividsamples.gui.robot_control_ur_rtde_read_only import RobotControlURRTDEReadOnly +from zividsamples.gui.robot.robot_control import RobotControl, RobotTarget +from zividsamples.gui.robot.robot_control_robodk import RobotControlRoboDK +from zividsamples.gui.robot.robot_control_ur_rtde_read_only import RobotControlURRTDEReadOnly +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration, RobotEnum from zividsamples.transformation_matrix import TransformationMatrix RobotControllers = { diff --git a/modules/zividsamples/gui/verification/__init__.py b/modules/zividsamples/gui/verification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/zividsamples/gui/capture_at_pose_selection_widget.py b/modules/zividsamples/gui/verification/capture_at_pose_selection_widget.py similarity index 60% rename from modules/zividsamples/gui/capture_at_pose_selection_widget.py rename to modules/zividsamples/gui/verification/capture_at_pose_selection_widget.py index fb7834a6..72b0593d 100644 --- a/modules/zividsamples/gui/capture_at_pose_selection_widget.py +++ b/modules/zividsamples/gui/verification/capture_at_pose_selection_widget.py @@ -1,10 +1,12 @@ +import threading from collections import OrderedDict from pathlib import Path +from typing import Optional import numpy as np import zivid from nptyping import Float32, NDArray, Shape -from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal from PyQt5.QtWidgets import ( QApplication, QCheckBox, @@ -59,6 +61,7 @@ def __init__( hand_eye_transform: TransformationMatrix, eye_in_hand: bool, optimize_for_speed: bool = True, + from_disk: bool = False, ): self.poseID = poseID @@ -67,19 +70,21 @@ def __init__( self.robot_pose_yaml_path: Path = self.directory / f"robot_pose_{self.poseID}.yaml" self.camera_frame_path: Path = self.directory / f"capture_{self.poseID}.zdf" self.robot_pose = robot_pose - zivid.Matrix4x4(self.robot_pose.as_matrix()).save(self.robot_pose_yaml_path) self.camera_frame = camera_frame - self.camera_frame.save(self.camera_frame_path) - if optimize_for_speed: - self.camera_frame.point_cloud().downsample(zivid.PointCloud.Downsampling.by2x2) + if not from_disk: + zivid.Matrix4x4(self.robot_pose.as_matrix()).save(self.robot_pose_yaml_path) + self.camera_frame.save(self.camera_frame_path) - if eye_in_hand: - transform_robot_base_to_camera = self.robot_pose * hand_eye_transform - self.camera_frame.point_cloud().transform(zivid.Matrix4x4(transform_robot_base_to_camera.as_matrix())) - else: - transform_flange_to_camera = self.robot_pose.inv() * hand_eye_transform - self.camera_frame.point_cloud().transform(zivid.Matrix4x4(transform_flange_to_camera.as_matrix())) + if optimize_for_speed: + self.camera_frame.point_cloud().downsample(zivid.PointCloud.Downsampling.by2x2) + + if eye_in_hand: + transform_robot_base_to_camera = self.robot_pose * hand_eye_transform + self.camera_frame.point_cloud().transform(zivid.Matrix4x4(transform_robot_base_to_camera.as_matrix())) + else: + transform_flange_to_camera = self.robot_pose.inv() * hand_eye_transform + self.camera_frame.point_cloud().transform(zivid.Matrix4x4(transform_flange_to_camera.as_matrix())) self.selected_checkbox = QCheckBox("") self.selected_checkbox.setChecked(True) @@ -110,16 +115,66 @@ def robot_pose_yaml_text(self) -> str: return self.robot_pose_yaml_path.read_text(encoding="utf-8") +class _CaptureAtPoseLoadWorker(QObject): + """Loads capture-at-pose data from disk in a background thread.""" + + item_loaded = pyqtSignal(int, int, object, object) + finished = pyqtSignal(int) + + # pylint: disable=too-many-positional-arguments + def __init__(self, generation, directory, pose_ids, hand_eye_transform, eye_in_hand): + super().__init__() + self._generation = generation + self._directory = directory + self._pose_ids = pose_ids + self._hand_eye_transform = hand_eye_transform + self._eye_in_hand = eye_in_hand + self._cancel_event = threading.Event() + + def cancel(self): + self._cancel_event.set() + + def run(self): + for poseID in self._pose_ids: + if self._cancel_event.is_set(): + break + try: + robot_pose_yaml_path = self._directory / f"robot_pose_{poseID}.yaml" + camera_frame_path = self._directory / f"capture_{poseID}.zdf" + robot_pose = TransformationMatrix.from_matrix(np.asarray(zivid.Matrix4x4(robot_pose_yaml_path))) + camera_frame = zivid.Frame(camera_frame_path) + + if self._cancel_event.is_set(): + break + + camera_frame.point_cloud().downsample(zivid.PointCloud.Downsampling.by2x2) + + if self._eye_in_hand: + transform = robot_pose * self._hand_eye_transform + else: + transform = robot_pose.inv() * self._hand_eye_transform + camera_frame.point_cloud().transform(zivid.Matrix4x4(transform.as_matrix())) + + self.item_loaded.emit(self._generation, poseID, robot_pose, camera_frame) + except FileNotFoundError: + continue + self.finished.emit(self._generation) + + class CaptureAtPoseSelectionWidget(QWidget): directory: Path capture_at_pose_clicked = pyqtSignal(CaptureAtPose) capture_at_pose_remove_clicked = pyqtSignal(CaptureAtPose) selected_captures_updated = pyqtSignal() + loading_finished = pyqtSignal() def __init__(self, directory: Path, parent=None): super().__init__(parent) self.directory = directory + self._load_generation = 0 + self._loader_thread: Optional[QThread] = None + self._loader_worker: Optional[_CaptureAtPoseLoadWorker] = None self.capture_at_poses: OrderedDict[int, CaptureAtPose] = OrderedDict() self.captures_group_box_layout = QVBoxLayout() @@ -159,28 +214,85 @@ def load_capture_at_poses(self, hand_eye_transform: TransformationMatrix, eye_in self.clear() else: return + + self.cancel_loading() + + pose_ids = [ + pid + for pid in range(20) + if (self.directory / f"robot_pose_{pid}.yaml").exists() and (self.directory / f"capture_{pid}.zdf").exists() + ] + if not pose_ids: + return + + self._load_generation += 1 + generation = self._load_generation + self.captures_group_box.setTitle(f"Loading from {self.directory}...") - QApplication.processEvents() - for poseID in range(20): - try: - # Load from files - try: - robot_pose_yaml_path: Path = self.directory / f"robot_pose_{poseID}.yaml" - camera_frame_path: Path = self.directory / f"capture_{poseID}.zdf" - robot_pose = TransformationMatrix.from_matrix(np.asarray(zivid.Matrix4x4(robot_pose_yaml_path))) - camera_frame = zivid.Frame(camera_frame_path) - except (FileNotFoundError, RuntimeError) as ex: - raise FileNotFoundError(f"Failed to load pose pair from {self.directory}: {ex}") from ex - except FileNotFoundError: - continue - self.add_capture_at_pose( - robot_pose=robot_pose, - camera_frame=camera_frame, - hand_eye_transform=hand_eye_transform, - eye_in_hand=eye_in_hand, - ) - QApplication.processEvents() + + self._loader_thread = QThread() + self._loader_worker = _CaptureAtPoseLoadWorker( + generation=generation, + directory=self.directory, + pose_ids=pose_ids, + hand_eye_transform=hand_eye_transform, + eye_in_hand=eye_in_hand, + ) + self._loader_worker.moveToThread(self._loader_thread) + assert self._loader_thread is not None + self._loader_thread.started.connect(self._loader_worker.run) + self._loader_worker.item_loaded.connect(self._on_capture_loaded) + self._loader_worker.finished.connect(self._on_loading_finished) + self._loader_worker.finished.connect(self._loader_thread.quit) + self._loader_thread.start() + + def cancel_loading(self): + if self._loader_worker is not None: + self._loader_worker.cancel() + if self._loader_thread is not None and self._loader_thread.isRunning(): + self._loader_thread.quit() + self._loader_thread.wait(10000) + self._loader_worker = None + self._loader_thread = None + + def _on_capture_loaded( + self, generation: int, poseID: int, robot_pose: TransformationMatrix, camera_frame: zivid.Frame + ): + if generation != self._load_generation: + return + if poseID in self.capture_at_poses: + return + + dummy_transform = TransformationMatrix() + capture_at_pose = CaptureAtPose( + poseID=poseID, + directory=self.directory, + robot_pose=robot_pose, + camera_frame=camera_frame, + hand_eye_transform=dummy_transform, + eye_in_hand=True, + from_disk=True, + ) + capture_at_pose_layout = QHBoxLayout() + capture_at_pose.capture_pose_button.clicked.connect(lambda: self.on_capture_at_pose_clicked(capture_at_pose)) + capture_at_pose.remove_capture_at_pose_button.clicked.connect( + lambda: self.remove_capture_at_pose(capture_at_pose) + ) + capture_at_pose.selected_checkbox.clicked.connect(self.selected_captures_updated.emit) + + capture_at_pose_layout.addWidget(QLabel(f"{capture_at_pose.poseID}")) + capture_at_pose_layout.addWidget(capture_at_pose.selected_checkbox) + capture_at_pose_layout.addWidget(capture_at_pose.capture_pose_button) + capture_at_pose_layout.addWidget(capture_at_pose.remove_capture_at_pose_button) + self.captures_layout.insertLayout(capture_at_pose.poseID, capture_at_pose_layout) + self.capture_at_poses[poseID] = capture_at_pose + self.clear_button.setEnabled(True) + + def _on_loading_finished(self, generation: int): + if generation != self._load_generation: + return self.captures_group_box.setTitle("Captures") + self.loading_finished.emit() def get_selected_capture_at_poses(self): return [pose_pair for pose_pair in self.capture_at_poses.values() if pose_pair.selected_checkbox.isChecked()] @@ -200,6 +312,9 @@ def remove_capture_at_pose(self, capture_at_pose: CaptureAtPose): del self.capture_at_poses[capture_at_pose.poseID] self._clear_layout(self.captures_layout.itemAt(capture_at_pose.poseID)) + def is_loading(self) -> bool: + return self._loader_thread is not None and self._loader_thread.isRunning() + def add_capture_at_pose( self, robot_pose: TransformationMatrix, @@ -207,6 +322,8 @@ def add_capture_at_pose( hand_eye_transform: TransformationMatrix, eye_in_hand: bool, ): + if self.is_loading(): + return poseID = self.get_current_poseID() if poseID in self.capture_at_poses: reply = QMessageBox.question( @@ -271,6 +388,7 @@ def _clear_layout(self, layout): self._clear_layout(sublayout) def clear(self): + self.cancel_loading() self._clear_layout(self.captures_layout) self.capture_at_poses.clear() self.selected_captures_updated.emit() diff --git a/modules/zividsamples/gui/hand_eye_verification_gui.py b/modules/zividsamples/gui/verification/hand_eye_verification_gui.py similarity index 96% rename from modules/zividsamples/gui/hand_eye_verification_gui.py rename to modules/zividsamples/gui/verification/hand_eye_verification_gui.py index ef647fc1..2768f122 100644 --- a/modules/zividsamples/gui/hand_eye_verification_gui.py +++ b/modules/zividsamples/gui/verification/hand_eye_verification_gui.py @@ -15,16 +15,16 @@ from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QGridLayout, QPushButton, QVBoxLayout, QWidget from zivid.calibration import MarkerShape -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.detection_visualization import DetectionVisualizationWidget -from zividsamples.gui.hand_eye_configuration import CalibrationObject, HandEyeConfiguration -from zividsamples.gui.marker_widget import MarkerConfiguration, generate_marker_dictionary -from zividsamples.gui.pose_widget import MarkerPosesWidget, PoseWidget, PoseWidgetDisplayMode -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.rotation_format_configuration import RotationInformation -from zividsamples.gui.settings_selector import SettingsPixelMappingIntrinsics -from zividsamples.gui.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.detection_visualization import DetectionVisualizationWidget +from zividsamples.gui.widgets.pose_widget import MarkerPosesWidget, PoseWidget, PoseWidgetDisplayMode +from zividsamples.gui.widgets.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject, HandEyeConfiguration +from zividsamples.gui.wizard.marker_configuration import MarkerConfiguration, generate_marker_dictionary +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation +from zividsamples.gui.wizard.settings_selector import SettingsPixelMappingIntrinsics from zividsamples.save_load_transformation_matrix import load_transformation_matrix, save_transformation_matrix from zividsamples.transformation_matrix import TransformationMatrix diff --git a/modules/zividsamples/gui/stitch_gui.py b/modules/zividsamples/gui/verification/stitch_gui.py similarity index 91% rename from modules/zividsamples/gui/stitch_gui.py rename to modules/zividsamples/gui/verification/stitch_gui.py index 53f23253..fa680476 100644 --- a/modules/zividsamples/gui/stitch_gui.py +++ b/modules/zividsamples/gui/verification/stitch_gui.py @@ -15,14 +15,14 @@ from PyQt5.QtCore import pyqtSignal from PyQt5.QtGui import QImage from PyQt5.QtWidgets import QCheckBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget -from zividsamples.gui.capture_at_pose_selection_widget import CaptureAtPose, CaptureAtPoseSelectionWidget -from zividsamples.gui.hand_eye_configuration import HandEyeConfiguration -from zividsamples.gui.pointcloud_visualizer import VisualizerWidget -from zividsamples.gui.pose_widget import PoseWidget, PoseWidgetDisplayMode -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.rotation_format_configuration import RotationInformation -from zividsamples.gui.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.verification.capture_at_pose_selection_widget import CaptureAtPose, CaptureAtPoseSelectionWidget +from zividsamples.gui.widgets.pointcloud_visualizer import VisualizerWidget +from zividsamples.gui.widgets.pose_widget import PoseWidget, PoseWidgetDisplayMode +from zividsamples.gui.widgets.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.wizard.hand_eye_configuration import HandEyeConfiguration +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation from zividsamples.transformation_matrix import TransformationMatrix @@ -33,6 +33,7 @@ class StitchGUI(TabWidgetWithRobotSupport): has_detection_result: bool = False has_confirmed_robot_pose: bool = False point_cloud_widget: VisualizerWidget + loading_finished = pyqtSignal() instructions_updated: pyqtSignal = pyqtSignal() description: List[str] instruction_steps: Dict[str, bool] @@ -111,6 +112,8 @@ def connect_signals(self): self.confirm_robot_pose_button.clicked.connect(self.on_confirm_robot_pose_button_clicked) self.capture_at_pose_selection_widget.capture_at_pose_clicked.connect(self.on_capture_at_pose_selected) self.capture_at_pose_selection_widget.selected_captures_updated.connect(self.update_stitched_view) + self.capture_at_pose_selection_widget.loading_finished.connect(self.update_stitched_view) + self.capture_at_pose_selection_widget.loading_finished.connect(self.loading_finished) self.uniform_color_check_box.stateChanged.connect(self.update_stitched_view) def update_instructions(self, captured: bool, robot_pose_confirmed: bool): @@ -137,10 +140,12 @@ def on_pending_changes(self): self.hand_eye_pose_widget.get_transformation_matrix(), self.hand_eye_configuration.eye_in_hand, ) - self.update_stitched_view() else: self.capture_at_pose_selection_widget.set_directory(self.data_directory) + def is_loading(self) -> bool: + return self.capture_at_pose_selection_widget.is_loading() + def on_tab_visibility_changed(self, is_current: bool): if is_current: self.update_stitched_view() diff --git a/modules/zividsamples/gui/touch_gui.py b/modules/zividsamples/gui/verification/touch_gui.py similarity index 91% rename from modules/zividsamples/gui/touch_gui.py rename to modules/zividsamples/gui/verification/touch_gui.py index e87811b8..55f4d220 100644 --- a/modules/zividsamples/gui/touch_gui.py +++ b/modules/zividsamples/gui/verification/touch_gui.py @@ -19,17 +19,17 @@ from PyQt5.QtCore import pyqtSignal from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.hand_eye_configuration import HandEyeConfiguration -from zividsamples.gui.image_viewer import ImageViewer -from zividsamples.gui.marker_widget import generate_marker_dictionary -from zividsamples.gui.pose_widget import MarkerPosesWidget, PoseWidget, PoseWidgetDisplayMode -from zividsamples.gui.robot_configuration import RobotConfiguration -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.rotation_format_configuration import RotationInformation -from zividsamples.gui.settings_selector import SettingsPixelMappingIntrinsics -from zividsamples.gui.tab_with_robot_support import TabWidgetWithRobotSupport -from zividsamples.gui.touch_configuration import TouchConfigurationWidget +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.image_viewer import ImageViewer +from zividsamples.gui.widgets.pose_widget import MarkerPosesWidget, PoseWidget, PoseWidgetDisplayMode +from zividsamples.gui.widgets.tab_with_robot_support import TabWidgetWithRobotSupport +from zividsamples.gui.wizard.hand_eye_configuration import HandEyeConfiguration +from zividsamples.gui.wizard.marker_configuration import generate_marker_dictionary +from zividsamples.gui.wizard.robot_configuration import RobotConfiguration +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation +from zividsamples.gui.wizard.settings_selector import SettingsPixelMappingIntrinsics +from zividsamples.gui.wizard.touch_configuration import TouchConfigurationWidget from zividsamples.transformation_matrix import TransformationMatrix diff --git a/modules/zividsamples/gui/verification/verification_buttons_widget.py b/modules/zividsamples/gui/verification/verification_buttons_widget.py new file mode 100644 index 00000000..f4677886 --- /dev/null +++ b/modules/zividsamples/gui/verification/verification_buttons_widget.py @@ -0,0 +1,45 @@ +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QGroupBox, QHBoxLayout, QPushButton, QWidget +from zividsamples.gui.wizard.hand_eye_configuration import HandEyeConfiguration + + +class HandEyeVerificationButtonsWidget(QWidget): + project_button_clicked = pyqtSignal() + + def __init__( + self, + hand_eye_configuration: HandEyeConfiguration, + parent=None, + ): + super().__init__(parent) + + # Define buttons + self.project_button = QPushButton() + self.project_button.setCheckable(True) + self.project_button.setDisabled(True) + self.on_hand_eye_configuration_updated(hand_eye_configuration) + + # Connect signals + self.project_button.clicked.connect(self.on_project_button_clicked) + + # Add buttons to layout + verify_group_box = QGroupBox("Projection") + verify_group_box_layout = QHBoxLayout() + verify_group_box.setLayout(verify_group_box_layout) + + verify_group_box_layout.addWidget(self.project_button) + + buttons_layout = QHBoxLayout() + buttons_layout.addWidget(verify_group_box) + + self.setLayout(buttons_layout) + + def on_project_button_clicked(self): + self.project_button_clicked.emit() + if self.project_button.isChecked(): + self.project_button.setStyleSheet("background-color: green;") + else: + self.project_button.setStyleSheet("") + + def on_hand_eye_configuration_updated(self, hand_eye_configuration: HandEyeConfiguration): + self.project_button.setText(f"Project on {hand_eye_configuration.calibration_object.name}") diff --git a/modules/zividsamples/gui/widgets/__init__.py b/modules/zividsamples/gui/widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/zividsamples/gui/aspect_ratio_label.py b/modules/zividsamples/gui/widgets/aspect_ratio_label.py similarity index 100% rename from modules/zividsamples/gui/aspect_ratio_label.py rename to modules/zividsamples/gui/widgets/aspect_ratio_label.py diff --git a/modules/zividsamples/gui/buttons_widget.py b/modules/zividsamples/gui/widgets/camera_buttons_widget.py similarity index 50% rename from modules/zividsamples/gui/buttons_widget.py rename to modules/zividsamples/gui/widgets/camera_buttons_widget.py index 00814acb..20151f3f 100644 --- a/modules/zividsamples/gui/buttons_widget.py +++ b/modules/zividsamples/gui/widgets/camera_buttons_widget.py @@ -2,8 +2,7 @@ import zivid from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QApplication, QCheckBox, QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget -from zividsamples.gui.hand_eye_configuration import HandEyeConfiguration +from PyQt5.QtWidgets import QApplication, QGroupBox, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget class CameraButtonsWidget(QWidget): @@ -86,104 +85,15 @@ def set_information(self, text: str): self.information_label.show() self.information_label.setText(text) - def disable_buttons(self): + def disable_buttons(self, capture_tooltip: str = ""): self.connect_button.setEnabled(False) self.capture_button.setEnabled(False) + self.capture_button.setToolTip(capture_tooltip) def enable_buttons(self): self.connect_button.setEnabled(True) self.capture_button.setEnabled(True) + self.capture_button.setToolTip("") def get_tab_widgets_in_order(self) -> List[QWidget]: return [self.connect_button, self.capture_button] - - -class HandEyeCalibrationButtonsWidget(QWidget): - calibrate_button_clicked = pyqtSignal() - use_fixed_objects_toggled = pyqtSignal(bool) - - def __init__(self, parent=None): - super().__init__(parent) - - # Define buttons - self.calibrate_button = QPushButton("Calibrate") - self.calibrate_button.setObjectName("HandEye-calibrate_button") - self.use_fixed_objects_checkbox = QCheckBox("Fixed Objects - for low DOF systems") - self.use_fixed_objects_checkbox.setObjectName("HandEye-fixed_objects_checkbox") - - # Connect signals - self.calibrate_button.clicked.connect(self.on_calibrate_button_clicked) - self.use_fixed_objects_checkbox.toggled.connect(self.on_use_fixed_objects_toggled) - - # Add buttons to layout - calibrate_group_box = QGroupBox("Calibrate") - calibrate_group_box_layout = QHBoxLayout() - calibrate_group_box.setLayout(calibrate_group_box_layout) - - calibrate_group_box_layout.addWidget(self.calibrate_button) - calibrate_group_box_layout.addWidget(self.use_fixed_objects_checkbox) - - buttons_layout = QHBoxLayout() - buttons_layout.addWidget(calibrate_group_box) - - self.setLayout(buttons_layout) - - def on_calibrate_button_clicked(self): - self.calibrate_button.setStyleSheet("background-color: yellow;") - QApplication.processEvents() - self.calibrate_button_clicked.emit() - self.calibrate_button.setStyleSheet("") - - def on_use_fixed_objects_toggled(self, checked: bool): - self.use_fixed_objects_toggled.emit(checked) - - def disable_buttons(self): - self.calibrate_button.setEnabled(False) - - def enable_buttons(self): - self.calibrate_button.setEnabled(True) - - def get_tab_widgets_in_order(self) -> List[QWidget]: - return [self.calibrate_button] - - -class HandEyeVerificationButtonsWidget(QWidget): - project_button_clicked = pyqtSignal() - - def __init__( - self, - hand_eye_configuration: HandEyeConfiguration, - parent=None, - ): - super().__init__(parent) - - # Define buttons - self.project_button = QPushButton() - self.project_button.setCheckable(True) - self.project_button.setDisabled(True) - self.on_hand_eye_configuration_updated(hand_eye_configuration) - - # Connect signals - self.project_button.clicked.connect(self.on_project_button_clicked) - - # Add buttons to layout - verify_group_box = QGroupBox("Projection") - verify_group_box_layout = QHBoxLayout() - verify_group_box.setLayout(verify_group_box_layout) - - verify_group_box_layout.addWidget(self.project_button) - - buttons_layout = QHBoxLayout() - buttons_layout.addWidget(verify_group_box) - - self.setLayout(buttons_layout) - - def on_project_button_clicked(self): - self.project_button_clicked.emit() - if self.project_button.isChecked(): - self.project_button.setStyleSheet("background-color: green;") - else: - self.project_button.setStyleSheet("") - - def on_hand_eye_configuration_updated(self, hand_eye_configuration: HandEyeConfiguration): - self.project_button.setText(f"Project on {hand_eye_configuration.calibration_object.name}") diff --git a/modules/zividsamples/gui/cv2_handler.py b/modules/zividsamples/gui/widgets/cv2_handler.py similarity index 99% rename from modules/zividsamples/gui/cv2_handler.py rename to modules/zividsamples/gui/widgets/cv2_handler.py index c67fda7f..0d8abed1 100644 --- a/modules/zividsamples/gui/cv2_handler.py +++ b/modules/zividsamples/gui/widgets/cv2_handler.py @@ -5,7 +5,7 @@ from nptyping import NDArray, Shape, UInt8 from zivid.calibration import MarkerShape from zivid.experimental import PixelMapping -from zividsamples.gui.fov import PointsOfInterest, PointsOfInterest2D +from zividsamples.gui.widgets.fov import PointsOfInterest, PointsOfInterest2D from zividsamples.transformation_matrix import TransformationMatrix diff --git a/modules/zividsamples/gui/detection_visualization.py b/modules/zividsamples/gui/widgets/detection_visualization.py similarity index 97% rename from modules/zividsamples/gui/detection_visualization.py rename to modules/zividsamples/gui/widgets/detection_visualization.py index 5c5dabd0..94c7ce7d 100644 --- a/modules/zividsamples/gui/detection_visualization.py +++ b/modules/zividsamples/gui/widgets/detection_visualization.py @@ -5,8 +5,8 @@ from PyQt5.QtCore import Qt from PyQt5.QtGui import QImage, QPixmap from PyQt5.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget -from zividsamples.gui.hand_eye_configuration import CalibrationObject, HandEyeConfiguration -from zividsamples.gui.image_viewer import ImageViewer +from zividsamples.gui.widgets.image_viewer import ImageViewer +from zividsamples.gui.wizard.hand_eye_configuration import CalibrationObject, HandEyeConfiguration from zividsamples.paths import get_image_file_path diff --git a/modules/zividsamples/gui/fov.py b/modules/zividsamples/gui/widgets/fov.py similarity index 100% rename from modules/zividsamples/gui/fov.py rename to modules/zividsamples/gui/widgets/fov.py diff --git a/modules/zividsamples/gui/image_viewer.py b/modules/zividsamples/gui/widgets/image_viewer.py similarity index 100% rename from modules/zividsamples/gui/image_viewer.py rename to modules/zividsamples/gui/widgets/image_viewer.py diff --git a/modules/zividsamples/gui/live_2d_widget.py b/modules/zividsamples/gui/widgets/live_2d_widget.py similarity index 99% rename from modules/zividsamples/gui/live_2d_widget.py rename to modules/zividsamples/gui/widgets/live_2d_widget.py index f51e0bee..5986aa38 100644 --- a/modules/zividsamples/gui/live_2d_widget.py +++ b/modules/zividsamples/gui/widgets/live_2d_widget.py @@ -9,7 +9,7 @@ from PyQt5.QtCore import Q_ARG, QMetaObject, Qt, pyqtSignal from PyQt5.QtGui import QCloseEvent, QColor, QImage, QPainter, QPixmap from PyQt5.QtWidgets import QApplication, QGroupBox, QVBoxLayout, QWidget -from zividsamples.gui.image_viewer import ImageViewer +from zividsamples.gui.widgets.image_viewer import ImageViewer class Live2DWidget(QWidget): diff --git a/modules/zividsamples/gui/pointcloud_visualizer.py b/modules/zividsamples/gui/widgets/pointcloud_visualizer.py similarity index 100% rename from modules/zividsamples/gui/pointcloud_visualizer.py rename to modules/zividsamples/gui/widgets/pointcloud_visualizer.py diff --git a/modules/zividsamples/gui/pose_widget.py b/modules/zividsamples/gui/widgets/pose_widget.py similarity index 99% rename from modules/zividsamples/gui/pose_widget.py rename to modules/zividsamples/gui/widgets/pose_widget.py index bbb71f4e..efe969e4 100644 --- a/modules/zividsamples/gui/pose_widget.py +++ b/modules/zividsamples/gui/widgets/pose_widget.py @@ -10,9 +10,9 @@ from PyQt5.QtGui import QPixmap from PyQt5.QtWidgets import QGridLayout, QGroupBox, QLabel, QLineEdit, QScrollArea, QVBoxLayout, QWidget from scipy.spatial.transform import Rotation -from zividsamples.gui.aspect_ratio_label import AspectRatioLabel from zividsamples.gui.qt_application import create_horizontal_line, create_vertical_line -from zividsamples.gui.rotation_format_configuration import ( +from zividsamples.gui.widgets.aspect_ratio_label import AspectRatioLabel +from zividsamples.gui.wizard.rotation_format_configuration import ( RotationFormats, RotationFormatSelectionWidget, RotationInformation, diff --git a/modules/zividsamples/gui/show_yaml_dialog.py b/modules/zividsamples/gui/widgets/show_yaml_dialog.py similarity index 100% rename from modules/zividsamples/gui/show_yaml_dialog.py rename to modules/zividsamples/gui/widgets/show_yaml_dialog.py diff --git a/modules/zividsamples/gui/tab_content_widget.py b/modules/zividsamples/gui/widgets/tab_content_widget.py similarity index 62% rename from modules/zividsamples/gui/tab_content_widget.py rename to modules/zividsamples/gui/widgets/tab_content_widget.py index 5f53e7a4..acfeb696 100644 --- a/modules/zividsamples/gui/tab_content_widget.py +++ b/modules/zividsamples/gui/widgets/tab_content_widget.py @@ -12,9 +12,11 @@ def __init__(self, data_directory: Path, parent=None): super().__init__(parent) self._is_current_tab = False self.data_directory = data_directory + self.session_info = None self.has_pending_changes = True - def update_data_directory(self, data_directory: Path): + def update_data_directory(self, data_directory: Path, session_info=None): + self.session_info = session_info if self.data_directory != data_directory: self.data_directory = data_directory self.has_pending_changes = True @@ -27,14 +29,17 @@ def is_current_tab(self): return self._is_current_tab def notify_current_tab(self, widget: QWidget): - """ - Called by the parent to notify this widget if it is now the currently visible tab. - :param widget: The widget that is currently visible. + """Called by the parent to notify this widget which tab is currently visible. + + Pending changes are processed immediately regardless of whether this tab is + current. Since loading happens in a background thread, all tabs can load in + parallel. The parent is expected to call the current tab first so it gets + priority. """ is_current = widget is self self._is_current_tab = is_current self.on_tab_visibility_changed(is_current) - if is_current and self.has_pending_changes: + if self.has_pending_changes: self.on_pending_changes() self.has_pending_changes = False @@ -47,9 +52,15 @@ def on_tab_visibility_changed(self, is_current: bool): """ raise NotImplementedError("Subclasses should implement this method.") + def is_loading(self) -> bool: + """Override in subclasses that perform background loading.""" + return False + def on_pending_changes(self): - """ - Override in subclasses to handle pending changes. - This is called when the tab becomes visible and there are pending changes. + """Override in subclasses to handle pending changes. + + Called whenever the data directory changes, for all tabs (not only the + currently visible one). Implementations should clear stale in-memory data + before loading to avoid showing unnecessary confirmation dialogs. """ raise NotImplementedError("Subclasses should implement this method.") diff --git a/modules/zividsamples/gui/tab_with_robot_support.py b/modules/zividsamples/gui/widgets/tab_with_robot_support.py similarity index 74% rename from modules/zividsamples/gui/tab_with_robot_support.py rename to modules/zividsamples/gui/widgets/tab_with_robot_support.py index c9ef4579..2a26ffe9 100644 --- a/modules/zividsamples/gui/tab_with_robot_support.py +++ b/modules/zividsamples/gui/widgets/tab_with_robot_support.py @@ -1,6 +1,6 @@ -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.rotation_format_configuration import RotationInformation -from zividsamples.gui.tab_content_widget import TabContentWidget +from zividsamples.gui.robot.robot_control import RobotTarget +from zividsamples.gui.widgets.tab_content_widget import TabContentWidget +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation class TabWidgetWithRobotSupport(TabContentWidget): diff --git a/modules/zividsamples/gui/tutorial_widget.py b/modules/zividsamples/gui/widgets/tutorial_widget.py similarity index 100% rename from modules/zividsamples/gui/tutorial_widget.py rename to modules/zividsamples/gui/widgets/tutorial_widget.py diff --git a/modules/zividsamples/gui/wizard/__init__.py b/modules/zividsamples/gui/wizard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/zividsamples/gui/camera_selection.py b/modules/zividsamples/gui/wizard/camera_selection.py similarity index 100% rename from modules/zividsamples/gui/camera_selection.py rename to modules/zividsamples/gui/wizard/camera_selection.py diff --git a/modules/zividsamples/gui/data_directory.py b/modules/zividsamples/gui/wizard/data_directory.py similarity index 91% rename from modules/zividsamples/gui/data_directory.py rename to modules/zividsamples/gui/wizard/data_directory.py index 20762b3d..7afd71f9 100644 --- a/modules/zividsamples/gui/data_directory.py +++ b/modules/zividsamples/gui/wizard/data_directory.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional from PyQt5 import sip from PyQt5.QtCore import QSettings, Qt, pyqtSignal @@ -41,12 +41,21 @@ def is_session_folder(session_path: Path) -> bool: @dataclass class SessionInfo: + """Single authority for all data in session_info.json. + + Core fields (last_modified_date, created_date, name) are stored as dataclass + attributes. Additional sections (e.g. hand_eye_configuration) are kept in + _extra_data and persisted alongside the core fields. All writes to + session_info.json should go through this class to prevent data loss. + """ + last_modified_date: str created_date: str name: str root_folder: Path def __post_init__(self): + self._extra_data: Dict[str, Any] = {} self._ensure_structure() def _ensure_structure(self): @@ -63,15 +72,24 @@ def has_any_data(self) -> bool: return True return False + def get_section(self, key: str) -> Optional[Any]: + return self._extra_data.get(key) + + def set_section(self, key: str, value: Any) -> None: + self._extra_data[key] = value + def to_dict(self) -> dict: - return { + data: Dict[str, Any] = { "last_modified_date": self.last_modified_date, "created_date": self.created_date, "name": self.name, } + data.update(self._extra_data) + return data - def save(self, root_path: Path) -> None: - json_path = root_path / self.name / "session_info.json" + def save(self) -> None: + json_path = self.root_folder / self.name / "session_info.json" + self.update_last_modified_date() with open(json_path, "w", encoding="utf-8") as session_file: json.dump(self.to_dict(), session_file) @@ -79,12 +97,15 @@ def save(self, root_path: Path) -> None: def from_existing(cls, root_folder: Path, session_name: str) -> "SessionInfo": with open(root_folder / session_name / "session_info.json", "r", encoding="utf-8") as session_file: session_data = json.load(session_file) - return cls( + core_keys = {"last_modified_date", "created_date", "name"} + instance = cls( last_modified_date=session_data["last_modified_date"], created_date=session_data["created_date"], name=session_name, root_folder=root_folder, ) + instance._extra_data = {k: v for k, v in session_data.items() if k not in core_keys} + return instance @classmethod def new(cls, root_folder: Path) -> "SessionInfo": @@ -146,8 +167,7 @@ def save_choice(self): qsettings.beginGroup("data_directory") qsettings.setValue("root_folder", str(self.root_folder)) if self.session is not None: - self.session.update_last_modified_date() - self.session.save(self.root_folder) + self.session.save() qsettings.setValue("session_name", self.session.name) else: qsettings.setValue("session_name", "") @@ -307,6 +327,8 @@ def register_tab_widget(self, widget, name: str): name in SESSION_FOLDER_STRUCTURE ), f"Unexpected widget directory name. Got {name}, expected one of {list(SESSION_FOLDER_STRUCTURE)}." self.tab_widgets[name] = widget + if hasattr(widget, "session_info"): + widget.session_info = self.data_directory.session def start_new_session(self): self.close_session() @@ -317,7 +339,7 @@ def start_new_session(self): def _notify_tab_widgets(self): for widget in self.tab_widgets.values(): if hasattr(widget, "update_data_directory"): - widget.update_data_directory(self.folder(widget.objectName())) + widget.update_data_directory(self.folder(widget.objectName()), session_info=self.data_directory.session) def select_folder(self): dialog = DirectoryAndSessionDialog(self.data_directory) diff --git a/modules/zividsamples/gui/hand_eye_configuration.py b/modules/zividsamples/gui/wizard/hand_eye_configuration.py similarity index 100% rename from modules/zividsamples/gui/hand_eye_configuration.py rename to modules/zividsamples/gui/wizard/hand_eye_configuration.py diff --git a/modules/zividsamples/gui/marker_widget.py b/modules/zividsamples/gui/wizard/marker_configuration.py similarity index 99% rename from modules/zividsamples/gui/marker_widget.py rename to modules/zividsamples/gui/wizard/marker_configuration.py index e4b559e3..7efb18ec 100644 --- a/modules/zividsamples/gui/marker_widget.py +++ b/modules/zividsamples/gui/wizard/marker_configuration.py @@ -19,9 +19,9 @@ from scipy.spatial.transform import Rotation from zivid.calibration import MarkerDictionary, MarkerShape from zivid.experimental import PixelMapping -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.image_viewer import ImageViewer from zividsamples.gui.qt_application import ZividQtApplication +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.image_viewer import ImageViewer def generate_marker_dictionary(markers: List[MarkerShape]) -> Dict[str, MarkerShape]: diff --git a/modules/zividsamples/gui/robot_configuration.py b/modules/zividsamples/gui/wizard/robot_configuration.py similarity index 100% rename from modules/zividsamples/gui/robot_configuration.py rename to modules/zividsamples/gui/wizard/robot_configuration.py diff --git a/modules/zividsamples/gui/rotation_format_configuration.py b/modules/zividsamples/gui/wizard/rotation_format_configuration.py similarity index 100% rename from modules/zividsamples/gui/rotation_format_configuration.py rename to modules/zividsamples/gui/wizard/rotation_format_configuration.py diff --git a/modules/zividsamples/gui/settings_selector.py b/modules/zividsamples/gui/wizard/settings_selector.py similarity index 99% rename from modules/zividsamples/gui/settings_selector.py rename to modules/zividsamples/gui/wizard/settings_selector.py index 88326233..bf203b31 100644 --- a/modules/zividsamples/gui/settings_selector.py +++ b/modules/zividsamples/gui/wizard/settings_selector.py @@ -414,18 +414,18 @@ def __init__(self, camera: zivid.Camera, show_dialog: bool): self.stack = QStackedWidget(self) layout.addWidget(self.stack) - # 1) Preset page - self.preset_widget = PresetSelectionWidget(camera) - self.stack.addWidget(self.preset_widget) - - # 2) File page + # 1) File page self.file_widget = SettingsFromFileWidget(camera) self.stack.addWidget(self.file_widget) - # 3) Manual (engine + sampling) page + # 2) Manual (engine + sampling) page self.manual_widget = EngineAndSamplingSelectionWidget(camera) self.stack.addWidget(self.manual_widget) + # 3) Preset page + self.preset_widget = PresetSelectionWidget(camera) + self.stack.addWidget(self.preset_widget) + horizontal_layout = QHBoxLayout() # --- Show this dialog --- diff --git a/modules/zividsamples/gui/touch_configuration.py b/modules/zividsamples/gui/wizard/touch_configuration.py similarity index 100% rename from modules/zividsamples/gui/touch_configuration.py rename to modules/zividsamples/gui/wizard/touch_configuration.py diff --git a/modules/zividsamples/images/LogoZBlue.png b/modules/zividsamples/images/LogoZBlue.png new file mode 100644 index 0000000000000000000000000000000000000000..c9a426363f628ce597c01ec2655aeb1bd5bfad6a GIT binary patch literal 5319 zcmX9?c_5Tu7rtW~*~w1Gu7qUEHls!&B}>_|FIkF|B{2+=tsgC-ESV;2*|J0!M)vF} zO9_!|Y19}7Gvj-w@2|Oc?t9NY=iKL;*1uU@lU#~q+gQqfrmdoo)hnJU({V3ZC(*qIri;v#RjT5NGu^}v zDj=<92>4AtZ}gFQCT(l1a*kA!G1;@@C>A-IH~vmF1vj@UT2k8AlqpN_@2jgu^25yc zITd~neC_ycaWr6`I1oKMVAz&s8mPS|MKL0wN%#|T*iNS@!@R)fTBJDlA?qJ7fQw8V zfFb9+hQi)x4n`>7XkP0%W-VF2KMgSXpA);adX#EALt$FeH)&WbyD;kzj)TL4>sPhP z4QIOu*c3O5vuykC{0X(-XO(GeUckZkUCc#{pHym#^x2uWamJNfT%Mfb*V@~KPq(Xa zfrQICyh~C>*MucaU0GRSuG%iPpFVA8bcKln@grZ{WuA6{|oa_X9^mP^K9wDi~XG&cFto8f#$Y)Dkr>Yf6> z$}4F>y=A4MD2}q@T(}Ov&Of|q9grATHOP^D;JXK~Tydh&aqudZQ2`nI^AnF*TQjcr z960iBYAN9B#8QUk#A9zR%mI8&%*9Zw0x~G4QsFg2%aDLozr=HQ`!qmzbxow+MD%SZ zri(wPX4E*QFJo_Rcb^&zJ!bwMh_=`7(oF4JG^5)(>+$QMR+lCk4Mn+`-7(|4GcF0e zYsG;t{?ui`gSv9bN4VnYqQL0JTnRkEU4soN!b9C$WoaK;eV?=9bnQJLEPM^<=WDs% zB%JEBCf=U^x&PAGlSvs8th$AcEsv@0PK%nF-9@BJFK9)BsRhBFNI&BH?TnT8&N5AvSz(7S*X9u;B+4OT?4J z+{%QSQ`>fRPwy$1;Fz(WJ7S%_4;pyK2W%u1&e}eh1P%~XqbQeZA8zU{U3_0Q^TiQw zr|HsNC?NJvbFKRJ*Hmw2)}STd7EggHPQ7AI(Fq%LTW7nK4*%TeBsXmdhJj=C_m5hi zz$}vCK1h0d=phuolqlWt_|zjc9z|ca>MOAq7w436Q`THjwi4&av_~sWXPzXB5pL2< zp4jiIm_MpH?H&fouSpH%8($lr9JVsgyvtV|eqdzk=fYe`p+Lxtn-ik+X{8t6(W_v3 zp~UV@qdlQX48RdZ>Aho>mUEu)5Kn;Cn2iMq(Kj%uy z1!k*qTH|^rxiD6L4hMWg{xHbVB?oBAoxl|RM>AxqcuEP9%}ri&%_O|8fRukmp-(cq z`;cy=Z<7uheQ$E`HyYHBdBa9npvUmU;7Y^%aBL9{WGmBjEf4yze!3y!w~#D@A5pQ zvX>c4t2Az{O!JeJt?C(7lUxzNti|B^x<=&%r??oXl+8saD^ByReOVnT(Y&^(LXGH?qO1-6@2kb+>7~AiFlmnvl-i@s`FVv(L0% zZfg_$dH6IZ=E-2kNpeDcb-Ed9_-5DZdyy?8OxT@`OnX|2tRmsH$Bc%c1tTIjFwHW&;d4=s=Fn^XKkoB#V>jiFp}@wUQtMzEKW2ft7nJO3mbuQnCFD_ z!}T9O^|smX*Rm*QxN!1!1?6#)F{$^z+Y&PgXXbZxXSx zk`gl8`85LkDwHU7(m448OeKaNmhQ1(;8j}JkD~wY0*j z?PjkoNn6kN4VoCi(9B3ws&(dR)F1Ir2S;?lCteO~LB=dpllYEv!V)G(a z&TUu>^U~>lxv*@iOIrymnG3kmlq|Ail*m`J{-qG>BuFtWe!)95;#FTDpx0Fzco^5% z_t|xu&F~=jqNI|nJcTN{v9)g`1diq=-C}AHw>LovN_W+Mjk97Uyi6KAb)}2Xy5&y( zbO8r2dzeunE}Qyos^=a;2wd4eSmgR*FR6qHvlffXJUAG!PtgO#kv)3+3LR5X(a#6K zkUTSgQAXqMuHPa_hAS*~Ki~-IWM%zX{DY3@3mfw%MHrwyrqs9_y*5FTAgLMCWC;?| zQuDD^pKlcPEhT5et2skiG0EWH>5*Hp&!zw;~#D86awScoU^nT(vmQGtTiXOamV<#^`Wa!=P?|BJ3Q_B+c&Z4rCaeLZ9(+y@89y(|BTHVYPy`^==%%> z;JfuTl9?1>&ok{1dI%$;NlbJ~*OF!S=vcFX8}4sxo3_iD0Bx(BNWcwuhr0_;8B${D zD{?&aWR-YR>^C?c<>?7WuC6t8kzr>e$}EL)V$7$|_3AhgOv8E~{XMs|Q8F=*Ep&ofcy#)d{N{NL0&A)=#u|Yglqi`@zRWe6VYE_{Kg7Up2Yxl@y=fc34a3YcrMZyL}W&hPfSTgiw!c z>Hj)BMBML8PA_h+h```&K6V;C3P_8|nZ@nyBuE(cYKVje_vE74V^1J2*-Z5l=fbdf zWlNt=gFU$T>+eeq4Iq%}5+lfF5S!p96TV~Z@QZp3*9vX$fHtrn9fWskIeC)!)FDTATEKyi1IE52zM|cJgom&M8O{ ztL@hLIV+g8mLzLXuG-z_zERoI66lW|L$ftp3;|c$^uNzpi5bL>X?}oYG#Kx^4;2$s z9F>C;vR3qyU1Gd3AF||9<;KFi4>ePJ1=V0&kOj{^qHayVMl@W`UVhV`VcFDh$0z#) zI1Op;yCMbj3|@0`cRcatfi`F*{$grj<1+}~dB}|l6Y+zCX4pc98lvX(1Q!x(rg*N8 zBNy`P`ULMu$_e=gm-nsK8mxW#-H$zUkIJ6S_#KTRHk|#15^irw8)*(;eft2C?B7C4c01&url7(XO?)an2-s_m=g=r2VG(SWY2~#zGA4L zfCbYbMyR_+{2LXqbdHBUFotIYPJV$DoWM||j7WO4AqagwK2}1cqgNX)U0Et?D;$NK z^9cFkzuyJJ^llxb7vwpG#0=~xjbsbM{teY1xdEZz#|S>^wS9Q={;(~Kum%<&Tq&d) z8+63_j(%7JFB#LpDKcV24%pQTn;kdrRzq0eYS&`1W?*xd&!`=m%Fgy|{r{8S6|Ph3 zt>-9exS={~?|7=qh@c~u)- zIuSehT0;AO`?4uRiIS~eO;7Lc604}cHZ8cG_3=)RH9=*_R1g|PR@TnV(YBx3LL zftlSOoMS$n$nEf;D^8M|DRJ3vYsUqls#C>QY%A_Xlymw)E~B&JkcwtnmBqXD9tC3J zfI%)C8%-(1*2vK>Y6#q)6*&IR{;;?>U{H?EkN}TN?^==}$u^bc!K!kBL>d14p$bb| zF8(g_ZMJny&vRU_C0 z6ynz|H@i`f4>&Y5maTqiB=cEEM_<3(y+d_?J$Q1=1c$Q0YkwGbYk0$ityfrv7Iaa9 z8H&_;sA`zBdIzD6<0=f5^=$4JuO%PIW96H=`luG6VFDOqN1yl5*aO@UMtWzM57P;E zA*u_-THr5E8O>{{(3&ZzYt2Q&P{mN8ZFOr4Ud)9tc%A*)@s2zD!}FaXN5}GLaVpFZ zFNQDrXPBTCtY;BhzW5JyJnWv1T_`^o=)At*M zfAXK^X7Jg2OvJm9ma+l1Dv2TAtDKjKEkO!`*-R)8o4Ne)bmvC4R*4$|j0?$HgDbG> zbQp(cZW#yXsKt5yos;(`vVOu(TLv9Vl{4GYpcau6EQH-uEd=$u$jU=>B+EHWI&@aI zjt6c9rA;O#tAe*x!_p^RFQ%?iQ2tAMlRSH`_8lLxf{{Bf*s;N9b`GKZ3tMA<-8Naa zrwylS0pPyWUBp2Yk`aF_x2Pczvm0~%lN$gtmX)0qCOH1G8IIQzu}>YC-1l~=4^xE~ zC~7!)68c(H?Yo;-W<|~cur6{RB2SI28Or|iPTLV_#{gz)kM&Y;*G@jl{6(d6s@J#K zW8;89?awK+2IMWPqDjC1OLUr^6^{Y{8Pt8W#yq+7Jo$29gKEsfdM4i5x;F)8wh~ai zcm6ZdJBR4CmmOR4K$lEA;|q|@=Dm-Zq!rFMF78b}KS>m_9Cud*m~eJI8y!dHhX+>o zdQL3iPvzNDqmGLMWZ+fI*Mv+3=--V_${bvk9bgV4r$)u7A;H^ZFH-=1@nbh@uWv95 zLk?QycW1vSKBY&2^)mrzXwelKG23{b4CAVW_*x>wq1R+`?p0i+^Q`%TL+e={#-4cs z{afw44u;Ij40?*n+de;GqLnVs6+@U8L&6)_Rs0`mQsP(!fW9k4Y*LB#kwh?6J~k70 z`th)`z(D|lWn`Nbpz{xK0?-J>y>guWEtm4Am5Iy8KRZMCwJ7cv%_dKPA;vq?5C#`2 z(6W3xqWf46k~##M{*b-AAMMiLGzIl_K8}9>Xc>UpArA&N(ANxH`Swmw-)A){5F8_d zRX@#{$7P7VoGhKmIZ0IT%ng3pISlj}#d(fWiO^eKTRW)P@crz`%IxsY!`iTsH+Hk? zhetTFSpWE!;1kJ$3>H>MFS#=P$9|{v3MD zyFjaoyw_(3LT{8{npAE`2srHYR+jo`0?dV3WbJ^DNqA9+TkMr*+?e!0@?P5WMj=Lr zBys*lYpiOz^B-TlMON`1-WxgriXm2^@;|%C4`nj}@7P0`DZU zohJ^bFN*@Nf4RBcWlTo7CW9c{1jo(~{&?ls_6niKM!3sx=LLwOBoI_c#>}>x3Bcz3 zU%7XdUf`~~zZXJ1F7xEkg#Q_noE;$; zDI_?zw^NuH56KM+6FK4x#AnJIx{U7ZL1h|_MvZTgbr94`;~PBcJ@iB16nbB@vRUC( zGv*gt{IQ4b*;luIxPwB}YmxS6f%^`(|LQiC%BCHZ3O^@k2EgdcttrX#S`qJDt&;ZP YT>+iiwpCwZp#~)|H@$$WJmnt$KktJ&^Z)<= literal 0 HcmV?d00001 diff --git a/source/applications/advanced/hand_eye_calibration/hand_eye_gui.py b/source/applications/advanced/hand_eye_calibration/hand_eye_gui.py index 428ce34f..a7f53d37 100644 --- a/source/applications/advanced/hand_eye_calibration/hand_eye_gui.py +++ b/source/applications/advanced/hand_eye_calibration/hand_eye_gui.py @@ -4,107 +4,71 @@ This script provides a graphical user interface for performing hand-eye calibration and verification through various methods. +The application is structured as: + 1. A configuration wizard (camera, hand-eye type, markers, robot, settings) + 2. Three main tabs: + - PREPARE: Warmup + Infield Correction + - CALIBRATE: Hand-Eye Calibration + - VERIFY: Touch, Projection, and Stitching + 3. Shared panels: live 2D preview, tutorial steps, camera/robot controls + +All event handling, signal wiring, and business logic lives in HandEyeAppBase. +This script shows the high-level application structure. + Note: This script requires the `zividsamples` package to be installed. The `zividsamples` package is available in the /modules folder in the `zivid-python-samples` repository. `pip install /path/to/zivid-python-samples/modules` """ -import functools import sys -import time -from enum import Enum -from typing import Dict, List, Optional +from typing import List, Optional import zivid -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtGui import QCloseEvent, QKeyEvent from PyQt5.QtWidgets import ( - QAction, - QApplication, - QFileDialog, QHBoxLayout, - QMainWindow, QMessageBox, QTabWidget, QVBoxLayout, QWidget, ) -from zividsamples.gui.buttons_widget import CameraButtonsWidget -from zividsamples.gui.camera_selection import select_camera -from zividsamples.gui.cv2_handler import CV2Handler -from zividsamples.gui.data_directory import DataDirectoryManager -from zividsamples.gui.hand_eye_calibration_gui import HandEyeCalibrationGUI -from zividsamples.gui.hand_eye_configuration import ( +from zividsamples.gui.calibration.hand_eye_calibration_gui import HandEyeCalibrationGUI +from zividsamples.gui.hand_eye_app import HandEyeAppBase +from zividsamples.gui.preparation.infield_correction_gui import InfieldCorrectionGUI +from zividsamples.gui.preparation.warmup_gui import WarmUpGUI +from zividsamples.gui.qt_application import ZividQtApplication +from zividsamples.gui.robot.robot_control_widget import RobotControlWidget +from zividsamples.gui.verification.hand_eye_verification_gui import HandEyeVerificationGUI +from zividsamples.gui.verification.stitch_gui import StitchGUI +from zividsamples.gui.verification.touch_gui import TouchGUI +from zividsamples.gui.widgets.camera_buttons_widget import CameraButtonsWidget +from zividsamples.gui.widgets.cv2_handler import CV2Handler +from zividsamples.gui.widgets.live_2d_widget import Live2DWidget +from zividsamples.gui.widgets.tutorial_widget import TutorialWidget +from zividsamples.gui.wizard.camera_selection import select_camera +from zividsamples.gui.wizard.data_directory import DataDirectoryManager +from zividsamples.gui.wizard.hand_eye_configuration import ( CalibrationObject, - HandEyeConfiguration, select_hand_eye_configuration, ) -from zividsamples.gui.hand_eye_verification_gui import HandEyeVerificationGUI -from zividsamples.gui.infield_correction_gui import InfieldCorrectionGUI -from zividsamples.gui.live_2d_widget import Live2DWidget -from zividsamples.gui.marker_widget import MarkerConfiguration, select_marker_configuration -from zividsamples.gui.qt_application import ZividQtApplication -from zividsamples.gui.robot_configuration import RobotConfiguration, select_robot_configuration -from zividsamples.gui.robot_control import RobotTarget -from zividsamples.gui.robot_control_widget import RobotControlWidget -from zividsamples.gui.rotation_format_configuration import RotationInformation, select_rotation_format -from zividsamples.gui.settings_selector import SettingsForHandEyeGUI, select_settings_for_hand_eye -from zividsamples.gui.stitch_gui import StitchGUI -from zividsamples.gui.touch_gui import TouchGUI -from zividsamples.gui.tutorial_widget import TutorialWidget -from zividsamples.gui.warmup_gui import WarmUpGUI -from zividsamples.transformation_matrix import TransformationMatrix - - -class AutoRunState(Enum): - INACTIVE = 0 - HOMING = 1 - RUNNING = 2 - CALIBRATING = 3 - STOPPING = 4 - - -# pylint: disable=too-many-instance-attributes,too-many-public-methods -class HandEyeGUI(QMainWindow): - camera: Optional[zivid.Camera] - data_directory_manager: DataDirectoryManager - settings: SettingsForHandEyeGUI - hand_eye_configuration: HandEyeConfiguration - marker_configuration: MarkerConfiguration - robot_configuration: RobotConfiguration - use_robot: bool - rotation_information: RotationInformation - auto_run_state: AutoRunState - robot_pose: TransformationMatrix - projection_handle: Optional[zivid.projection.ProjectedImage] - last_frame: Optional[zivid.Frame] - common_instructions: Dict[str, bool] - projection_error_dialog: QMessageBox - tab_widgets: List[QWidget] +from zividsamples.gui.wizard.marker_configuration import MarkerConfiguration, select_marker_configuration +from zividsamples.gui.wizard.robot_configuration import select_robot_configuration +from zividsamples.gui.wizard.rotation_format_configuration import select_rotation_format + +class HandEyeGUI(HandEyeAppBase): # pylint: disable=too-many-instance-attributes def __init__(self, zivid_app: zivid.Application, parent=None): # noqa: ANN001 super().__init__(parent) self.zivid_app = zivid_app self.previous_tab_widget: Optional[QWidget] = None + self.configuration_wizard() - self.static_configuration() self.create_widgets() self.setup_layout() - self.create_toolbar() - self.connect_signals() - self.current_tab_widget = self.hand_eye_calibration_gui - self.on_instructions_updated() - for widget in self.tab_widgets: - self.data_directory_manager.register_tab_widget(widget, widget.objectName()) - - if self.camera: - self.live2d_widget.update_settings_2d(self.settings.production.settings_2d3d.color, self.camera.info.model) - self.live2d_widget.start_live_2d() + self.initialize() - QTimer.singleShot(0, functools.partial(self.main_tab_widget.setCurrentWidget, self.hand_eye_calibration_gui)) - QTimer.singleShot(100, self.update_tab_order) + # -- Configuration Wizard ---------------------------------------------------------- def configuration_wizard(self) -> None: self.data_directory_manager = DataDirectoryManager() @@ -112,6 +76,7 @@ def configuration_wizard(self) -> None: self.data_directory_manager.select_folder() else: self.data_directory_manager.start_new_session() + self.camera = select_camera(self.zivid_app, connect=True) self.hand_eye_configuration = select_hand_eye_configuration() self.marker_configuration = ( @@ -123,25 +88,20 @@ def configuration_wizard(self) -> None: self.rotation_information = select_rotation_format() self.configure_settings() - def static_configuration(self) -> None: - self.auto_run_state = AutoRunState.INACTIVE - self.robot_pose = TransformationMatrix() - self.projection_handle = None - self.last_frame = None - self.common_instructions = {} + # -- Widget & Tab Creation --------------------------------------------------------- def create_widgets(self) -> None: self.central_widget = QWidget() cv2_handler = CV2Handler() - self.main_tab_widget = QTabWidget() - self.main_tab_widget.setObjectName("main_tab_widget") - + # --- PREPARE tab: Warmup + Infield Correction --- self.preparation_tab_widget = QTabWidget() self.preparation_tab_widget.setObjectName("preparation_tab_widget") + self.warmup_gui = WarmUpGUI(data_directory=self.data_directory_manager.folder("Warmup")) self.warmup_gui.setObjectName("Warmup") self.preparation_tab_widget.addTab(self.warmup_gui, "Warmup") + self.infield_correction_gui = InfieldCorrectionGUI( data_directory=self.data_directory_manager.folder("Infield"), hand_eye_configuration=self.hand_eye_configuration, @@ -150,8 +110,8 @@ def create_widgets(self) -> None: ) self.infield_correction_gui.setObjectName("Infield") self.preparation_tab_widget.addTab(self.infield_correction_gui, "Infield Correction") - self.main_tab_widget.addTab(self.preparation_tab_widget, "PREPARE") + # --- CALIBRATE tab --- self.hand_eye_calibration_gui = HandEyeCalibrationGUI( data_directory=self.data_directory_manager.folder("Calibration"), robot_configuration=self.robot_configuration, @@ -161,10 +121,11 @@ def create_widgets(self) -> None: initial_rotation_information=self.rotation_information, ) self.hand_eye_calibration_gui.setObjectName("Calibration") - self.main_tab_widget.addTab(self.hand_eye_calibration_gui, "CALIBRATE") + # --- VERIFY tab: Touch + Projection + Stitching --- self.verification_tab_widget = QTabWidget() self.verification_tab_widget.setObjectName("verification_tab_widget") + self.touch_gui = TouchGUI( data_directory=self.data_directory_manager.folder("Touch"), hand_eye_configuration=self.hand_eye_configuration, @@ -173,6 +134,7 @@ def create_widgets(self) -> None: self.touch_gui.setObjectName("Touch") if self.robot_configuration.can_control(): self.verification_tab_widget.addTab(self.touch_gui, "by Touching") + self.hand_eye_verification_gui = HandEyeVerificationGUI( data_directory=self.data_directory_manager.folder("Projection"), robot_configuration=self.robot_configuration, @@ -183,6 +145,7 @@ def create_widgets(self) -> None: ) self.hand_eye_verification_gui.setObjectName("Projection") self.verification_tab_widget.addTab(self.hand_eye_verification_gui, "with Projection") + self.stitch_gui = StitchGUI( data_directory=self.data_directory_manager.folder("Stitching"), robot_configuration=self.robot_configuration, @@ -191,8 +154,15 @@ def create_widgets(self) -> None: ) self.stitch_gui.setObjectName("Stitching") self.verification_tab_widget.addTab(self.stitch_gui, "by Stitching") + + # --- Main tab bar (PREPARE / CALIBRATE / VERIFY) --- + self.main_tab_widget = QTabWidget() + self.main_tab_widget.setObjectName("main_tab_widget") + self.main_tab_widget.addTab(self.preparation_tab_widget, "PREPARE") + self.main_tab_widget.addTab(self.hand_eye_calibration_gui, "CALIBRATE") self.main_tab_widget.addTab(self.verification_tab_widget, "VERIFY") + # --- Shared widgets --- if self.camera is None or self.camera.state.connected is False: self.live2d_widget = Live2DWidget() else: @@ -211,6 +181,7 @@ def create_widgets(self) -> None: get_user_pose=self.get_transformation_matrix, robot_configuration=self.robot_configuration ) self.robot_control_widget.show_buttons(auto_run=True, touch=False) + self.camera_buttons = CameraButtonsWidget(capture_button_text="Capture (F5)") self.camera_buttons.set_connection_status(self.camera) @@ -234,6 +205,8 @@ def create_widgets(self) -> None: self.setCentralWidget(self.central_widget) + # -- Layout ------------------------------------------------------------------------ + def setup_layout(self) -> None: layout = QVBoxLayout(self.central_widget) left_panel = QVBoxLayout() @@ -255,508 +228,6 @@ def setup_layout(self) -> None: self.live2d_widget.setVisible(self.camera is not None) self.robot_control_widget.setVisible(self.robot_configuration.can_get_pose()) - def update_tab_order(self) -> None: - tab_widgets = ( - self.current_tab_widget.get_tab_widgets_in_order() - + self.camera_buttons.get_tab_widgets_in_order() - + self.robot_control_widget.get_tab_widgets_in_order() - ) - for i in range(len(tab_widgets) - 1): - self.setTabOrder(tab_widgets[i], tab_widgets[i + 1]) - tab_widgets[0].setFocus() - - def create_toolbar(self) -> None: - file_menu = self.menuBar().addMenu("File") - self.directory_load_session_action = QAction("Load Session", self) - self.directory_new_session_action = QAction("New Session", self) - file_menu.addAction(self.directory_load_session_action) - file_menu.addAction(self.directory_new_session_action) - self.save_frame_action = QAction("Save last capture", self) - self.save_frame_action.setEnabled(False) - self.save_frame_action.setToolTip("Save the last captured frame") - self.save_frame_action.setShortcut("Ctrl+S") - file_menu.addAction(self.save_frame_action) - close_action = QAction("Close", self) - close_action.triggered.connect(self.close) - file_menu.addAction(close_action) - - config_menu = self.menuBar().addMenu("Configuration") - hand_eye_submenu = config_menu.addMenu("Hand-Eye") - self.select_hand_eye_configuration_action = QAction("Hand-Eye", self) - hand_eye_submenu.addAction(self.select_hand_eye_configuration_action) - self.select_marker_configuration_action = QAction("Markers", self) - hand_eye_submenu.addAction(self.select_marker_configuration_action) - self.set_fixed_objects_action = QAction("Fixed Objects", self) - hand_eye_submenu.addAction(self.set_fixed_objects_action) - - camera_submenu = config_menu.addMenu("Camera") - self.select_hand_eye_settings_action = QAction("Settings", self) - camera_submenu.addAction(self.select_hand_eye_settings_action) - - robot_submenu = config_menu.addMenu("Robot") - self.select_robot_configuration_action = QAction("Control Option", self) - robot_submenu.addAction(self.select_robot_configuration_action) - self.select_rotation_format_action = QAction("Rotation Format", self) - robot_submenu.addAction(self.select_rotation_format_action) - - view_menu = self.menuBar().addMenu("View") - self.toggle_advanced_view_action = QAction("Advanced", self, checkable=True) - self.toggle_advanced_view_action.setChecked(False) - view_menu.addAction(self.toggle_advanced_view_action) - - def connect_signals(self) -> None: - self.live2d_widget.camera_disconnected.connect(self.on_camera_disconnected) - self.main_tab_widget.currentChanged.connect(self.on_tab_changed) - self.preparation_tab_widget.currentChanged.connect(self.on_tab_changed) - self.verification_tab_widget.currentChanged.connect(self.on_tab_changed) - self.directory_load_session_action.triggered.connect(self.on_data_directory_load_session_action_triggered) - self.directory_new_session_action.triggered.connect(self.on_data_directory_new_session_action_triggered) - self.save_frame_action.triggered.connect(self.on_save_last_frame_action_triggered) - self.select_hand_eye_configuration_action.triggered.connect(self.hand_eye_configuration_action_triggered) - self.select_marker_configuration_action.triggered.connect(self.on_select_marker_configuration) - self.select_hand_eye_settings_action.triggered.connect(self.on_select_hand_eye_settings_action_triggered) - self.select_rotation_format_action.triggered.connect(self.on_select_rotation_format) - self.set_fixed_objects_action.triggered.connect(self.on_select_fixed_objects_action_triggered) - self.toggle_advanced_view_action.triggered.connect(self.on_toggle_advanced_view_action_triggered) - self.select_robot_configuration_action.triggered.connect(self.on_select_robot_configuration_action_triggered) - self.camera_buttons.capture_button_clicked.connect(self.on_capture_button_clicked) - self.camera_buttons.connect_button_clicked.connect(self.on_connect_button_clicked) - self.warmup_gui.warmup_finished.connect(self.on_warmup_finished) - self.warmup_gui.warmup_start_requested.connect(self.on_warmup_start_requested) - self.warmup_gui.instructions_updated.connect(self.on_instructions_updated) - self.infield_correction_gui.apply_correction_button_clicked.connect(self.on_apply_correction_button_clicked) - self.infield_correction_gui.instructions_updated.connect(self.on_instructions_updated) - self.infield_correction_gui.update_projection.connect(self.update_projection) - self.hand_eye_calibration_gui.calibration_finished.connect(self.on_calibration_finished) - self.hand_eye_calibration_gui.instructions_updated.connect(self.on_instructions_updated) - self.hand_eye_verification_gui.update_projection.connect(self.update_projection) - self.hand_eye_verification_gui.instructions_updated.connect(self.on_instructions_updated) - self.stitch_gui.instructions_updated.connect(self.on_instructions_updated) - self.touch_gui.instructions_updated.connect(self.on_instructions_updated) - self.touch_gui.touch_pose_updated.connect(self.on_touch_pose_updated) - self.robot_control_widget.robot_connected.connect(self.on_robot_connected) - self.robot_control_widget.auto_run_toggled.connect(self.on_auto_run_toggled) - self.robot_control_widget.target_pose_updated.connect(self.on_target_pose_updated) - self.robot_control_widget.actual_pose_updated.connect(self.on_actual_pose_updated) - - def tab_widgets_with_robot_support(self) -> List[QWidget]: - return [ - self.infield_correction_gui, - self.hand_eye_calibration_gui, - self.hand_eye_verification_gui, - self.stitch_gui, - self.touch_gui, - ] - - def get_currently_selected_tab_widget(self) -> QWidget: - if self.main_tab_widget.currentWidget() == self.preparation_tab_widget: - return self.preparation_tab_widget.currentWidget() - if self.main_tab_widget.currentWidget() == self.verification_tab_widget: - return self.verification_tab_widget.currentWidget() - return self.main_tab_widget.currentWidget() - - def keyPressEvent(self, a0: QKeyEvent) -> None: # pylint: disable=invalid-name - if a0 is not None and a0.key() == Qt.Key_F5: - if self.camera and self.camera.state.connected: - self.camera_buttons.on_capture_button_clicked() - else: - super().keyPressEvent(a0) - - def configure_settings(self, show_anyway: bool = False) -> None: - if self.camera: - current_settings = self.settings if hasattr(self, "settings") else None - self.settings = select_settings_for_hand_eye(self.camera, current_settings, show_anyway) - - def setup_instructions(self) -> None: - self.common_instructions = { - "Connect Camera": self.camera is not None and self.camera.state.connected, - } - if self.robot_configuration.can_get_pose(): - self.common_instructions.update( - { - "Connect Robot": self.robot_control_widget.connected, - } - ) - - def on_capture_button_clicked(self) -> None: - assert self.camera is not None - self.live2d_widget.stop_live_2d() - try: - if self.robot_configuration.can_control(): - while self.robot_control_widget.robot_is_moving(): - time.sleep(0.1) - was_projecting = False - if self.current_tab_widget in [self.hand_eye_verification_gui, self.infield_correction_gui]: - if self.projection_handle and self.projection_handle.active(): - self.projection_handle.stop() - was_projecting = True - settings = ( - self.settings.hand_eye - if self.current_tab_widget in [self.hand_eye_calibration_gui, self.hand_eye_verification_gui] - else ( - self.settings.infield_correction - if self.current_tab_widget in [self.warmup_gui, self.infield_correction_gui] - else self.settings.production - ) - ) - frame = ( - zivid.calibration.capture_calibration_board(self.camera) - if self.current_tab_widget in [self.warmup_gui, self.infield_correction_gui] - else self.camera.capture_2d_3d(settings.settings_2d3d) - ) - self.last_frame = frame - self.save_frame_action.setEnabled(True) - frame_2d = frame.frame_2d() - assert frame_2d - rgba = frame_2d.image_srgb().copy_data() - self.current_tab_widget.process_capture(frame, rgba, settings) - if self.current_tab_widget in [self.hand_eye_verification_gui]: - if was_projecting: - # In case we resume live 2D before projection is ready with an update, - # we reset the capture function here to avoid calling an invalid - # projection API. - self.live2d_widget.set_capture_function(self.camera.capture_2d) - self.update_projection(True) - rgba = self.live2d_widget.get_current_rgba() - self.current_tab_widget.process_capture(frame, rgba, self.settings.production) - if not self.live2d_widget.is_active(): - self.live2d_widget.start_live_2d() - if self.robot_configuration.can_control() and self.auto_run_state == AutoRunState.RUNNING: - QApplication.processEvents() - self.robot_control_widget.on_move_to_next_target(blocking=False) - except RuntimeError as ex: - if self.camera.state.connected: - if self.robot_configuration.can_control() and self.auto_run_state != AutoRunState.INACTIVE: - self.finish_auto_run() - else: - self.on_camera_disconnected(str(ex)) - if not self.live2d_widget.is_active(): - self.live2d_widget.start_live_2d() - - def on_warmup_start_requested(self) -> None: - self.camera_buttons.disable_buttons() - self.live2d_widget.stop_live_2d() - self.warmup_gui.start_warmup(self.camera, self.settings.production.settings_2d3d) - - def on_warmup_finished(self, success: bool) -> None: - self.camera_buttons.enable_buttons() - self.live2d_widget.start_live_2d() - if success: - dialog = QMessageBox(self) - dialog.setWindowTitle("Warmup Finished") - warn_about_trueness_str = f"\n{self.warmup_gui.get_warn_about_trueness_str(self.camera)}" - dialog.setText("Warmup is finished. What would you like to do next?" + warn_about_trueness_str) - dialog.addButton("Stay in Warmup", QMessageBox.RejectRole) - skip_to_calibration_button = dialog.addButton("Hand Eye Calibration", QMessageBox.AcceptRole) - move_to_infield_button = dialog.addButton("Infield Correction", QMessageBox.YesRole) - dialog.exec() - if dialog.clickedButton() == move_to_infield_button: - self.main_tab_widget.setCurrentWidget(self.preparation_tab_widget) - self.preparation_tab_widget.setCurrentWidget(self.infield_correction_gui) - elif dialog.clickedButton() == skip_to_calibration_button: - self.main_tab_widget.setCurrentWidget(self.hand_eye_calibration_gui) - - def on_apply_correction_button_clicked(self) -> None: - self.infield_correction_gui.apply_correction(self.camera) - - def on_calibration_finished(self, transformation_matrix: TransformationMatrix) -> None: - if self.robot_configuration.can_control() and self.auto_run_state == AutoRunState.CALIBRATING: - self.finish_auto_run() - if not transformation_matrix.is_identity(): - if ( - QMessageBox.question(None, "Calibration", "Use in Verification?", QMessageBox.Yes | QMessageBox.No) - == QMessageBox.Yes - ): - self.hand_eye_verification_gui.set_hand_eye_transformation_matrix(transformation_matrix) - self.touch_gui.set_hand_eye_transformation_matrix(transformation_matrix) - self.stitch_gui.set_hand_eye_transformation_matrix(transformation_matrix) - - def on_auto_run_toggled(self) -> None: - if self.auto_run_state == AutoRunState.INACTIVE: - self.start_auto_run() - else: - self.auto_run_state = AutoRunState.STOPPING - - def start_auto_run(self) -> None: - self.camera_buttons.disable_buttons() - self.robot_control_widget.set_auto_run_active(True) - if self.robot_control_widget.robot_is_home(): - self.auto_run_state = AutoRunState.RUNNING - if self.current_tab_widget == self.hand_eye_calibration_gui: - if self.hand_eye_calibration_gui.on_start_auto_run(): - self.on_capture_button_clicked() - else: - self.finish_auto_run() - elif self.current_tab_widget == self.hand_eye_verification_gui: - self.robot_control_widget.on_move_to_next_target(blocking=False) - else: - self.auto_run_state = AutoRunState.HOMING - self.robot_control_widget.on_move_home() - - def finish_auto_run(self) -> None: - self.auto_run_state = AutoRunState.INACTIVE - self.robot_control_widget.set_auto_run_active(False) - self.camera_buttons.enable_buttons() - - def get_transformation_matrix(self) -> TransformationMatrix: - return self.robot_pose - - def on_instructions_updated(self) -> None: - self.tutorial_widget.set_title("Steps") - self.tutorial_widget.clear_steps() - self.tutorial_widget.add_steps(self.common_instructions) - self.tutorial_widget.add_steps(self.current_tab_widget.instruction_steps) - self.tutorial_widget.set_description(self.current_tab_widget.description) - self.tutorial_widget.update_text() - - def on_robot_connected(self) -> None: - self.setup_instructions() - self.on_instructions_updated() - - def on_actual_pose_updated(self, robot_target: RobotTarget) -> None: - self.robot_pose = robot_target.pose - if self.current_tab_widget in self.tab_widgets_with_robot_support(): - self.current_tab_widget.on_actual_pose_updated(robot_target) - if self.robot_control_widget.robot_is_home(): - if self.auto_run_state == AutoRunState.HOMING: - self.auto_run_state = AutoRunState.RUNNING - elif self.auto_run_state == AutoRunState.RUNNING: - if self.current_tab_widget == self.hand_eye_calibration_gui: - self.auto_run_state = AutoRunState.CALIBRATING - self.hand_eye_calibration_gui.on_calibrate_button_clicked() - elif self.current_tab_widget == self.hand_eye_verification_gui: - self.auto_run_state = AutoRunState.STOPPING - if self.auto_run_state == AutoRunState.STOPPING: - self.finish_auto_run() - elif self.auto_run_state == AutoRunState.RUNNING: - if self.current_tab_widget == self.hand_eye_calibration_gui: - self.on_capture_button_clicked() - elif self.current_tab_widget == self.hand_eye_verification_gui: - time.sleep(2) - self.robot_control_widget.on_move_to_next_target(blocking=False) - elif self.auto_run_state != AutoRunState.INACTIVE: - error_message = ( - f"Expected to be home now, but arrived at {robot_target.name} {robot_target.pose}" - if self.auto_run_state == AutoRunState.HOMING - else f"Invalid state {self.auto_run_state} when we got pose update from robot." - ) - QMessageBox.critical(self, "Auto-Run Error", error_message) - self.finish_auto_run() - - def on_target_pose_updated(self, robot_target: RobotTarget) -> None: - if self.current_tab_widget == self.hand_eye_calibration_gui: - self.hand_eye_calibration_gui.on_target_pose_updated(robot_target) - elif self.current_tab_widget == self.hand_eye_verification_gui: - self.hand_eye_verification_gui.on_target_pose_updated(robot_target) - - def on_touch_pose_updated(self, touch_target: TransformationMatrix) -> None: - self.robot_control_widget.set_touch_target(touch_target) - - def update_projection(self, project: bool = True) -> None: - if ( - self.current_tab_widget in [self.hand_eye_verification_gui, self.infield_correction_gui] - and self.camera is not None - and self.camera.state.connected - ): - self.live2d_widget.stop_live_2d() - if project and self.current_tab_widget.has_features_to_project(): - self.robot_control_widget.enable_disable_buttons(auto_run=True, touch=False) - error_msg = None - try: - if self.camera is None: - raise RuntimeError("No camera connected.") - try: - projector_image = self.current_tab_widget.generate_projector_image(self.camera) - except ValueError as ex: - error_msg = f"Failed to generate projector image: {ex}. Most likely the estimated position of the calibration object is out of view." - raise ValueError(ex) from ex - self.projection_handle = zivid.projection.show_image_bgra(self.camera, projector_image) - assert self.projection_handle is not None - self.live2d_widget.set_capture_function(self.projection_handle.capture) - except (RuntimeError, ValueError, AssertionError) as ex: - if not error_msg: - error_msg = f"Failed to project: {ex}" - if not self.projection_error_dialog.isVisible(): - self.projection_error_dialog.setText(error_msg) - self.projection_error_dialog.show() - if self.camera is not None: - self.live2d_widget.set_capture_function(self.camera.capture_2d) - elif self.camera is not None: - self.live2d_widget.set_capture_function(self.camera.capture_2d) - self.live2d_widget.start_live_2d() - - def on_tab_changed(self, _: int) -> None: - if self.auto_run_state != AutoRunState.INACTIVE: - self.auto_run_state = AutoRunState.STOPPING - self.previous_tab_widget = self.current_tab_widget - self.current_tab_widget = self.get_currently_selected_tab_widget() - if self.previous_tab_widget == self.warmup_gui: - self.camera_buttons.enable_buttons() - if (self.previous_tab_widget in [self.hand_eye_verification_gui, self.infield_correction_gui]) and ( - self.current_tab_widget not in [self.hand_eye_verification_gui, self.infield_correction_gui] - ): - if self.projection_handle and self.projection_handle.active(): - self.live2d_widget.stop_live_2d() - self.projection_handle.stop() - if self.camera is not None: - self.live2d_widget.set_capture_function(self.camera.capture_2d) - self.live2d_widget.start_live_2d() - if self.current_tab_widget == self.infield_correction_gui: - if self.infield_correction_gui.infield_correction_input_data is not None: - self.update_projection(True) - self.robot_control_widget.enable_disable_buttons(auto_run=False, touch=False) - self.robot_control_widget.show_buttons(auto_run=False, touch=False) - if self.camera is not None: - self.infield_correction_gui.check_correction(self.camera) - elif self.current_tab_widget == self.hand_eye_calibration_gui: - self.robot_control_widget.enable_disable_buttons(auto_run=True, touch=False) - self.robot_control_widget.show_buttons(auto_run=True, touch=False) - elif self.current_tab_widget == self.hand_eye_verification_gui: - self.update_projection(True) - if self.robot_configuration.can_control(): - self.robot_control_widget.enable_disable_buttons(auto_run=True, touch=False) - self.robot_control_widget.show_buttons(auto_run=True, touch=False) - elif self.current_tab_widget == self.stitch_gui: - self.robot_control_widget.enable_disable_buttons(auto_run=False, touch=False) - self.robot_control_widget.show_buttons(auto_run=False, touch=False) - elif self.current_tab_widget == self.touch_gui: - self.robot_control_widget.enable_disable_buttons( - auto_run=False, touch=self.robot_configuration.can_control() - ) - self.robot_control_widget.show_buttons(auto_run=False, touch=self.robot_configuration.can_control()) - self.robot_control_widget.set_get_pose_interval( - fast=(self.current_tab_widget != self.hand_eye_verification_gui) - ) - for widget in self.tab_widgets: - widget.notify_current_tab(self.current_tab_widget) - self.on_instructions_updated() - self.update_tab_order() - - def on_data_directory_load_session_action_triggered(self) -> None: - self.data_directory_manager.select_folder() - for widget in self.tab_widgets: - widget.notify_current_tab(self.current_tab_widget) - - def on_data_directory_new_session_action_triggered(self) -> None: - self.data_directory_manager.start_new_session() - for widget in self.tab_widgets: - widget.notify_current_tab(self.current_tab_widget) - - def on_save_last_frame_action_triggered(self) -> None: - if self.last_frame is not None: - file_name = QFileDialog.getSaveFileName( - caption="Save Capture", - directory=self.current_tab_widget.data_directory.joinpath("last_capture.zdf").resolve().as_posix(), - filter="Zivid Frame (*.zdf *.ply *.pcd *.xyz)", - )[0] - self.last_frame.save(file_name) - else: - QMessageBox.warning(self, "Save Capture", "No capture to save.") - - def on_select_hand_eye_settings_action_triggered(self) -> None: - self.configure_settings(show_anyway=True) - if self.camera: - self.live2d_widget.update_settings_2d(self.settings.production.settings_2d3d.color, self.camera.info.model) - - def hand_eye_configuration_action_triggered(self) -> None: - self.hand_eye_configuration = select_hand_eye_configuration(self.hand_eye_configuration, show_anyway=True) - self.hand_eye_calibration_gui.hand_eye_configuration_update(self.hand_eye_configuration) - self.hand_eye_verification_gui.hand_eye_configuration_update(self.hand_eye_configuration) - - def on_select_marker_configuration(self) -> None: - self.marker_configuration = select_marker_configuration(self.marker_configuration, show_anyway=True) - self.hand_eye_calibration_gui.marker_configuration_update(self.marker_configuration) - self.hand_eye_verification_gui.marker_configuration_update(self.marker_configuration) - - def on_select_rotation_format(self) -> None: - self.rotation_information = select_rotation_format( - current_rotation_information=self.rotation_information, show_anyway=True - ) - if self.rotation_information is not None: - for widget in self.tab_widgets_with_robot_support(): - widget.rotation_format_update(self.rotation_information) - - def on_select_fixed_objects_action_triggered(self) -> None: - self.hand_eye_calibration_gui.on_select_fixed_objects_action_triggered() - - def on_toggle_advanced_view_action_triggered(self, checked: bool) -> None: - self.hand_eye_calibration_gui.toggle_advanced_view(checked) - self.hand_eye_verification_gui.toggle_advanced_view(checked) - - def on_select_robot_configuration_action_triggered(self) -> None: - selected_robot = select_robot_configuration(self.robot_configuration, show_anyway=True) - if self.robot_configuration.robot_type == selected_robot: - return - self.robot_configuration = selected_robot - self.setup_instructions() - self.on_instructions_updated() - self.robot_control_widget.robot_configuration_update(self.robot_configuration) - if self.robot_configuration.can_control(): - # Check if the widget is already added - if self.verification_tab_widget.indexOf(self.touch_gui) == -1: - self.verification_tab_widget.addTab(self.touch_gui, "by Touching") - else: - self.verification_tab_widget.removeTab(self.verification_tab_widget.indexOf(self.touch_gui)) - for widget in self.tab_widgets: - widget.robot_configuration_update(self.robot_configuration) - - def on_connect_button_clicked(self) -> None: - if self.camera is not None and self.camera.state.connected: - self.live2d_widget.stop_live_2d() - self.live2d_widget.hide() - self.camera.disconnect() - self.camera_buttons.set_connection_status(self.camera) - self.live2d_widget.camera = None - else: - self.camera = select_camera(self.zivid_app, connect=True) - self.live2d_widget.camera = self.camera - self.camera_buttons.set_connection_status(self.camera) - if self.camera: - self.configure_settings() - self.live2d_widget.setMinimumHeight( - int(self.main_tab_widget.height() / 2), - aspect_ratio=self.settings.production.intrinsics.camera_matrix.cx - / self.settings.production.intrinsics.camera_matrix.cy, - ) - if self.camera.state.connected: - self.live2d_widget.set_capture_function(self.camera.capture_2d) - self.live2d_widget.update_settings_2d( - self.settings.production.settings_2d3d.color, self.camera.info.model - ) - self.live2d_widget.show() - self.live2d_widget.start_live_2d() - self.setup_instructions() - self.on_instructions_updated() - - def on_camera_disconnected(self, error_message: str) -> None: - print(f"Camera disconnected signal received {error_message}") - if self.camera is not None: - self.camera.disconnect() - self.camera_buttons.set_connection_status(self.camera) - self.live2d_widget.stop_live_2d() - self.live2d_widget.hide() - - if self.robot_configuration.can_control() and self.auto_run_state != AutoRunState.INACTIVE: - self.finish_auto_run() - if self.camera is not None: - QMessageBox.critical( - self, - "Camera Disconnected", - f"Camera disconnected unexpectedly ({self.camera.state.status})\n{error_message}", - ) - self.camera = None - self.setup_instructions() - self.on_instructions_updated() - - def closeEvent(self, event: QCloseEvent) -> None: # pylint: disable=C0103 - for widget in self.tab_widgets: - widget.closeEvent(event) - self.live2d_widget.closeEvent(event) - self.robot_control_widget.disconnect() - self.data_directory_manager.close_session() - self.stitch_gui.closeEvent(event) - super().closeEvent(event) - def _main() -> None: with ZividQtApplication() as qt_app: diff --git a/source/applications/advanced/hand_eye_calibration/pose_conversion_gui.py b/source/applications/advanced/hand_eye_calibration/pose_conversion_gui.py index 00415fc8..2abb2692 100644 --- a/source/applications/advanced/hand_eye_calibration/pose_conversion_gui.py +++ b/source/applications/advanced/hand_eye_calibration/pose_conversion_gui.py @@ -9,9 +9,9 @@ """ from PyQt5.QtWidgets import QAction, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget -from zividsamples.gui.pose_widget import PoseWidget, PoseWidgetDisplayMode from zividsamples.gui.qt_application import ZividQtApplication -from zividsamples.gui.rotation_format_configuration import RotationInformation +from zividsamples.gui.widgets.pose_widget import PoseWidget, PoseWidgetDisplayMode +from zividsamples.gui.wizard.rotation_format_configuration import RotationInformation from zividsamples.paths import get_image_file_path