|
@@ -0,0 +1,756 @@
|
|
|
|
|
+import os
|
|
|
|
|
+import logging
|
|
|
|
|
+import threading
|
|
|
|
|
+from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
|
|
|
+ QPushButton, QLabel, QGridLayout, QScrollArea,
|
|
|
|
|
+ QToolButton, QFrame, QApplication, QCheckBox)
|
|
|
|
|
+from PySide6.QtCore import Qt, QTimer, QSize
|
|
|
|
|
+from PySide6.QtGui import QIcon, QPixmap, QResizeEvent, QKeyEvent, QCloseEvent
|
|
|
|
|
+from .models import *
|
|
|
|
|
+from .rtsp_player import RTSPPlayer
|
|
|
|
|
+from .rfid_client import RFIDClient
|
|
|
|
|
+from .modbus_client import ModbusClient
|
|
|
|
|
+from .mqtt_client import MQTTClient
|
|
|
|
|
+import pyttsx3
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+class MainWindow(QMainWindow):
|
|
|
|
|
+ # 防抖阈值(连续相同状态次数)
|
|
|
|
|
+ STATE_THRESHOLD = 3
|
|
|
|
|
+ # 放包判断位移阈值(像素)
|
|
|
|
|
+ RELEASE_DELTA_THRESHOLD = 5
|
|
|
|
|
+
|
|
|
|
|
+ def __init__(self, config: Config):
|
|
|
|
|
+ super().__init__()
|
|
|
|
|
+ self.config = config
|
|
|
|
|
+ self.buckets = config.buckets
|
|
|
|
|
+ self.active_bucket: BucketModel = None
|
|
|
|
|
+ self.switch_mode = SwitchMode.AUTO
|
|
|
|
|
+
|
|
|
|
|
+ self.left_player: RTSPPlayer = None
|
|
|
|
|
+ self.right_player: RTSPPlayer = None
|
|
|
|
|
+
|
|
|
|
|
+ self.rfid_client = None
|
|
|
|
|
+ self.modbus_client = None
|
|
|
|
|
+ self.mqtt_client = None
|
|
|
|
|
+
|
|
|
|
|
+ self.video_aspect_ratio = 16 / 9
|
|
|
|
|
+ self.left_label = None
|
|
|
|
|
+ self.right_label = None
|
|
|
|
|
+
|
|
|
|
|
+ # 挂钩状态相关
|
|
|
|
|
+ self.hook_state = HookState.IDLE
|
|
|
|
|
+ self.prev_state = HookState.IDLE
|
|
|
|
|
+ self.state_counter = 0
|
|
|
|
|
+
|
|
|
|
|
+ # 存储左右画面最近检测对象
|
|
|
|
|
+ self.left_objects = []
|
|
|
|
|
+ self.right_objects = []
|
|
|
|
|
+ # 前一帧 hook/lug 中心位置(用于轨迹判断)
|
|
|
|
|
+ self.prev_left_hook = None
|
|
|
|
|
+ self.prev_right_hook = None
|
|
|
|
|
+ self.prev_left_lug = None
|
|
|
|
|
+ self.prev_right_lug = None
|
|
|
|
|
+
|
|
|
|
|
+ # 语音引擎
|
|
|
|
|
+ self.tts_engine = pyttsx3.init()
|
|
|
|
|
+ self.tts_engine.setProperty('rate', 150)
|
|
|
|
|
+ self.last_voice_state = None
|
|
|
|
|
+ self.voice_enabled = True # 默认开启
|
|
|
|
|
+
|
|
|
|
|
+ self.setWindowTitle(config.app_name)
|
|
|
|
|
+
|
|
|
|
|
+ screen = QApplication.primaryScreen()
|
|
|
|
|
+ screen_geometry = screen.availableGeometry()
|
|
|
|
|
+ screen_width = screen_geometry.width()
|
|
|
|
|
+ screen_height = screen_geometry.height()
|
|
|
|
|
+ window_width = int(screen_width * 0.9)
|
|
|
|
|
+ window_height = int(screen_height * 0.85)
|
|
|
|
|
+ x = (screen_width - window_width) // 2
|
|
|
|
|
+ y = (screen_height - window_height) // 2
|
|
|
|
|
+ self.setGeometry(x, y, window_width, window_height)
|
|
|
|
|
+ self.setMinimumSize(800, 600)
|
|
|
|
|
+
|
|
|
|
|
+ self.setup_ui()
|
|
|
|
|
+
|
|
|
|
|
+ self.showFullScreen()
|
|
|
|
|
+ self.update_video_size()
|
|
|
|
|
+ self.init_hardware()
|
|
|
|
|
+
|
|
|
|
|
+ self.timer = QTimer()
|
|
|
|
|
+ self.timer.timeout.connect(self.check_idle)
|
|
|
|
|
+ self.timer.start(1000)
|
|
|
|
|
+
|
|
|
|
|
+ def setup_ui(self):
|
|
|
|
|
+ central = QWidget()
|
|
|
|
|
+ self.setCentralWidget(central)
|
|
|
|
|
+ main_layout = QVBoxLayout(central)
|
|
|
|
|
+ main_layout.setContentsMargins(10, 10, 10, 10)
|
|
|
|
|
+ main_layout.setSpacing(10)
|
|
|
|
|
+
|
|
|
|
|
+ # 标题栏
|
|
|
|
|
+ title_widget = QWidget()
|
|
|
|
|
+ title_layout = QHBoxLayout(title_widget)
|
|
|
|
|
+ title_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
+ self.title_label = QLabel(self.config.app_name)
|
|
|
|
|
+ self.title_label.setStyleSheet("font-size: 22px; font-weight: bold; padding: 5px;")
|
|
|
|
|
+ title_layout.addWidget(self.title_label)
|
|
|
|
|
+ title_layout.addStretch()
|
|
|
|
|
+
|
|
|
|
|
+ btn_style = """
|
|
|
|
|
+ QPushButton {
|
|
|
|
|
+ background-color: transparent;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ padding: 6px 12px;
|
|
|
|
|
+ border-radius: 5px;
|
|
|
|
|
+ }
|
|
|
|
|
+ QPushButton:hover {
|
|
|
|
|
+ background-color: rgba(255, 255, 255, 0.1);
|
|
|
|
|
+ }
|
|
|
|
|
+ """
|
|
|
|
|
+ self.min_btn = QPushButton("—")
|
|
|
|
|
+ self.min_btn.setStyleSheet(btn_style)
|
|
|
|
|
+ self.min_btn.clicked.connect(self.showMinimized)
|
|
|
|
|
+ self.close_btn = QPushButton("✕")
|
|
|
|
|
+ self.close_btn.setStyleSheet(btn_style)
|
|
|
|
|
+ self.close_btn.clicked.connect(self.close)
|
|
|
|
|
+
|
|
|
|
|
+ title_layout.addWidget(self.min_btn)
|
|
|
|
|
+ title_layout.addWidget(self.close_btn)
|
|
|
|
|
+ main_layout.addWidget(title_widget)
|
|
|
|
|
+
|
|
|
|
|
+ # 工位区域
|
|
|
|
|
+ bucket_group = QFrame()
|
|
|
|
|
+ bucket_group.setStyleSheet("QFrame { border-radius: 8px; padding: 8px; }")
|
|
|
|
|
+ bucket_layout = QVBoxLayout(bucket_group)
|
|
|
|
|
+
|
|
|
|
|
+ scroll_area = QScrollArea()
|
|
|
|
|
+ scroll_area.setWidgetResizable(True)
|
|
|
|
|
+ scroll_area.setFrameShape(QFrame.NoFrame)
|
|
|
|
|
+ scroll_area.setStyleSheet("QScrollArea { background: transparent; border: none; }")
|
|
|
|
|
+
|
|
|
|
|
+ self.bucket_container = QWidget()
|
|
|
|
|
+ self.bucket_grid = QGridLayout(self.bucket_container)
|
|
|
|
|
+ self.bucket_grid.setSpacing(15)
|
|
|
|
|
+ self.bucket_grid.setContentsMargins(10, 10, 10, 10)
|
|
|
|
|
+ self.bucket_grid.setAlignment(Qt.AlignLeft | Qt.AlignTop)
|
|
|
|
|
+
|
|
|
|
|
+ self.bucket_buttons = []
|
|
|
|
|
+ # 原代码(删除)
|
|
|
|
|
+ # for idx, bucket in enumerate(self.buckets):
|
|
|
|
|
+ # btn = QToolButton()
|
|
|
|
|
+ # ...
|
|
|
|
|
+ # row = idx // 6
|
|
|
|
|
+ # col = idx % 6
|
|
|
|
|
+ # self.bucket_grid.addWidget(btn, row, col)
|
|
|
|
|
+ # self.bucket_buttons.append((btn, bucket))
|
|
|
|
|
+
|
|
|
|
|
+ # 新代码:按 code_x 分组纵向排列
|
|
|
|
|
+ from collections import defaultdict
|
|
|
|
|
+
|
|
|
|
|
+ groups = defaultdict(list)
|
|
|
|
|
+ for bucket in self.buckets:
|
|
|
|
|
+ groups[bucket.code_x].append(bucket)
|
|
|
|
|
+
|
|
|
|
|
+ sorted_keys = sorted(groups.keys())
|
|
|
|
|
+ self.bucket_buttons = [] # 重置
|
|
|
|
|
+
|
|
|
|
|
+ col = 0
|
|
|
|
|
+ for key in sorted_keys:
|
|
|
|
|
+ buckets_in_group = groups[key]
|
|
|
|
|
+ for row, bucket in enumerate(buckets_in_group):
|
|
|
|
|
+ btn = QToolButton()
|
|
|
|
|
+ btn.setFixedSize(100, 100)
|
|
|
|
|
+ btn.setToolButtonStyle(Qt.ToolButtonTextUnderIcon)
|
|
|
|
|
+ btn.setText(bucket.description)
|
|
|
|
|
+ btn.setIcon(self.get_bucket_icon(bucket.is_active))
|
|
|
|
|
+ btn.setIconSize(QSize(64, 64))
|
|
|
|
|
+ btn.setStyleSheet("""
|
|
|
|
|
+ QToolButton {
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ }
|
|
|
|
|
+ """)
|
|
|
|
|
+ btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
|
|
|
|
|
+ self.bucket_grid.addWidget(btn, row, col)
|
|
|
|
|
+ self.bucket_buttons.append((btn, bucket))
|
|
|
|
|
+ col += 1
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ self.bucket_container.setLayout(self.bucket_grid)
|
|
|
|
|
+ scroll_area.setWidget(self.bucket_container)
|
|
|
|
|
+ bucket_layout.addWidget(scroll_area)
|
|
|
|
|
+ main_layout.addWidget(bucket_group)
|
|
|
|
|
+
|
|
|
|
|
+ # 视频区域(移除覆盖层,仅保留边框)
|
|
|
|
|
+ video_container = QWidget()
|
|
|
|
|
+ video_layout = QHBoxLayout(video_container)
|
|
|
|
|
+ video_layout.setSpacing(15)
|
|
|
|
|
+ video_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
|
+
|
|
|
|
|
+ self.left_label = QLabel("左侧画面")
|
|
|
|
|
+ self.left_label.setScaledContents(False)
|
|
|
|
|
+ self.left_label.setAlignment(Qt.AlignCenter)
|
|
|
|
|
+ self.left_label.setStyleSheet("""
|
|
|
|
|
+ QLabel {
|
|
|
|
|
+ border: 2px solid #dcdfe6;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background-color: black;
|
|
|
|
|
+ }
|
|
|
|
|
+ """)
|
|
|
|
|
+ self.right_label = QLabel("右侧画面")
|
|
|
|
|
+ self.right_label.setScaledContents(False)
|
|
|
|
|
+ self.right_label.setAlignment(Qt.AlignCenter)
|
|
|
|
|
+ self.right_label.setStyleSheet("""
|
|
|
|
|
+ QLabel {
|
|
|
|
|
+ border: 2px solid #dcdfe6;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background-color: black;
|
|
|
|
|
+ }
|
|
|
|
|
+ """)
|
|
|
|
|
+
|
|
|
|
|
+ video_layout.addWidget(self.left_label)
|
|
|
|
|
+ video_layout.addWidget(self.right_label)
|
|
|
|
|
+ main_layout.addWidget(video_container, 1)
|
|
|
|
|
+
|
|
|
|
|
+ # 状态栏
|
|
|
|
|
+ status_widget = QWidget()
|
|
|
|
|
+ status_layout = QHBoxLayout(status_widget)
|
|
|
|
|
+ status_layout.setContentsMargins(5, 5, 5, 5)
|
|
|
|
|
+
|
|
|
|
|
+ self.module_status = QLabel("通讯状态: 初始化")
|
|
|
|
|
+ self.module_status.setStyleSheet("color: #606266; font-weight: bold;")
|
|
|
|
|
+ self.rfid_tag_label = QLabel("当前卡号: 无")
|
|
|
|
|
+ self.rfid_tag_label.setStyleSheet("color: #409eff; font-weight: bold; margin-left: 20px;")
|
|
|
|
|
+
|
|
|
|
|
+ self.hook_status_label = QLabel("挂钩状态: 空闲")
|
|
|
|
|
+ self.hook_status_label.setStyleSheet("color: #ffa500; font-weight: bold; margin-left: 20px;")
|
|
|
|
|
+
|
|
|
|
|
+ self.voice_checkbox = QCheckBox("语音播报")
|
|
|
|
|
+ self.voice_checkbox.setChecked(True)
|
|
|
|
|
+ self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
|
|
|
|
|
+ self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
|
|
|
|
|
+
|
|
|
|
|
+ self.auto_switch_btn = QPushButton("自动模式")
|
|
|
|
|
+ self.auto_switch_btn.setCheckable(True)
|
|
|
|
|
+ self.auto_switch_btn.setChecked(True)
|
|
|
|
|
+ self.auto_switch_btn.setStyleSheet("border-radius: 15px; padding: 5px 15px; font-weight: bold;")
|
|
|
|
|
+ self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
|
|
|
|
|
+
|
|
|
|
|
+ status_layout.addWidget(self.module_status)
|
|
|
|
|
+ status_layout.addWidget(self.rfid_tag_label)
|
|
|
|
|
+ status_layout.addWidget(self.hook_status_label)
|
|
|
|
|
+ status_layout.addStretch()
|
|
|
|
|
+ status_layout.addWidget(self.voice_checkbox)
|
|
|
|
|
+ status_layout.addWidget(self.auto_switch_btn)
|
|
|
|
|
+ main_layout.addWidget(status_widget)
|
|
|
|
|
+
|
|
|
|
|
+ self.update_video_size()
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 事件与资源管理 ----------
|
|
|
|
|
+ def closeEvent(self, event: QCloseEvent):
|
|
|
|
|
+ """窗口关闭时释放资源"""
|
|
|
|
|
+ if self.rfid_client:
|
|
|
|
|
+ self.rfid_client.disconnect()
|
|
|
|
|
+ if self.modbus_client:
|
|
|
|
|
+ self.modbus_client.disconnect()
|
|
|
|
|
+ if self.mqtt_client:
|
|
|
|
|
+ self.mqtt_client.disconnect()
|
|
|
|
|
+ if self.left_player:
|
|
|
|
|
+ self.left_player.stop()
|
|
|
|
|
+ if self.right_player:
|
|
|
|
|
+ self.right_player.stop()
|
|
|
|
|
+ event.accept()
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 语音控制 ----------
|
|
|
|
|
+ def on_voice_toggle(self, state):
|
|
|
|
|
+ self.voice_enabled = (state == Qt.Checked)
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 工位图标 ----------
|
|
|
|
|
+ def get_bucket_icon(self, is_active):
|
|
|
|
|
+ icon_dir = os.path.join(os.path.dirname(__file__), "..", "assets")
|
|
|
|
|
+ if not os.path.exists(icon_dir):
|
|
|
|
|
+ os.makedirs(icon_dir, exist_ok=True)
|
|
|
|
|
+ return QIcon()
|
|
|
|
|
+ if is_active:
|
|
|
|
|
+ icon_path = os.path.join(icon_dir, "bucket_active.svg")
|
|
|
|
|
+ else:
|
|
|
|
|
+ icon_path = os.path.join(icon_dir, "bucket_normal.svg")
|
|
|
|
|
+ if os.path.exists(icon_path):
|
|
|
|
|
+ return QIcon(icon_path)
|
|
|
|
|
+ else:
|
|
|
|
|
+ pixmap = QPixmap(64, 64)
|
|
|
|
|
+ pixmap.fill(Qt.green if is_active else Qt.gray)
|
|
|
|
|
+ return QIcon(pixmap)
|
|
|
|
|
+
|
|
|
|
|
+ def refresh_bucket_icons(self):
|
|
|
|
|
+ for btn, bucket in self.bucket_buttons:
|
|
|
|
|
+ icon = self.get_bucket_icon(bucket.is_active)
|
|
|
|
|
+ btn.setIcon(icon)
|
|
|
|
|
+ if bucket.is_active:
|
|
|
|
|
+ btn.setStyleSheet("""
|
|
|
|
|
+ QToolButton {
|
|
|
|
|
+ background-color: #e1f3d8;
|
|
|
|
|
+ border: 2px solid #67c23a;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: #1f2d3d;
|
|
|
|
|
+ }
|
|
|
|
|
+ """)
|
|
|
|
|
+ else:
|
|
|
|
|
+ btn.setStyleSheet("""
|
|
|
|
|
+ QToolButton {
|
|
|
|
|
+ background-color: transparent;
|
|
|
|
|
+ border: 2px solid #d0d7de;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ font-weight: bold;
|
|
|
|
|
+ color: #1f2d3d;
|
|
|
|
|
+ }
|
|
|
|
|
+ QToolButton:hover {
|
|
|
|
|
+ background-color: rgba(0, 0, 0, 0.05);
|
|
|
|
|
+ border-color: #409eff;
|
|
|
|
|
+ }
|
|
|
|
|
+ """)
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 视频尺寸自适应 ----------
|
|
|
|
|
+ def update_video_size(self):
|
|
|
|
|
+ if not self.left_label or not self.right_label:
|
|
|
|
|
+ return
|
|
|
|
|
+ container = self.left_label.parentWidget()
|
|
|
|
|
+ if not container:
|
|
|
|
|
+ return
|
|
|
|
|
+ available_width = container.width() - 15
|
|
|
|
|
+ single_width = available_width // 2
|
|
|
|
|
+ target_height = int(single_width / self.video_aspect_ratio)
|
|
|
|
|
+ if target_height > container.height():
|
|
|
|
|
+ target_height = container.height()
|
|
|
|
|
+ self.left_label.setFixedSize(single_width, target_height)
|
|
|
|
|
+ self.right_label.setFixedSize(single_width, target_height)
|
|
|
|
|
+
|
|
|
|
|
+ def update_players_target_size(self):
|
|
|
|
|
+ if self.left_player:
|
|
|
|
|
+ self.left_player.set_target_size(self.left_label.width(), self.left_label.height())
|
|
|
|
|
+ if self.right_player:
|
|
|
|
|
+ self.right_player.set_target_size(self.right_label.width(), self.right_label.height())
|
|
|
|
|
+
|
|
|
|
|
+ def resizeEvent(self, event: QResizeEvent):
|
|
|
|
|
+ self.update_video_size()
|
|
|
|
|
+ self.update_players_target_size()
|
|
|
|
|
+ super().resizeEvent(event)
|
|
|
|
|
+
|
|
|
|
|
+ def keyPressEvent(self, event: QKeyEvent):
|
|
|
|
|
+ if event.key() == Qt.Key_Escape and self.isFullScreen():
|
|
|
|
|
+ self.showNormal()
|
|
|
|
|
+ elif event.key() == Qt.Key_F11:
|
|
|
|
|
+ if self.isFullScreen():
|
|
|
|
|
+ self.showNormal()
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.showFullScreen()
|
|
|
|
|
+ else:
|
|
|
|
|
+ super().keyPressEvent(event)
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 加载动画和超时 ----------
|
|
|
|
|
+ def show_loading_on_label(self, label: QLabel):
|
|
|
|
|
+ label.setText("加载中...")
|
|
|
|
|
+ label.setAlignment(Qt.AlignCenter)
|
|
|
|
|
+ label.setStyleSheet(label.styleSheet() + "color: white; font-size: 20px;")
|
|
|
|
|
+
|
|
|
|
|
+ def hide_loading_on_label(self, label: QLabel):
|
|
|
|
|
+ label.setText("")
|
|
|
|
|
+ label.setStyleSheet("""
|
|
|
|
|
+ QLabel {
|
|
|
|
|
+ border: 2px solid #dcdfe6;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background-color: black;
|
|
|
|
|
+ }
|
|
|
|
|
+ """)
|
|
|
|
|
+
|
|
|
|
|
+ def show_timeout_on_label(self, label: QLabel):
|
|
|
|
|
+ label.setText("连接超时")
|
|
|
|
|
+ label.setAlignment(Qt.AlignCenter)
|
|
|
|
|
+ label.setStyleSheet(label.styleSheet() + "color: red; font-size: 20px;")
|
|
|
|
|
+
|
|
|
|
|
+ def on_player_timeout(self, label: QLabel, player_ref, side: str):
|
|
|
|
|
+ self.show_timeout_on_label(label)
|
|
|
|
|
+ if side == "left" and self.left_player:
|
|
|
|
|
+ self.left_player.stop()
|
|
|
|
|
+ self.left_player = None
|
|
|
|
|
+ elif side == "right" and self.right_player:
|
|
|
|
|
+ self.right_player.stop()
|
|
|
|
|
+ self.right_player = None
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 点击工位 ----------
|
|
|
|
|
+ def on_bucket_clicked(self, bucket: BucketModel):
|
|
|
|
|
+ if self.switch_mode != SwitchMode.HAND:
|
|
|
|
|
+ self.switch_mode = SwitchMode.HAND
|
|
|
|
|
+ self.auto_switch_btn.setChecked(False)
|
|
|
|
|
+ self.auto_switch_btn.setText("手动模式")
|
|
|
|
|
+ self.activate_bucket(bucket)
|
|
|
|
|
+
|
|
|
|
|
+ def on_rfid_state_changed(self, online: bool):
|
|
|
|
|
+ if online:
|
|
|
|
|
+ self.module_status.setText("通讯状态: RFID正常")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.module_status.setText("通讯状态: RFID离线")
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 硬件初始化 ----------
|
|
|
|
|
+ def init_hardware(self):
|
|
|
|
|
+ if self.config.module_type == ModuleType.RFID and self.config.rfid:
|
|
|
|
|
+ self.rfid_client = RFIDClient(
|
|
|
|
|
+ self.config.rfid,
|
|
|
|
|
+ self.on_rfid_tag,
|
|
|
|
|
+ on_state_changed=self.on_rfid_state_changed,
|
|
|
|
|
+ tag_offset=18,
|
|
|
|
|
+ tag_length=4,
|
|
|
|
|
+ hex_output=True
|
|
|
|
|
+ )
|
|
|
|
|
+ if self.rfid_client.connect():
|
|
|
|
|
+ self.module_status.setText("通讯状态: RFID正常")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.module_status.setText("通讯状态: RFID离线")
|
|
|
|
|
+
|
|
|
|
|
+ if self.config.module_type == ModuleType.HD3200 and self.config.modbus:
|
|
|
|
|
+ self.modbus_client = ModbusClient(self.config.modbus, self.on_modbus_data)
|
|
|
|
|
+ if self.modbus_client.connect():
|
|
|
|
|
+ self.module_status.setText("通讯状态: Modbus正常")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.module_status.setText("通讯状态: Modbus离线")
|
|
|
|
|
+ if self.config.ai.enabled:
|
|
|
|
|
+ self.mqtt_client = MQTTClient(self.config.ai, self.on_mqtt_message)
|
|
|
|
|
+ self.mqtt_client.connect()
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 激活工位 ----------
|
|
|
|
|
+ def activate_bucket(self, bucket: BucketModel):
|
|
|
|
|
+ if self.active_bucket == bucket:
|
|
|
|
|
+ return
|
|
|
|
|
+ if self.left_player:
|
|
|
|
|
+ self.left_player.stop()
|
|
|
|
|
+ self.left_player = None
|
|
|
|
|
+ if self.right_player:
|
|
|
|
|
+ self.right_player.stop()
|
|
|
|
|
+ self.right_player = None
|
|
|
|
|
+ if self.active_bucket:
|
|
|
|
|
+ self.active_bucket.is_active = False
|
|
|
|
|
+ self.active_bucket = bucket
|
|
|
|
|
+ if bucket:
|
|
|
|
|
+ bucket.is_active = True
|
|
|
|
|
+ self.refresh_bucket_icons()
|
|
|
|
|
+
|
|
|
|
|
+ self.show_loading_on_label(self.left_label)
|
|
|
|
|
+ self.show_loading_on_label(self.right_label)
|
|
|
|
|
+
|
|
|
|
|
+ # 重置挂钩状态
|
|
|
|
|
+ self.hook_state = HookState.IDLE
|
|
|
|
|
+ self.prev_state = HookState.IDLE
|
|
|
|
|
+ self.state_counter = 0
|
|
|
|
|
+ self.left_objects = []
|
|
|
|
|
+ self.right_objects = []
|
|
|
|
|
+ self.prev_left_hook = None
|
|
|
|
|
+ self.prev_right_hook = None
|
|
|
|
|
+ self.prev_left_lug = None
|
|
|
|
|
+ self.prev_right_lug = None
|
|
|
|
|
+ self.last_voice_state = None
|
|
|
|
|
+
|
|
|
|
|
+ if bucket:
|
|
|
|
|
+ left_cam = next((c for c in bucket.cameras if c.position == CameraPosition.LEFT), None)
|
|
|
|
|
+ right_cam = next((c for c in bucket.cameras if c.position == CameraPosition.RIGHT), None)
|
|
|
|
|
+
|
|
|
|
|
+ if left_cam:
|
|
|
|
|
+ w = self.left_label.width()
|
|
|
|
|
+ h = self.left_label.height()
|
|
|
|
|
+ timeout = self.config.video.rtsp_timeout
|
|
|
|
|
+ player = RTSPPlayer(left_cam.rtsp_url, w, h, timeout_sec=timeout)
|
|
|
|
|
+ player.frame_ready.connect(self.left_label.setPixmap)
|
|
|
|
|
+ player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.left_label))
|
|
|
|
|
+ player.timeout.connect(lambda: self.on_player_timeout(self.left_label, player, "left"))
|
|
|
|
|
+ self.left_player = player
|
|
|
|
|
+ player.start()
|
|
|
|
|
+
|
|
|
|
|
+ if right_cam:
|
|
|
|
|
+ w = self.right_label.width()
|
|
|
|
|
+ h = self.right_label.height()
|
|
|
|
|
+ timeout = self.config.video.rtsp_timeout
|
|
|
|
|
+ player = RTSPPlayer(right_cam.rtsp_url, w, h, timeout_sec=timeout)
|
|
|
|
|
+ player.frame_ready.connect(self.right_label.setPixmap)
|
|
|
|
|
+ player.first_frame_received.connect(lambda: self.hide_loading_on_label(self.right_label))
|
|
|
|
|
+ player.timeout.connect(lambda: self.on_player_timeout(self.right_label, player, "right"))
|
|
|
|
|
+ self.right_player = player
|
|
|
|
|
+ player.start()
|
|
|
|
|
+
|
|
|
|
|
+ if self.mqtt_client:
|
|
|
|
|
+ topics = [c.topic for c in bucket.cameras if c.topic]
|
|
|
|
|
+ if topics:
|
|
|
|
|
+ self.mqtt_client.subscribe(topics)
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.left_label.clear()
|
|
|
|
|
+ self.right_label.clear()
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- RFID 标签回调 ----------
|
|
|
|
|
+ def on_rfid_tag(self, tag: str):
|
|
|
|
|
+ if tag:
|
|
|
|
|
+ self.rfid_tag_label.setText(f"当前卡号: {tag}")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.rfid_tag_label.setText("当前卡号: 无")
|
|
|
|
|
+
|
|
|
|
|
+ if tag is None:
|
|
|
|
|
+ if self.switch_mode == SwitchMode.AUTO and self.active_bucket:
|
|
|
|
|
+ self.activate_bucket(None)
|
|
|
|
|
+ return
|
|
|
|
|
+ for bucket in self.buckets:
|
|
|
|
|
+ if bucket.code_x == tag:
|
|
|
|
|
+ if self.switch_mode == SwitchMode.AUTO:
|
|
|
|
|
+ self.activate_bucket(bucket)
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- Modbus 数据回调 ----------
|
|
|
|
|
+ def on_modbus_data(self, bools):
|
|
|
|
|
+ for bucket in self.buckets:
|
|
|
|
|
+ try:
|
|
|
|
|
+ addr = int(bucket.code_x)
|
|
|
|
|
+ except:
|
|
|
|
|
+ addr = -1
|
|
|
|
|
+ if 0 <= addr < len(bools) and bools[addr]:
|
|
|
|
|
+ if self.switch_mode == SwitchMode.AUTO:
|
|
|
|
|
+ self.activate_bucket(bucket)
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- MQTT 消息处理 ----------
|
|
|
|
|
+ def parse_ai_objects(self, payload: dict, display_width: int, display_height: int,
|
|
|
|
|
+ include_ready: bool = True) -> list:
|
|
|
|
|
+ if payload is None or not isinstance(payload, dict):
|
|
|
|
|
+ return []
|
|
|
|
|
+ orig_width = payload.get("width", 640)
|
|
|
|
|
+ orig_height = payload.get("height", 360)
|
|
|
|
|
+ scale_x = display_width / orig_width if orig_width > 0 else 1.0
|
|
|
|
|
+ scale_y = display_height / orig_height if orig_height > 0 else 1.0
|
|
|
|
|
+ objects = []
|
|
|
|
|
+ for obj_str in payload.get("objects", []):
|
|
|
|
|
+ parts = obj_str.split('|')
|
|
|
|
|
+ if len(parts) >= 6:
|
|
|
|
|
+ try:
|
|
|
|
|
+ x = int(float(parts[1]) * scale_x)
|
|
|
|
|
+ y = int(float(parts[2]) * scale_y)
|
|
|
|
|
+ w = int(float(parts[3]) * scale_x)
|
|
|
|
|
+ h = int(float(parts[4]) * scale_y)
|
|
|
|
|
+ raw_label = parts[5]
|
|
|
|
|
+ # 类别映射(hoist→hook, cup→lug)
|
|
|
|
|
+ label = raw_label
|
|
|
|
|
+ if 'hoist' in raw_label:
|
|
|
|
|
+ label = 'hook'
|
|
|
|
|
+ elif 'cup' in raw_label:
|
|
|
|
|
+ label = 'lug'
|
|
|
|
|
+ # 颜色分配(BGR)
|
|
|
|
|
+ if 'hook' in label:
|
|
|
|
|
+ color = (0, 0, 255) # 红色
|
|
|
|
|
+ elif 'lug' in label:
|
|
|
|
|
+ color = (255, 0, 0) # 蓝色
|
|
|
|
|
+ elif 'ready' in label:
|
|
|
|
|
+ if not include_ready:
|
|
|
|
|
+ continue
|
|
|
|
|
+ color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部)
|
|
|
|
|
+ else:
|
|
|
|
|
+ color = (128, 128, 128) # 灰色(其他类别)
|
|
|
|
|
+ objects.append({
|
|
|
|
|
+ "x": x,
|
|
|
|
|
+ "y": y,
|
|
|
|
|
+ "w": w,
|
|
|
|
|
+ "h": h,
|
|
|
|
|
+ "label": label,
|
|
|
|
|
+ "raw_label": raw_label,
|
|
|
|
|
+ "color": color
|
|
|
|
|
+ })
|
|
|
|
|
+ except (ValueError, IndexError):
|
|
|
|
|
+ continue
|
|
|
|
|
+ return objects
|
|
|
|
|
+
|
|
|
|
|
+ def on_mqtt_message(self, topic: str, payload: dict):
|
|
|
|
|
+ if payload is None or not isinstance(payload, dict):
|
|
|
|
|
+ return
|
|
|
|
|
+ if not self.active_bucket:
|
|
|
|
|
+ return
|
|
|
|
|
+ for cam in self.active_bucket.cameras:
|
|
|
|
|
+ if cam.topic == topic:
|
|
|
|
|
+ self.update_video_size()
|
|
|
|
|
+ if cam.position == CameraPosition.LEFT:
|
|
|
|
|
+ display_width = self.left_label.width()
|
|
|
|
|
+ display_height = self.left_label.height()
|
|
|
|
|
+ else:
|
|
|
|
|
+ display_width = self.right_label.width()
|
|
|
|
|
+ display_height = self.right_label.height()
|
|
|
|
|
+ if display_width == 0 or display_height == 0:
|
|
|
|
|
+ display_width = 640
|
|
|
|
|
+ display_height = 360
|
|
|
|
|
+
|
|
|
|
|
+ # 解析所有对象(包括 ready)用于状态判断
|
|
|
|
|
+ all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
|
|
|
|
|
+ # 解析仅用于绘制的对象(排除 ready)
|
|
|
|
|
+ draw_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=False)
|
|
|
|
|
+
|
|
|
|
|
+ if cam.position == CameraPosition.LEFT:
|
|
|
|
|
+ self.left_objects = all_objects
|
|
|
|
|
+ if self.left_player:
|
|
|
|
|
+ self.left_player.set_ai_objects(draw_objects)
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.right_objects = all_objects
|
|
|
|
|
+ if self.right_player:
|
|
|
|
|
+ self.right_player.set_ai_objects(draw_objects)
|
|
|
|
|
+
|
|
|
|
|
+ # 触发状态评估
|
|
|
|
|
+ self.evaluate_hook_state()
|
|
|
|
|
+ break
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 挂钩状态评估 ----------
|
|
|
|
|
+ def evaluate_hook_state(self):
|
|
|
|
|
+ left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
|
|
|
|
|
+ left_lugs = [o for o in self.left_objects if 'lug' in o['label'].lower()]
|
|
|
|
|
+ right_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
|
|
|
|
|
+ right_lugs = [o for o in self.right_objects if 'lug' in o['label'].lower()]
|
|
|
|
|
+
|
|
|
|
|
+ def find_nearest_pair(hooks, lugs):
|
|
|
|
|
+ if not hooks or not lugs:
|
|
|
|
|
+ return None
|
|
|
|
|
+ best_pair = None
|
|
|
|
|
+ best_dist = float('inf')
|
|
|
|
|
+ for h in hooks:
|
|
|
|
|
+ hx = h['x'] + h['w'] / 2
|
|
|
|
|
+ hy = h['y'] + h['h'] / 2
|
|
|
|
|
+ for l in lugs:
|
|
|
|
|
+ lx = l['x'] + l['w'] / 2
|
|
|
|
|
+ ly = l['y'] + l['h'] / 2
|
|
|
|
|
+ dist = (hx - lx) ** 2 + (hy - ly) ** 2
|
|
|
|
|
+ if dist < best_dist:
|
|
|
|
|
+ best_dist = dist
|
|
|
|
|
+ best_pair = (h, l)
|
|
|
|
|
+ return best_pair
|
|
|
|
|
+
|
|
|
|
|
+ left_pair = find_nearest_pair(left_hooks, left_lugs)
|
|
|
|
|
+ right_pair = find_nearest_pair(right_hooks, right_lugs)
|
|
|
|
|
+
|
|
|
|
|
+ is_releasing = False
|
|
|
|
|
+
|
|
|
|
|
+ # 处理左侧配对
|
|
|
|
|
+ if left_pair:
|
|
|
|
|
+ h, l = left_pair
|
|
|
|
|
+ if self.prev_left_hook and self.prev_left_lug:
|
|
|
|
|
+ dy_h = (h['y'] + h['h'] / 2) - self.prev_left_hook[1]
|
|
|
|
|
+ dy_l = (l['y'] + l['h'] / 2) - self.prev_left_lug[1]
|
|
|
|
|
+ if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
|
|
|
|
|
+ is_releasing = True
|
|
|
|
|
+ self.prev_left_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
|
|
|
|
|
+ self.prev_left_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.prev_left_hook = None
|
|
|
|
|
+ self.prev_left_lug = None
|
|
|
|
|
+
|
|
|
|
|
+ # 处理右侧配对
|
|
|
|
|
+ if right_pair:
|
|
|
|
|
+ h, l = right_pair
|
|
|
|
|
+ if self.prev_right_hook and self.prev_right_lug:
|
|
|
|
|
+ dy_h = (h['y'] + h['h'] / 2) - self.prev_right_hook[1]
|
|
|
|
|
+ dy_l = (l['y'] + l['h'] / 2) - self.prev_right_lug[1]
|
|
|
|
|
+ if dy_h > self.RELEASE_DELTA_THRESHOLD and dy_l > self.RELEASE_DELTA_THRESHOLD:
|
|
|
|
|
+ is_releasing = True
|
|
|
|
|
+ self.prev_right_hook = (h['x'] + h['w'] / 2, h['y'] + h['h'] / 2)
|
|
|
|
|
+ self.prev_right_lug = (l['x'] + l['w'] / 2, l['y'] + l['h'] / 2)
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.prev_right_hook = None
|
|
|
|
|
+ self.prev_right_lug = None
|
|
|
|
|
+
|
|
|
|
|
+ # 状态判断
|
|
|
|
|
+ if is_releasing:
|
|
|
|
|
+ new_state = HookState.RELEASING
|
|
|
|
|
+ else:
|
|
|
|
|
+ any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
|
|
|
|
|
+ if any_ready:
|
|
|
|
|
+ new_state = HookState.READY
|
|
|
|
|
+ else:
|
|
|
|
|
+ intersecting = False
|
|
|
|
|
+ if left_pair:
|
|
|
|
|
+ h, l = left_pair
|
|
|
|
|
+ if self.rects_intersect(h, l):
|
|
|
|
|
+ intersecting = True
|
|
|
|
|
+ if not intersecting and right_pair:
|
|
|
|
|
+ h, l = right_pair
|
|
|
|
|
+ if self.rects_intersect(h, l):
|
|
|
|
|
+ intersecting = True
|
|
|
|
|
+ if intersecting:
|
|
|
|
|
+ new_state = HookState.HOOKING
|
|
|
|
|
+ else:
|
|
|
|
|
+ new_state = HookState.IDLE
|
|
|
|
|
+
|
|
|
|
|
+ # 防抖
|
|
|
|
|
+ if new_state == self.prev_state:
|
|
|
|
|
+ self.state_counter += 1
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.prev_state = new_state
|
|
|
|
|
+ self.state_counter = 1
|
|
|
|
|
+
|
|
|
|
|
+ if self.state_counter >= self.STATE_THRESHOLD:
|
|
|
|
|
+ if new_state != self.hook_state:
|
|
|
|
|
+ self.hook_state = new_state
|
|
|
|
|
+ self.update_ui_for_state(new_state)
|
|
|
|
|
+ self.state_counter = 0
|
|
|
|
|
+
|
|
|
|
|
+ def rects_intersect(self, a, b):
|
|
|
|
|
+ ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
|
|
|
|
|
+ bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
|
|
|
|
|
+ return not (ax2 < bx1 or bx2 < ax1 or ay2 < by1 or by2 < ay1)
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- UI 更新与语音 ----------
|
|
|
|
|
+ def update_ui_for_state(self, state):
|
|
|
|
|
+ if state == HookState.IDLE:
|
|
|
|
|
+ border_color = "#dcdfe6"
|
|
|
|
|
+ text = "空闲"
|
|
|
|
|
+ elif state == HookState.RELEASING:
|
|
|
|
|
+ border_color = "#ffa500"
|
|
|
|
|
+ text = "放包中"
|
|
|
|
|
+ elif state == HookState.HOOKING:
|
|
|
|
|
+ border_color = "#ff0000"
|
|
|
|
|
+ text = "挂包中"
|
|
|
|
|
+ elif state == HookState.READY:
|
|
|
|
|
+ border_color = "#00ff00"
|
|
|
|
|
+ text = "挂包成功"
|
|
|
|
|
+ else:
|
|
|
|
|
+ border_color = "#dcdfe6"
|
|
|
|
|
+ text = ""
|
|
|
|
|
+
|
|
|
|
|
+ # 更新状态栏标签
|
|
|
|
|
+ self.hook_status_label.setText(f"挂钩状态: {text}")
|
|
|
|
|
+
|
|
|
|
|
+ # 更新视频边框
|
|
|
|
|
+ style = f"""
|
|
|
|
|
+ QLabel {{
|
|
|
|
|
+ border: 4px solid {border_color};
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background-color: black;
|
|
|
|
|
+ }}
|
|
|
|
|
+ """
|
|
|
|
|
+ self.left_label.setStyleSheet(style)
|
|
|
|
|
+ self.right_label.setStyleSheet(style)
|
|
|
|
|
+
|
|
|
|
|
+ # 语音播报(带开关和防重复)
|
|
|
|
|
+ if self.voice_enabled and state in (HookState.HOOKING, HookState.READY) and state != self.last_voice_state:
|
|
|
|
|
+ msg = "挂包中" if state == HookState.HOOKING else "挂包成功"
|
|
|
|
|
+ def speak():
|
|
|
|
|
+ self.tts_engine.say(msg)
|
|
|
|
|
+ self.tts_engine.runAndWait()
|
|
|
|
|
+ threading.Thread(target=speak, daemon=True).start()
|
|
|
|
|
+ self.last_voice_state = state
|
|
|
|
|
+
|
|
|
|
|
+ # ---------- 定时任务 ----------
|
|
|
|
|
+ def check_idle(self):
|
|
|
|
|
+ if self.rfid_client:
|
|
|
|
|
+ self.rfid_client.check_idle()
|
|
|
|
|
+
|
|
|
|
|
+ def toggle_switch_mode(self, checked):
|
|
|
|
|
+ if checked:
|
|
|
|
|
+ self.switch_mode = SwitchMode.AUTO
|
|
|
|
|
+ self.auto_switch_btn.setText("自动模式")
|
|
|
|
|
+ else:
|
|
|
|
|
+ self.switch_mode = SwitchMode.HAND
|
|
|
|
|
+ self.auto_switch_btn.setText("手动模式")
|