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 00000000..c9a42636 Binary files /dev/null and b/modules/zividsamples/images/LogoZBlue.png differ 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