|
29 | 29 | from PySide6.QtGui import QBrush, QColor, QImage, QKeyEvent, QPainter, QPen, QPixmap |
30 | 30 | from PySide6.QtWidgets import ( |
31 | 31 | QApplication, |
| 32 | + QCheckBox, |
32 | 33 | QComboBox, |
33 | 34 | QDoubleSpinBox, |
34 | 35 | QFileDialog, |
35 | 36 | QFormLayout, |
36 | 37 | QGraphicsEllipseItem, |
37 | 38 | QGraphicsItem, |
| 39 | + QGraphicsPixmapItem, |
38 | 40 | QGraphicsScene, |
39 | 41 | QGraphicsView, |
40 | 42 | QGridLayout, |
@@ -122,6 +124,10 @@ def __init__(self, image_msg, pointcloud_msg, camerainfo_msg, ros_utils, initial |
122 | 124 | self.extrinsics = np.copy(self.initial_extrinsics) |
123 | 125 | self.occlusion_mask = None |
124 | 126 |
|
| 127 | + # Image rectification state |
| 128 | + self.original_cv_image = None |
| 129 | + self.is_rectification_enabled = False |
| 130 | + |
125 | 131 | self.selection_mode = None |
126 | 132 | self.selected_2d_point = None |
127 | 133 | self.temp_2d_marker = [] |
@@ -152,6 +158,48 @@ def __init__(self, image_msg, pointcloud_msg, camerainfo_msg, ros_utils, initial |
152 | 158 | self._update_calibrate_button_highlight() |
153 | 159 | self.display_camera_intrinsics() |
154 | 160 |
|
| 161 | + |
| 162 | + def has_significant_distortion(self): |
| 163 | + """Check if camera has significant distortion coefficients.""" |
| 164 | + if not hasattr(self.camerainfo_msg, 'd'): |
| 165 | + return False |
| 166 | + |
| 167 | + # Convert distortion coefficients to numpy array |
| 168 | + dist_coeffs = np.array(self.camerainfo_msg.d) |
| 169 | + |
| 170 | + # Check if the array is empty or all zeros |
| 171 | + if dist_coeffs.size == 0: |
| 172 | + return False |
| 173 | + |
| 174 | + # Check if any distortion coefficient is significantly non-zero |
| 175 | + # Use a threshold to account for numerical precision |
| 176 | + threshold = 1e-6 |
| 177 | + return np.any(np.abs(dist_coeffs) > threshold) |
| 178 | + |
| 179 | + def toggle_rectification(self, enabled): |
| 180 | + """Toggle image rectification on/off.""" |
| 181 | + self.is_rectification_enabled = enabled |
| 182 | + self.display_image() # Refresh the display |
| 183 | + |
| 184 | + def rectify_image(self, image): |
| 185 | + """Apply camera undistortion to the image using cv2.undistort.""" |
| 186 | + if not self.has_significant_distortion(): |
| 187 | + return image |
| 188 | + |
| 189 | + # Get camera matrix and distortion coefficients |
| 190 | + K = np.array(self.camerainfo_msg.k).reshape(3, 3) |
| 191 | + dist_coeffs = np.array(self.camerainfo_msg.d) |
| 192 | + |
| 193 | + # Undistort the image |
| 194 | + try: |
| 195 | + # Use cv2.undistort with the same camera matrix as newCameraMatrix |
| 196 | + # This preserves the same image dimensions and focal length |
| 197 | + rectified_image = cv2.undistort(image, K, dist_coeffs, None, K) |
| 198 | + return rectified_image |
| 199 | + except Exception as e: |
| 200 | + print(f"[WARNING] Failed to rectify image: {e}") |
| 201 | + return image |
| 202 | + |
155 | 203 | def _setup_controls(self): |
156 | 204 | right_layout = QHBoxLayout() |
157 | 205 | col1_layout = QVBoxLayout() |
@@ -183,6 +231,20 @@ def _setup_controls(self): |
183 | 231 | self.max_value_spinbox.setRange(-1e9, 1e9) |
184 | 232 | self.max_value_spinbox.setDecimals(2) |
185 | 233 | view_controls_layout.addRow("Max Value:", self.max_value_spinbox) |
| 234 | + |
| 235 | + # Image rectification checkbox |
| 236 | + self.rectify_checkbox = QCheckBox("Rectify Image") |
| 237 | + self.rectify_checkbox.setToolTip("Undistort the image using camera distortion parameters") |
| 238 | + # Only enable if distortion coefficients are available |
| 239 | + has_distortion = self.has_significant_distortion() |
| 240 | + self.rectify_checkbox.setEnabled(has_distortion) |
| 241 | + # Enable by default if distortion is detected |
| 242 | + if has_distortion: |
| 243 | + self.is_rectification_enabled = True |
| 244 | + self.rectify_checkbox.setChecked(True) |
| 245 | + self.rectify_checkbox.toggled.connect(self.toggle_rectification) |
| 246 | + view_controls_layout.addRow(self.rectify_checkbox) |
| 247 | + |
186 | 248 | self.apply_view_button = QPushButton("Apply View Changes") |
187 | 249 | self.apply_view_button.clicked.connect(self.redraw_points) |
188 | 250 | view_controls_layout.addRow(self.apply_view_button) |
@@ -294,7 +356,8 @@ def _setup_controls(self): |
294 | 356 | intrinsics_layout = QVBoxLayout(intrinsics_group) |
295 | 357 |
|
296 | 358 | self.intrinsics_display = QTextEdit() |
297 | | - self.intrinsics_display.setMaximumHeight(300) |
| 359 | + self.intrinsics_display.setMinimumHeight(400) |
| 360 | + self.intrinsics_display.setMaximumHeight(600) |
298 | 361 | self.intrinsics_display.setFont("monospace") |
299 | 362 | self.intrinsics_display.setFontPointSize(10) |
300 | 363 | self.intrinsics_display.setReadOnly(True) |
@@ -557,42 +620,95 @@ def reset_selection_mode(self): |
557 | 620 | self.view.setDragMode(QGraphicsView.ScrollHandDrag) |
558 | 621 |
|
559 | 622 | def display_image(self): |
560 | | - if ( |
561 | | - hasattr(self.image_msg, "_type") |
562 | | - and self.image_msg._type == "sensor_msgs/msg/CompressedImage" |
563 | | - ): |
564 | | - np_arr = np.frombuffer(self.image_msg.data, np.uint8) |
565 | | - self.cv_image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
| 623 | + # Decode/load the original image if not already done |
| 624 | + if self.original_cv_image is None: |
| 625 | + if ( |
| 626 | + hasattr(self.image_msg, "_type") |
| 627 | + and self.image_msg._type == "sensor_msgs/msg/CompressedImage" |
| 628 | + ): |
| 629 | + np_arr = np.frombuffer(self.image_msg.data, np.uint8) |
| 630 | + self.original_cv_image = cv2.imdecode(np_arr, cv2.IMREAD_COLOR) |
| 631 | + else: |
| 632 | + self.original_cv_image = self.ros_utils.image_to_numpy(self.image_msg) |
| 633 | + |
| 634 | + # Convert BGR to RGB if needed |
| 635 | + if "bgr" in self.image_msg.encoding: |
| 636 | + self.original_cv_image = cv2.cvtColor(self.original_cv_image, cv2.COLOR_BGR2RGB) |
| 637 | + |
| 638 | + # Apply rectification if enabled |
| 639 | + if self.is_rectification_enabled: |
| 640 | + # Convert back to BGR for OpenCV undistort function |
| 641 | + bgr_image = cv2.cvtColor(self.original_cv_image, cv2.COLOR_RGB2BGR) |
| 642 | + rectified_bgr = self.rectify_image(bgr_image) |
| 643 | + self.cv_image = cv2.cvtColor(rectified_bgr, cv2.COLOR_BGR2RGB) |
566 | 644 | else: |
567 | | - self.cv_image = self.ros_utils.image_to_numpy(self.image_msg) |
568 | | - if "bgr" in self.image_msg.encoding: |
569 | | - self.cv_image = cv2.cvtColor(self.cv_image, cv2.COLOR_BGR2RGB) |
| 645 | + self.cv_image = self.original_cv_image.copy() |
| 646 | + |
| 647 | + # Update UI and display |
570 | 648 | h, w, c = self.cv_image.shape |
571 | 649 | self.image_res_label.setText(f"{w} x {h}") |
| 650 | + |
| 651 | + # Clear existing image from scene but preserve other items |
| 652 | + items_to_preserve = [] |
| 653 | + for item in self.scene.items(): |
| 654 | + if not isinstance(item, QGraphicsPixmapItem): |
| 655 | + items_to_preserve.append(item) |
| 656 | + |
| 657 | + # Clear the scene and add the new image |
| 658 | + self.scene.clear() |
572 | 659 | q_image = QImage(self.cv_image.data, w, h, 3 * w, QImage.Format_RGB888) |
573 | 660 | self.scene.addPixmap(QPixmap.fromImage(q_image)) |
574 | 661 |
|
| 662 | + # Restore preserved items |
| 663 | + for item in items_to_preserve: |
| 664 | + self.scene.addItem(item) |
| 665 | + |
| 666 | + # Re-project point cloud with the updated image |
| 667 | + self.project_pointcloud() |
| 668 | + |
575 | 669 | def display_camera_intrinsics(self): |
576 | 670 | """Display the camera intrinsic matrix K.""" |
| 671 | + # Add camera info |
| 672 | + display_text = ( |
| 673 | + f"\nImage Size: {self.camerainfo_msg.width} x {self.camerainfo_msg.height}\n" |
| 674 | + ) |
| 675 | + |
577 | 676 | K = np.array(self.camerainfo_msg.k).reshape(3, 3) |
578 | 677 |
|
579 | | - display_text = "Camera Matrix K:\n" |
| 678 | + display_text += "\nCamera Matrix K:\n" |
580 | 679 | for i in range(3): |
581 | 680 | row_text = " ".join(f"{K[i, j]:8.2f}" for j in range(3)) |
582 | 681 | display_text += f"[{row_text}]\n" |
583 | 682 |
|
584 | | - # Add camera info |
585 | | - display_text += ( |
586 | | - f"\nImage Size: {self.camerainfo_msg.width} x {self.camerainfo_msg.height}\n" |
587 | | - ) |
588 | | - display_text += f"Distortion: {self.camerainfo_msg.distortion_model}" |
589 | | - |
590 | 683 | # Add focal length and principal point info |
591 | 684 | fx, fy = K[0, 0], K[1, 1] |
592 | 685 | cx, cy = K[0, 2], K[1, 2] |
593 | | - display_text += f"\n\nFocal Length: fx={fx:.1f}, fy={fy:.1f}" |
| 686 | + display_text += f"\nFocal Length: fx={fx:.1f}, fy={fy:.1f}" |
594 | 687 | display_text += f"\nPrincipal Point: cx={cx:.1f}, cy={cy:.1f}" |
595 | 688 |
|
| 689 | + display_text += "\n" |
| 690 | + |
| 691 | + |
| 692 | + display_text += f"Distortion Model: {self.camerainfo_msg.distortion_model}" |
| 693 | + |
| 694 | + # Add distortion coefficients |
| 695 | + if hasattr(self.camerainfo_msg, 'd') and len(self.camerainfo_msg.d) > 0: |
| 696 | + dist_coeffs = np.array(self.camerainfo_msg.d) |
| 697 | + display_text += "\nDistortion Coeffs: [" |
| 698 | + coeffs_str = ", ".join(f"{coeff:.6f}" for coeff in dist_coeffs) |
| 699 | + display_text += coeffs_str + "]" |
| 700 | + |
| 701 | + # Add interpretation of common distortion models |
| 702 | + if len(dist_coeffs) >= 4: |
| 703 | + display_text += f"\n k1={dist_coeffs[0]:.6f}, k2={dist_coeffs[1]:.6f}" |
| 704 | + display_text += f"\n p1={dist_coeffs[2]:.6f}, p2={dist_coeffs[3]:.6f}" |
| 705 | + if len(dist_coeffs) >= 5: |
| 706 | + display_text += f", k3={dist_coeffs[4]:.6f}" |
| 707 | + if len(dist_coeffs) >= 8: |
| 708 | + display_text += f"\n k4={dist_coeffs[5]:.6f}, k5={dist_coeffs[6]:.6f}, k6={dist_coeffs[7]:.6f}" |
| 709 | + else: |
| 710 | + display_text += "\nDistortion Coeffs: None" |
| 711 | + |
596 | 712 | self.intrinsics_display.setPlainText(display_text) |
597 | 713 |
|
598 | 714 | def redraw_points(self): |
|
0 commit comments