串口搜索和摄像头搜索

This commit is contained in:
cheng
2026-07-23 17:50:39 +08:00
parent b3fe4d976f
commit daa0ec8aec
44 changed files with 4705 additions and 45 deletions
+319 -5
View File
@@ -18,6 +18,11 @@ if sys.platform.startswith("linux") and os.environ.get("WSL_DISTRO_NAME"):
import cv2
import numpy as np
try:
from serial.tools import list_ports as serial_list_ports
except Exception:
serial_list_ports = None
def _fix_qt_plugin_path():
try:
@@ -42,6 +47,7 @@ try:
QApplication,
QButtonGroup,
QCheckBox,
QComboBox,
QDialog,
QFrame,
QGraphicsOpacityEffect,
@@ -71,7 +77,7 @@ except Exception as exc:
QColor = QLinearGradient = QPainter = QPen = QPolygonF = QRadialGradient = QVector3D = None
QGridLayout = QHBoxLayout = QLabel = QPushButton = QSizeGrip = QSizePolicy = QSplitter = None
QVBoxLayout = QWidget = None
QButtonGroup = QCheckBox = QDialog = QFrame = QGraphicsOpacityEffect = QLineEdit = QListWidget = QMessageBox = QMainWindow = object
QButtonGroup = QCheckBox = QComboBox = QDialog = QFrame = QGraphicsOpacityEffect = QLineEdit = QListWidget = QMessageBox = QMainWindow = object
QProgressBar = QScrollArea = QSlider = QSpinBox = QStackedWidget = QTextEdit = None
try:
@@ -92,6 +98,7 @@ else:
from ..app import build_controller
from ..common.paths import PACKAGE_DIR, REPO_ROOT
from ..sensors.camera_discovery import camera_source_index, list_cameras
PLOT_POINTS = 180
@@ -599,7 +606,7 @@ QSpinBox {
border-radius: 4px;
padding: 3px 5px;
}
QLineEdit, QTextEdit {
QLineEdit, QTextEdit, QComboBox {
background-color: #ffffff;
color: #1f2933;
border: 1px solid #d7dee8;
@@ -608,7 +615,7 @@ QLineEdit, QTextEdit {
font-family: "Cascadia Code", "Consolas";
font-size: 12px;
}
QLineEdit:focus, QTextEdit:focus {
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {
border: 1px solid #7aa7e8;
}
QListWidget {
@@ -733,6 +740,12 @@ def _draw_top_icon(kind, color="#344054", size=22):
painter.setBrush(QColor("#ffffff"))
painter.drawEllipse(QPointF(knob_x, y), size * 0.075, size * 0.075)
painter.setBrush(Qt.NoBrush)
elif kind == "search":
painter.drawEllipse(QRectF(size * 0.25, size * 0.23, size * 0.40, size * 0.40))
painter.drawLine(
QPointF(size * 0.60, size * 0.60),
QPointF(size * 0.76, size * 0.76),
)
elif kind == "minimize":
painter.drawLine(
QPointF(size * 0.32, size * 0.62),
@@ -932,6 +945,7 @@ class ImageBox(QFrame):
layout.setContentsMargins(6, 6, 6, 6)
layout.setSpacing(5)
header = QHBoxLayout()
self.header_layout = header
header.setSpacing(6)
self.title_label = QLabel(title)
self.title_label.setObjectName("SectionTitle")
@@ -994,6 +1008,10 @@ class FloatingImageBox(ImageBox):
self.image_label.setScaledContents(False)
self.mode_toggle_button.setObjectName("FlowModeButton")
self.mode_toggle_button.setText("光流")
self.camera_search_button = TopIconButton("search", "搜索并设置左右摄像头")
self.camera_search_button.setFixedSize(24, 24)
self.camera_search_button.setIconSize(QSize(17, 17))
self.header_layout.insertWidget(self.header_layout.count() - 1, self.camera_search_button)
self._drag_mode = None
self._drag_start_global = None
self._start_geometry = None
@@ -2203,6 +2221,141 @@ class SliderControl(QFrame):
return int(self.slider.value())
class CameraDiscoveryDialog(QDialog):
def __init__(self, viewer):
super().__init__(viewer)
self.viewer = viewer
self._scan_id = 0
self._scan_result = None
self.setWindowTitle("双摄像头设置")
self.setWindowIcon(_load_company_icon(64))
# Keep the main window responsive while OpenCV probes devices in the
# worker thread. A modeless child also avoids a full-screen compositor
# flash caused by switching into a nested modal event loop.
self.setModal(False)
self.setMinimumWidth(520)
layout = QVBoxLayout(self)
layout.setContentsMargins(14, 14, 14, 14)
layout.setSpacing(10)
self.status_label = QLabel("正在搜索摄像头...")
self.status_label.setObjectName("MetricLabel")
layout.addWidget(self.status_label)
camera_grid = QGridLayout()
camera_grid.setHorizontalSpacing(10)
camera_grid.setVerticalSpacing(8)
self.camera_combos = {}
for row, (side, title) in enumerate((("left", "左侧摄像头"), ("right", "右侧摄像头"))):
label = QLabel(title)
label.setObjectName("MetricLabel")
combo = QComboBox()
combo.setMinimumHeight(32)
self.camera_combos[side] = combo
camera_grid.addWidget(label, row, 0)
camera_grid.addWidget(combo, row, 1)
camera_grid.setColumnStretch(1, 1)
layout.addLayout(camera_grid)
actions = QHBoxLayout()
self.refresh_button = AnimatedButton("重新搜索")
self.refresh_button.setObjectName("ManualButton")
self.refresh_button.clicked.connect(self._start_scan)
self.apply_button = AnimatedButton("应用左右摄像头")
self.apply_button.setObjectName("OpenActionButton")
self.apply_button.clicked.connect(self._apply)
cancel_button = AnimatedButton("取消")
cancel_button.setObjectName("ManualButton")
cancel_button.clicked.connect(self.reject)
actions.addWidget(self.refresh_button)
actions.addStretch(1)
actions.addWidget(self.apply_button)
actions.addWidget(cancel_button)
layout.addLayout(actions)
self.poll_timer = QTimer(self)
self.poll_timer.setInterval(100)
self.poll_timer.timeout.connect(self._poll_scan)
self._start_scan()
def _start_scan(self):
self._scan_id += 1
scan_id = self._scan_id
self._scan_result = None
self.status_label.setText("正在快速搜索摄像头(编号 0-3)...")
current_sources = self.viewer.camera_sources()
skip_indices = set()
for side, combo in self.camera_combos.items():
combo.clear()
current = current_sources.get(side, "")
if current:
combo.addItem(f"当前配置 {current}(可能正在使用)", current)
combo.setEnabled(True)
current_index = camera_source_index(current)
if current_index is not None:
skip_indices.add(current_index)
else:
combo.setEnabled(False)
self.refresh_button.setEnabled(False)
self.apply_button.setEnabled(
all(combo.count() > 0 for combo in self.camera_combos.values())
)
def scan():
try:
self._scan_result = (
scan_id,
list_cameras(skip_indices=skip_indices),
None,
)
except Exception as exc:
self._scan_result = (scan_id, [], str(exc))
threading.Thread(target=scan, daemon=True).start()
self.poll_timer.start()
def _poll_scan(self):
result = self._scan_result
if result is None:
return
scan_id, cameras, error = result
if scan_id != self._scan_id:
return
self.poll_timer.stop()
self.refresh_button.setEnabled(True)
current_sources = self.viewer.camera_sources()
found_sources = set()
for side, combo in self.camera_combos.items():
combo.clear()
for camera in cameras:
combo.addItem(camera.label(), camera.source)
found_sources.add(camera.source)
current = current_sources.get(side, "")
if current and current not in found_sources:
combo.addItem(f"当前配置 {current}(可能正在使用)", current)
current_index = combo.findData(current)
if current_index >= 0:
combo.setCurrentIndex(current_index)
if error:
self.status_label.setText(f"搜索失败:{error}")
elif cameras:
self.status_label.setText(f"找到 {len(cameras)} 个可用摄像头")
else:
self.status_label.setText("未发现空闲摄像头;正在使用的当前配置仍可保留")
has_both = all(combo.count() > 0 for combo in self.camera_combos.values())
for combo in self.camera_combos.values():
combo.setEnabled(combo.count() > 0)
self.apply_button.setEnabled(has_both)
def _apply(self):
left = self.camera_combos["left"].currentData()
right = self.camera_combos["right"].currentData()
if left is None or right is None:
return
if self.viewer.apply_camera_sources(str(left), str(right)):
self.accept()
class VisualSettingsDialog(QDialog):
CONFIG_GROUPS = [
(
@@ -2595,6 +2748,21 @@ class VisualSettingsDialog(QDialog):
return scroll
def _make_control(self, key, value):
if key == "port":
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
control = QComboBox()
control.setEditable(True)
control.addItem(str(value), str(value))
control.setToolTip("port")
search_button = TopIconButton("search", "搜索可用串口")
search_button.clicked.connect(self._search_serial_ports)
layout.addWidget(control, 1)
layout.addWidget(search_button)
self.controls[key] = control
return container
if isinstance(value, bool):
control = QCheckBox()
control.setChecked(value)
@@ -2612,6 +2780,11 @@ class VisualSettingsDialog(QDialog):
def _control_value(self, key, original):
control = self.controls[key]
if isinstance(control, QComboBox):
selected = control.currentData()
if control.currentIndex() >= 0 and selected is not None:
return str(selected)
return control.currentText().strip()
if isinstance(original, bool):
return bool(control.isChecked())
if isinstance(original, (dict, list)):
@@ -2636,11 +2809,50 @@ class VisualSettingsDialog(QDialog):
return
if isinstance(control, QCheckBox):
control.setChecked(bool(value))
elif isinstance(control, QComboBox):
text = "" if value is None else str(value)
index = control.findData(text)
if index < 0:
control.addItem(text, text)
index = control.count() - 1
control.setCurrentIndex(index)
elif isinstance(control, QTextEdit):
control.setPlainText(json.dumps(value, ensure_ascii=False, indent=2))
elif isinstance(control, QLineEdit):
control.setText("null" if value is None else str(value))
def _search_serial_ports(self):
control = self.controls.get("port")
if not isinstance(control, QComboBox):
return
current = self._control_value("port", self.config_data.get("port", ""))
if serial_list_ports is None:
QMessageBox.warning(self, "搜索失败", "PySerial 串口枚举模块未安装。")
return
try:
ports = list(serial_list_ports.comports())
except Exception as exc:
QMessageBox.warning(self, "搜索失败", f"无法枚举串口:\n{exc}")
return
control.clear()
selected_index = -1
for port in ports:
device = str(port.device)
description = str(port.description or "未知设备")
control.addItem(f"{device} [{description}]", device)
if device == current:
selected_index = control.count() - 1
if current and selected_index < 0:
control.addItem(f"{current} [当前配置]", current)
selected_index = control.count() - 1
if selected_index >= 0:
control.setCurrentIndex(selected_index)
if not ports:
QMessageBox.information(self, "搜索串口", "未发现可用串口,已保留当前配置。")
return
control.showPopup()
def _reload(self):
self._load_config()
for key, value in self.config_data.items():
@@ -2665,12 +2877,26 @@ class VisualSettingsDialog(QDialog):
QMessageBox.warning(self, "保存失败", f"无法写入配置文件:\n{exc}")
return
try:
layout_path = self.viewer.save_current_layout()
except Exception as exc:
QMessageBox.warning(self, "画面保存失败", f"参数已写入,但画面旋转无法保存:\n{exc}")
return
self.config_data = updated
QMessageBox.information(self, "已保存", "配置已写入 gripper.json。重启控制程序后完整生效。")
QMessageBox.information(
self,
"已保存",
f"配置已写入 gripper.json,画面旋转已写入:\n{layout_path}\n重启控制程序后完整生效。",
)
def _adjust(self, side, action):
self.viewer.adjust_visual_transform(side, action)
self._refresh_labels()
try:
self.viewer.save_current_layout()
except Exception as exc:
QMessageBox.warning(self, "保存失败", f"画面已调整,但无法保存旋转配置:\n{exc}")
def _refresh_labels(self):
for side, title in (("left", "左侧光流"), ("right", "右侧光流")):
@@ -2726,7 +2952,7 @@ class GripperDemo02Viewer(QMainWindow):
if pg is None:
raise RuntimeError(f"pyqtgraph import failed: {_PYQTGRAPH_IMPORT_ERROR}")
self.setWindowTitle("Gripper Demo 02")
self.setWindowTitle("Orisys 夹爪")
self.setWindowIcon(_load_company_icon(64))
self.setStyleSheet(STYLE)
self.controller = None
@@ -2745,6 +2971,7 @@ class GripperDemo02Viewer(QMainWindow):
self.argv = argv
self.layout_path = GUI_LAYOUT_PATH
self.visual_settings_dialog = None
self.camera_discovery_dialog = None
self._pending_runtime_speed = None
self._pending_position_target = None
self._pending_position_since = 0.0
@@ -2898,6 +3125,91 @@ class GripperDemo02Viewer(QMainWindow):
config_path = REPO_ROOT / config_path
return config_path
def camera_sources(self):
sources = {"left": "", "right": ""}
try:
with open(self._control_config_path(), "r", encoding="utf-8") as f:
data = json.load(f)
sources["left"] = str(data.get("video1", ""))
sources["right"] = str(data.get("video2", ""))
except Exception:
pass
return sources
def open_camera_search(self):
existing = self.camera_discovery_dialog
if existing is not None and existing.isVisible():
existing.raise_()
existing.activateWindow()
return
dialog = CameraDiscoveryDialog(self)
self.camera_discovery_dialog = dialog
dialog.finished.connect(lambda _result: setattr(self, "camera_discovery_dialog", None))
dialog.show()
dialog.raise_()
def _update_camera_argv(self, config_key, source):
args = list(self.argv or [])
option_names = {
"video1": ("--video1", "-v1"),
"video2": ("--video2", "-v2"),
}[config_key]
for index, arg in enumerate(args):
if arg in option_names and index + 1 < len(args):
args[index + 1] = source
self.argv = args
return
for option in option_names:
if str(arg).startswith(option + "="):
args[index] = option + "=" + source
self.argv = args
return
def apply_camera_sources(self, left_source, right_source):
if left_source == right_source:
QMessageBox.warning(self, "摄像头冲突", "左右两侧需要选择不同的摄像头。")
return False
sources = self.camera_sources()
if left_source == sources.get("left") and right_source == sources.get("right"):
return True
config_path = self._control_config_path()
temp_path = config_path.with_suffix(config_path.suffix + ".tmp")
try:
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("配置文件根节点不是 JSON 对象")
data["video1"] = left_source
data["video2"] = right_source
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(temp_path, config_path)
except Exception as exc:
try:
if temp_path.exists():
temp_path.unlink()
except OSError:
pass
QMessageBox.warning(self, "保存失败", f"无法保存摄像头配置:\n{exc}")
return False
self._update_camera_argv("video1", left_source)
self._update_camera_argv("video2", right_source)
if self.visual_settings_dialog is not None:
for config_key, source in (("video1", left_source), ("video2", right_source)):
self.visual_settings_dialog.config_data[config_key] = source
self.visual_settings_dialog._set_control_value(config_key, source)
QMessageBox.information(
self,
"双摄像头已切换",
f"左侧摄像头: {left_source}\n右侧摄像头: {right_source}\n控制程序将自动重启。",
)
self.restart_control()
return True
def _load_position_config(self):
open_pos = 0
close_pos = 9000
@@ -3007,6 +3319,8 @@ class GripperDemo02Viewer(QMainWindow):
self.right_flow = self.gripper_stage.right_flow
self.left_flow.set_transform_label(self.visual_transforms["left"])
self.right_flow.set_transform_label(self.visual_transforms["right"])
self.left_flow.camera_search_button.clicked.connect(self.open_camera_search)
self.right_flow.camera_search_button.clicked.connect(self.open_camera_search)
visual_layout.addWidget(self.gripper_stage, 0, 0)
visual_layout.setColumnStretch(0, 1)
center_splitter.addWidget(visual_panel)