|
|
@@ -0,0 +1,2156 @@
|
|
|
+import os
|
|
|
+import sys
|
|
|
+import time
|
|
|
+import re
|
|
|
+import logging
|
|
|
+import configparser
|
|
|
+import threading
|
|
|
+import queue
|
|
|
+import subprocess
|
|
|
+from concurrent.futures import ThreadPoolExecutor
|
|
|
+from collections import defaultdict, deque
|
|
|
+from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
|
+ QPushButton, QLabel, QGridLayout, QScrollArea,
|
|
|
+ QToolButton, QFrame, QApplication, QCheckBox,
|
|
|
+ QMessageBox, QSlider, QDialog, QDialogButtonBox)
|
|
|
+from PySide6.QtCore import Qt, QTimer, QSize, Signal
|
|
|
+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
|
|
|
+from .auto_restart import AutoRestartScheduler
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+class MainWindow(QMainWindow):
|
|
|
+ # 防抖阈值(连续相同状态次数)
|
|
|
+ STATE_THRESHOLD = 3
|
|
|
+ # 放包判断位移阈值(像素)
|
|
|
+ RELEASE_DELTA_THRESHOLD = 5
|
|
|
+ # 轨迹阶段判断阈值(像素)
|
|
|
+ RELEASE_TRAJ_Y = 8
|
|
|
+ SEPARATE_TRAJ_DIST = 15
|
|
|
+ ENGAGE_DIST = 40
|
|
|
+ # 命中放包/分离后屏蔽语音的顺延秒数
|
|
|
+ SUPPRESS_SECONDS = 8
|
|
|
+ # 轨迹窗口:保留最近 2 秒 / 最多 30 帧
|
|
|
+ TRAJ_WINDOW_SECS = 2.0
|
|
|
+ TRAJ_MAX_FRAMES = 30
|
|
|
+ # MQTT 消息超时(秒)后清除残留画框
|
|
|
+ MQTT_CLEAR_TIMEOUT = 1
|
|
|
+ # 标定采样时长(秒),期间对每帧有效配对取平均
|
|
|
+ CALIB_DURATION_SECS = 1.0
|
|
|
+ # 自动标定参数见 [AI] 配置:auto_calib_ready_frames / auto_calib_duration_secs / auto_calib_min_samples
|
|
|
+ APPTITLE = "HookVision"
|
|
|
+
|
|
|
+ # 跨线程信号
|
|
|
+ rfid_tag_signal = Signal(object)
|
|
|
+ rfid_state_signal = Signal(object)
|
|
|
+ modbus_data_signal = Signal(object)
|
|
|
+ mqtt_message_signal = Signal(str, object)
|
|
|
+ voice_signal = Signal(str)
|
|
|
+ camera_signal_signal = Signal(dict)
|
|
|
+ restart_trigger_signal = Signal()
|
|
|
+
|
|
|
+ def __init__(self, config: Config, config_path: str = ""):
|
|
|
+ super().__init__()
|
|
|
+ self.config = config
|
|
|
+ self._config_path = config_path
|
|
|
+ self.buckets = config.buckets
|
|
|
+ self.active_bucket: BucketModel = None
|
|
|
+ self.switch_mode = SwitchMode.AUTO
|
|
|
+ self._subscribed_topics: set = set()
|
|
|
+ self._current_ai_module_id: Optional[int] = None
|
|
|
+
|
|
|
+ self.left_player: RTSPPlayer = None
|
|
|
+ self.right_player: RTSPPlayer = None
|
|
|
+
|
|
|
+ self.rfid_client = None
|
|
|
+ self.modbus_client = None
|
|
|
+ self.mqtt_client = None
|
|
|
+ self._last_rfid_tag: Optional[str] = None
|
|
|
+
|
|
|
+ self.video_aspect_ratio = 16 / 9
|
|
|
+ self._ratio_16_9 = config.video.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
|
|
|
+
|
|
|
+ # 配对轨迹(每侧:{t, hook_cx, hook_cy, lug_cx, lug_cy, dist}),用于放包/分离检测与画线
|
|
|
+ self._pair_traj: dict = {"left": [], "right": []}
|
|
|
+ # 放包/分离期间屏蔽 挂包中/挂包成功 语音的截止时间
|
|
|
+ self._voice_suppress_until: float = 0.0
|
|
|
+ # 当前叠加显示的阶段(放包/分离/空)
|
|
|
+ self._trace_phase: str = ""
|
|
|
+ # 未标定侧告警去重(side → 已告警)
|
|
|
+ self._calib_warned: set = set()
|
|
|
+ self.debug_trace = getattr(config.ai, 'debug_trace', True)
|
|
|
+ # MQTT 消息超时后是否已清除画框(防重复清空)
|
|
|
+ self._overlay_cleared = False
|
|
|
+
|
|
|
+ # 标定采集状态:点击标定后连续采样约 CALIB_DURATION_SECS 秒取平均
|
|
|
+ self._calib_active = False
|
|
|
+ self._calib_until: float = 0.0
|
|
|
+ self._calib_samples: dict = {} # side → [sum_xr, sum_yr, count]
|
|
|
+ self._calib_badge_labels: dict = {} # bucket.id → QLabel
|
|
|
+
|
|
|
+ # 自动标定(窗口内 ready 帧数达标):未标定侧自动标定,已标定侧不覆盖
|
|
|
+ self._auto_calib_state: dict = { # side → 窗口计数状态
|
|
|
+ side: {"ready_times": deque(), "samples": deque()}
|
|
|
+ for side in ("left", "right")
|
|
|
+ }
|
|
|
+ self._side_ready: dict = {"left": False, "right": False}
|
|
|
+
|
|
|
+ # 语音引擎(在工作线程内初始化)
|
|
|
+ self.last_voice_state = None
|
|
|
+ self._last_hooking_voice_time = 0.0
|
|
|
+ self.voice_enabled = True
|
|
|
+ self._voice_volume = 100
|
|
|
+ self.ai_enabled = True
|
|
|
+ self._last_left_draw: list = []
|
|
|
+ self._last_right_draw: list = []
|
|
|
+ self._voice_queue = queue.Queue(maxsize=32)
|
|
|
+ self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
|
|
|
+ self._voice_thread.start()
|
|
|
+ self._voices_dir = self._get_voices_dir()
|
|
|
+ self._use_tts = self.config.ai.tts_enabled
|
|
|
+
|
|
|
+ # RTSP 退避重连
|
|
|
+ self._rtsp_retry_left: Optional[QTimer] = None
|
|
|
+ self._rtsp_retry_right: Optional[QTimer] = None
|
|
|
+ self._rtsp_retry_delay_left: int = 1
|
|
|
+ self._rtsp_retry_delay_right: int = 1
|
|
|
+ self._last_mqtt_connect_attempt: float = 0.0
|
|
|
+ self._last_mqtt_process: float = 0.0
|
|
|
+
|
|
|
+ # 摄像头信号监控
|
|
|
+ self._camera_signal: dict = {} # ip → 延迟(ms), -1=不可达
|
|
|
+ self._bucket_signal_labels: dict = {} # bucket.id → QLabel
|
|
|
+ self._last_signal_check: float = 0.0
|
|
|
+ self._signal_check_thread: threading.Thread = None
|
|
|
+ self._offline_reported: set = set() # 已播报离线的 IP
|
|
|
+
|
|
|
+ # 连接跨线程信号到主线程槽
|
|
|
+ self.rfid_tag_signal.connect(self._on_rfid_tag_impl)
|
|
|
+ self.rfid_state_signal.connect(self._on_rfid_state_impl)
|
|
|
+ self.modbus_data_signal.connect(self._on_modbus_data_impl)
|
|
|
+ self.mqtt_message_signal.connect(self._on_mqtt_message_impl)
|
|
|
+ self.voice_signal.connect(self._speak_impl)
|
|
|
+ self.camera_signal_signal.connect(self._on_camera_signal)
|
|
|
+ self.restart_trigger_signal.connect(self._on_auto_restart_impl)
|
|
|
+
|
|
|
+ self.setWindowTitle(config.app_name)
|
|
|
+ logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
|
|
|
+ if os.path.exists(logo_path):
|
|
|
+ self.setWindowIcon(QIcon(logo_path))
|
|
|
+
|
|
|
+ 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._started = True
|
|
|
+
|
|
|
+ self.timer = QTimer()
|
|
|
+ self.timer.timeout.connect(self.check_idle)
|
|
|
+ self.timer.start(1000)
|
|
|
+
|
|
|
+ self.auto_restart_scheduler = AutoRestartScheduler(config.auto_restart)
|
|
|
+ self.auto_restart_scheduler.on_restart_triggered = self._on_auto_restart_triggered
|
|
|
+ self.auto_restart_scheduler.start()
|
|
|
+
|
|
|
+ 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)
|
|
|
+ logo_path = os.path.join(os.path.dirname(__file__), "..", "assets", "logo.png")
|
|
|
+ if os.path.exists(logo_path):
|
|
|
+ logo_label = QLabel()
|
|
|
+ logo_pixmap = QPixmap(logo_path).scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
|
+ logo_label.setPixmap(logo_pixmap)
|
|
|
+ logo_label.setFixedSize(28, 28)
|
|
|
+ title_layout.addWidget(logo_label)
|
|
|
+ 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.fullscreen_btn = QPushButton("⛶")
|
|
|
+ self.fullscreen_btn.setStyleSheet(btn_style)
|
|
|
+ self.fullscreen_btn.setToolTip("全屏 / 还原")
|
|
|
+ self.fullscreen_btn.clicked.connect(self._toggle_fullscreen)
|
|
|
+ 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.fullscreen_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)
|
|
|
+
|
|
|
+ # 按 code_x 分组纵向排列
|
|
|
+ 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):
|
|
|
+ cell = QWidget()
|
|
|
+ cell_layout = QVBoxLayout(cell)
|
|
|
+ cell_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
+ cell_layout.setSpacing(2)
|
|
|
+
|
|
|
+ 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))
|
|
|
+ cell_layout.addWidget(btn, 0, Qt.AlignHCenter)
|
|
|
+
|
|
|
+ # 底部状态条三等分:左摄像头信号 | 右摄像头信号 | 标定状态(与图标同宽)
|
|
|
+ bar = QWidget()
|
|
|
+ bar.setFixedSize(100, 8)
|
|
|
+ bar_layout = QHBoxLayout(bar)
|
|
|
+ bar_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
+ bar_layout.setSpacing(2)
|
|
|
+ left_bar = QFrame()
|
|
|
+ right_bar = QFrame()
|
|
|
+ calib_bar = QFrame()
|
|
|
+ for fb in (left_bar, right_bar, calib_bar):
|
|
|
+ fb.setFixedHeight(8)
|
|
|
+ fb.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
|
|
|
+ bar_layout.addWidget(left_bar, 1)
|
|
|
+ bar_layout.addWidget(right_bar, 1)
|
|
|
+ bar_layout.addWidget(calib_bar, 1)
|
|
|
+ cell_layout.addWidget(bar, 0, Qt.AlignHCenter)
|
|
|
+ self._bucket_signal_labels[bucket.id] = (left_bar, right_bar)
|
|
|
+ self._calib_badge_labels[bucket.id] = calib_bar
|
|
|
+
|
|
|
+ self.bucket_grid.addWidget(cell, 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, 15, 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.tts_checkbox = QCheckBox("TTS语音")
|
|
|
+ self.tts_checkbox.setChecked(self._use_tts)
|
|
|
+ self.tts_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
|
|
|
+ self.tts_checkbox.toggled.connect(self._on_tts_toggle)
|
|
|
+
|
|
|
+ self.volume_btn = QPushButton(f"音量 {self._voice_volume}%")
|
|
|
+ self.volume_btn.setFixedWidth(80)
|
|
|
+ self.volume_btn.setStyleSheet("""
|
|
|
+ QPushButton {
|
|
|
+ color: #606266; font-weight: bold; font-size: 12px;
|
|
|
+ border: 1px solid #dcdfe6; border-radius: 4px;
|
|
|
+ padding: 2px 6px; background: transparent;
|
|
|
+ }
|
|
|
+ QPushButton:hover { border-color: #409eff; color: #409eff; }
|
|
|
+ """)
|
|
|
+ self.volume_btn.clicked.connect(self._show_volume_popup)
|
|
|
+
|
|
|
+ self.ai_checkbox = QCheckBox("AI启用")
|
|
|
+ self.ai_checkbox.setChecked(True)
|
|
|
+ self.ai_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
|
|
|
+ self.ai_checkbox.stateChanged.connect(self.on_ai_toggle)
|
|
|
+
|
|
|
+ self.auto_switch_btn = QCheckBox("自动模式")
|
|
|
+ self.auto_switch_btn.setChecked(True)
|
|
|
+ self.auto_switch_btn.setStyleSheet("color: #606266; font-weight: bold;")
|
|
|
+ self.auto_switch_btn.clicked.connect(self.toggle_switch_mode)
|
|
|
+
|
|
|
+ self.autostart_checkbox = QCheckBox("开机自启")
|
|
|
+ self.autostart_checkbox.setChecked(self._read_autostart_flag())
|
|
|
+ self.autostart_checkbox.setStyleSheet("color: #606266; font-weight: bold; margin-left: 10px;")
|
|
|
+ self.autostart_checkbox.stateChanged.connect(self._on_autostart_toggle)
|
|
|
+
|
|
|
+ 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.tts_checkbox)
|
|
|
+ status_layout.addWidget(self.volume_btn)
|
|
|
+ status_layout.addWidget(self.ai_checkbox)
|
|
|
+ status_layout.addWidget(self.auto_switch_btn)
|
|
|
+ status_layout.addWidget(self.autostart_checkbox)
|
|
|
+ self.ratio_btn = QPushButton("16:9" if self._ratio_16_9 else "4:3")
|
|
|
+ self.ratio_btn.setStyleSheet("background:transparent; border:none; color:#606266; font-weight:bold; padding:4px 8px;")
|
|
|
+ self.ratio_btn.setToolTip("切换画面比例 16:9 / 4:3")
|
|
|
+ self.ratio_btn.clicked.connect(self._toggle_aspect_ratio)
|
|
|
+ status_layout.addWidget(self.ratio_btn)
|
|
|
+
|
|
|
+ self.calib_btn = QPushButton("标定")
|
|
|
+ self.calib_btn.setFixedWidth(72)
|
|
|
+ self.calib_btn.setStyleSheet("""
|
|
|
+ QPushButton {
|
|
|
+ color: #606266; font-weight: bold; font-size: 12px;
|
|
|
+ border: 1px solid #dcdfe6; border-radius: 4px;
|
|
|
+ padding: 2px 6px; background: transparent;
|
|
|
+ }
|
|
|
+ QPushButton:hover { border-color: #409eff; color: #409eff; }
|
|
|
+ """)
|
|
|
+ self.calib_btn.setToolTip("标定可以起吊的 hook/lug 偏移(当前工位,左右各采 1 秒取平均)")
|
|
|
+ self.calib_btn.clicked.connect(self.on_calibrate_clicked)
|
|
|
+ status_layout.addWidget(self.calib_btn)
|
|
|
+
|
|
|
+ self.reset_calib_btn = QPushButton("重置标定")
|
|
|
+ self.reset_calib_btn.setFixedWidth(80)
|
|
|
+ self.reset_calib_btn.setStyleSheet("""
|
|
|
+ QPushButton {
|
|
|
+ color: #f56c6c; font-weight: bold; font-size: 12px;
|
|
|
+ border: 1px solid #f56c6c; border-radius: 4px;
|
|
|
+ padding: 2px 6px; background: transparent;
|
|
|
+ }
|
|
|
+ QPushButton:hover { background-color: rgba(245, 108, 108, 0.1); }
|
|
|
+ """)
|
|
|
+ self.reset_calib_btn.setToolTip("清除当前工位的标定数据,清除后该工位会在识别到 ready 时自动重新标定")
|
|
|
+ self.reset_calib_btn.clicked.connect(self.on_reset_calibrate_clicked)
|
|
|
+ status_layout.addWidget(self.reset_calib_btn)
|
|
|
+
|
|
|
+ main_layout.addWidget(status_widget)
|
|
|
+
|
|
|
+ self.update_video_size()
|
|
|
+
|
|
|
+ # ---------- 事件与资源管理 ----------
|
|
|
+ def closeEvent(self, event: QCloseEvent):
|
|
|
+ self._cancel_rtsp_retry("left")
|
|
|
+ self._cancel_rtsp_retry("right")
|
|
|
+ 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()
|
|
|
+ if hasattr(self, 'auto_restart_scheduler'):
|
|
|
+ self.auto_restart_scheduler.stop()
|
|
|
+ event.accept()
|
|
|
+
|
|
|
+ # ---------- 自动重启 ----------
|
|
|
+ def _on_auto_restart_triggered(self):
|
|
|
+ self.restart_trigger_signal.emit()
|
|
|
+
|
|
|
+ def _on_auto_restart_impl(self):
|
|
|
+ ar = self.config.auto_restart
|
|
|
+ target_text = "重启电脑" if ar.target.value == "device" else "重启软件"
|
|
|
+ dialog = QDialog(self)
|
|
|
+ dialog.setWindowTitle("自动重启")
|
|
|
+ dialog.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
|
|
+ dialog.setModal(True)
|
|
|
+ dialog.setMinimumWidth(300)
|
|
|
+ layout = QVBoxLayout(dialog)
|
|
|
+ label = QLabel(f"{ar.countdown} 秒后将自动{target_text}", dialog)
|
|
|
+ label.setAlignment(Qt.AlignCenter)
|
|
|
+ label.setStyleSheet("font-size: 14px; padding: 10px;")
|
|
|
+ layout.addWidget(label)
|
|
|
+ btn_layout = QHBoxLayout()
|
|
|
+ btn_now = QPushButton("立即重启")
|
|
|
+ btn_cancel = QPushButton("取消")
|
|
|
+ btn_layout.addWidget(btn_now)
|
|
|
+ btn_layout.addWidget(btn_cancel)
|
|
|
+ layout.addLayout(btn_layout)
|
|
|
+
|
|
|
+ remaining = ar.countdown
|
|
|
+
|
|
|
+ def update_label():
|
|
|
+ nonlocal remaining
|
|
|
+ remaining -= 1
|
|
|
+ if remaining <= 0:
|
|
|
+ timer.stop()
|
|
|
+ dialog.accept()
|
|
|
+ else:
|
|
|
+ label.setText(f"{remaining} 秒后将自动{target_text}")
|
|
|
+
|
|
|
+ timer = QTimer(dialog)
|
|
|
+ timer.timeout.connect(update_label)
|
|
|
+ timer.start(1000)
|
|
|
+
|
|
|
+ btn_now.clicked.connect(dialog.accept)
|
|
|
+ btn_cancel.clicked.connect(dialog.reject)
|
|
|
+
|
|
|
+ result = dialog.exec()
|
|
|
+ timer.stop()
|
|
|
+ if result == QDialog.Accepted:
|
|
|
+ self.auto_restart_scheduler.perform_restart()
|
|
|
+ else:
|
|
|
+ logger.info("自动重启已取消")
|
|
|
+
|
|
|
+ # ---------- 语音控制 ----------
|
|
|
+ def on_voice_toggle(self, state):
|
|
|
+ self.voice_enabled = (state == Qt.Checked)
|
|
|
+
|
|
|
+ def _on_tts_toggle(self):
|
|
|
+ self._use_tts = self.tts_checkbox.isChecked()
|
|
|
+ self.config.ai.tts_enabled = self._use_tts
|
|
|
+ try:
|
|
|
+ self._save_config()
|
|
|
+ except Exception as e:
|
|
|
+ logger.warning(f"保存 TTS 设置失败: {e}")
|
|
|
+
|
|
|
+ def _on_volume_changed(self, value: int):
|
|
|
+ self._voice_volume = value
|
|
|
+ self.volume_btn.setText(f"音量 {value}%")
|
|
|
+
|
|
|
+ def _preview_volume(self, vol: int):
|
|
|
+ self._voice_volume = vol
|
|
|
+ self.volume_btn.setText(f"音量 {vol}%")
|
|
|
+ if not hasattr(self, '_preview_timer'):
|
|
|
+ self._preview_timer = QTimer()
|
|
|
+ self._preview_timer.setSingleShot(True)
|
|
|
+ self._preview_timer.timeout.connect(lambda: self._speak("挂包中"))
|
|
|
+ self._preview_timer.start(300)
|
|
|
+
|
|
|
+ def _show_volume_popup(self):
|
|
|
+ from PySide6.QtWidgets import QDialog, QVBoxLayout
|
|
|
+ from PySide6.QtCore import Qt
|
|
|
+ dlg = QDialog(self)
|
|
|
+ dlg.setWindowFlags(Qt.Popup | Qt.FramelessWindowHint)
|
|
|
+ dlg.setStyleSheet("background: #2c2c2c; border: 1px solid #555; border-radius: 6px;")
|
|
|
+ layout = QVBoxLayout(dlg)
|
|
|
+ layout.setContentsMargins(12, 8, 12, 8)
|
|
|
+ slider = QSlider(Qt.Horizontal)
|
|
|
+ slider.setRange(0, 100)
|
|
|
+ slider.setValue(self._voice_volume)
|
|
|
+ slider.setFixedWidth(160)
|
|
|
+ slider.setStyleSheet("""
|
|
|
+ QSlider::groove:horizontal { height: 4px; background: #555; border-radius: 2px; }
|
|
|
+ QSlider::handle:horizontal { width: 14px; margin: -5px 0; border-radius: 7px; background: #409eff; }
|
|
|
+ QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
|
|
|
+ """)
|
|
|
+ slider.valueChanged.connect(self._preview_volume)
|
|
|
+ layout.addWidget(slider)
|
|
|
+ dlg.adjustSize()
|
|
|
+ btn_global = self.volume_btn.mapToGlobal(self.volume_btn.rect().topLeft())
|
|
|
+ dlg.move(btn_global.x(), btn_global.y() - dlg.height() - 4)
|
|
|
+ dlg.exec()
|
|
|
+
|
|
|
+ def on_ai_toggle(self, state):
|
|
|
+ enabled = (state == Qt.Checked)
|
|
|
+ if enabled == self.ai_enabled:
|
|
|
+ return
|
|
|
+ self.ai_enabled = enabled
|
|
|
+ if enabled and not self.mqtt_client:
|
|
|
+ self.mqtt_client = MQTTClient(self.on_mqtt_message)
|
|
|
+ self._connect_current_ai()
|
|
|
+ elif not enabled and self.mqtt_client:
|
|
|
+ self.mqtt_client.disconnect()
|
|
|
+ self.mqtt_client = None
|
|
|
+ self._subscribed_topics.clear()
|
|
|
+ self._current_ai_module_id = None
|
|
|
+ if not enabled:
|
|
|
+ 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._pair_traj = {"left": [], "right": []}
|
|
|
+ self._voice_suppress_until = 0.0
|
|
|
+ self._trace_phase = ""
|
|
|
+ self.update_ui_for_state(HookState.IDLE)
|
|
|
+ self._last_left_draw = []
|
|
|
+ self._last_right_draw = []
|
|
|
+ if self.left_player:
|
|
|
+ self.left_player.set_ai_objects([])
|
|
|
+ if self.right_player:
|
|
|
+ self.right_player.set_ai_objects([])
|
|
|
+ else:
|
|
|
+ if self.left_player and self._last_left_draw:
|
|
|
+ self.left_player.set_ai_objects(self._last_left_draw)
|
|
|
+ if self.right_player and self._last_right_draw:
|
|
|
+ self.right_player.set_ai_objects(self._last_right_draw)
|
|
|
+ self._refresh_calib_ui()
|
|
|
+
|
|
|
+ def _speak(self, msg: str):
|
|
|
+ self.voice_signal.emit(msg)
|
|
|
+
|
|
|
+ def _speak_impl(self, msg: str):
|
|
|
+ try:
|
|
|
+ self._voice_queue.put_nowait(msg)
|
|
|
+ except queue.Full:
|
|
|
+ pass
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _get_voices_dir() -> str:
|
|
|
+ if getattr(sys, 'frozen', False):
|
|
|
+ base = sys._MEIPASS
|
|
|
+ else:
|
|
|
+ base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
+ return os.path.join(base, "assets", "voices")
|
|
|
+
|
|
|
+ def _voice_msg_to_path(self, msg: str) -> str:
|
|
|
+ mapping = {"挂包中": "hooking", "挂包成功": "ready", "放包中": "releasing",
|
|
|
+ "读卡器离线": "offline_reader", "左侧摄像头离线": "offline_cam_left",
|
|
|
+ "右侧摄像头离线": "offline_cam_right"}
|
|
|
+ if msg in mapping:
|
|
|
+ return os.path.join(self._voices_dir, f"{mapping[msg]}.wav")
|
|
|
+ m = re.search(r'到达(\d+)号工位', msg)
|
|
|
+ if m:
|
|
|
+ return os.path.join(self._voices_dir, f"arrive_{m.group(1)}.wav")
|
|
|
+ return ""
|
|
|
+
|
|
|
+ def _voice_worker(self):
|
|
|
+ speaker = None
|
|
|
+ tts_engine = None
|
|
|
+ if sys.platform == "win32":
|
|
|
+ try:
|
|
|
+ import pythoncom
|
|
|
+ pythoncom.CoInitialize()
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ import win32com.client as _win32com
|
|
|
+ speaker = _win32com.Dispatch("SAPI.SpVoice")
|
|
|
+ speaker.Rate = 2
|
|
|
+ except Exception:
|
|
|
+ logging.warning("SAPI 不可用,仅可使用 WAV 语音")
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ import pyttsx3
|
|
|
+ tts_engine = pyttsx3.init()
|
|
|
+ tts_engine.setProperty('rate', 150)
|
|
|
+ except Exception:
|
|
|
+ logging.warning("pyttsx3 不可用,将回退到 WAV 播放")
|
|
|
+ while True:
|
|
|
+ msg = self._voice_queue.get()
|
|
|
+ if msg is None:
|
|
|
+ break
|
|
|
+ try:
|
|
|
+ if self._use_tts and speaker is not None:
|
|
|
+ speaker.Volume = self._voice_volume
|
|
|
+ speaker.Speak(msg)
|
|
|
+ elif self._use_tts and tts_engine is not None:
|
|
|
+ tts_engine.setProperty('volume', self._voice_volume / 100.0)
|
|
|
+ tts_engine.say(msg)
|
|
|
+ tts_engine.runAndWait()
|
|
|
+ else:
|
|
|
+ self._play_wav(msg)
|
|
|
+ except Exception as e:
|
|
|
+ logging.warning(f"语音播报失败: {e}")
|
|
|
+ self._voice_queue.task_done()
|
|
|
+
|
|
|
+ def _play_wav(self, msg: str):
|
|
|
+ path = self._voice_msg_to_path(msg)
|
|
|
+ if not path:
|
|
|
+ return
|
|
|
+ if sys.platform == "win32":
|
|
|
+ self._play_wav_windows(path)
|
|
|
+ else:
|
|
|
+ self._play_wav_linux(path)
|
|
|
+
|
|
|
+ def _play_wav_windows(self, path: str):
|
|
|
+ import struct, wave, io, winsound
|
|
|
+ try:
|
|
|
+ with wave.open(path, 'rb') as wf:
|
|
|
+ params = wf.getparams()
|
|
|
+ raw = bytearray(wf.readframes(wf.getnframes()))
|
|
|
+ gain = self._voice_volume / 100.0
|
|
|
+ if params.sampwidth == 2:
|
|
|
+ fmt = '<' + 'h' * (len(raw) // 2)
|
|
|
+ samples = list(struct.unpack_from(fmt, bytes(raw)))
|
|
|
+ samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
|
|
|
+ raw = bytearray(struct.pack(fmt, *samples))
|
|
|
+ elif params.sampwidth == 1:
|
|
|
+ samples = list(raw)
|
|
|
+ samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
|
|
|
+ raw = bytearray(samples)
|
|
|
+ buf = io.BytesIO()
|
|
|
+ with wave.open(buf, 'wb') as wf:
|
|
|
+ wf.setparams(params)
|
|
|
+ wf.writeframes(bytes(raw))
|
|
|
+ winsound.PlaySound(buf.getvalue(), winsound.SND_MEMORY)
|
|
|
+ except Exception:
|
|
|
+ winsound.PlaySound(path, winsound.SND_FILENAME | winsound.SND_ASYNC)
|
|
|
+
|
|
|
+ def _play_wav_linux(self, path: str):
|
|
|
+ import struct, wave, io, numpy as np
|
|
|
+ try:
|
|
|
+ with wave.open(path, 'rb') as wf:
|
|
|
+ params = wf.getparams()
|
|
|
+ raw = bytearray(wf.readframes(wf.getnframes()))
|
|
|
+ gain = self._voice_volume / 100.0
|
|
|
+ if params.sampwidth == 2:
|
|
|
+ fmt = '<' + 'h' * (len(raw) // 2)
|
|
|
+ samples = list(struct.unpack_from(fmt, bytes(raw)))
|
|
|
+ samples = [max(-32768, min(32767, int(s * gain))) for s in samples]
|
|
|
+ raw = bytearray(struct.pack(fmt, *samples))
|
|
|
+ elif params.sampwidth == 1:
|
|
|
+ samples = list(raw)
|
|
|
+ samples = [max(0, min(255, int((s - 128) * gain) + 128)) for s in samples]
|
|
|
+ raw = bytearray(samples)
|
|
|
+ import sounddevice as sd
|
|
|
+ if params.sampwidth == 2:
|
|
|
+ dtype = np.int16
|
|
|
+ elif params.sampwidth == 4:
|
|
|
+ dtype = np.int32
|
|
|
+ else:
|
|
|
+ dtype = np.float32
|
|
|
+ audio_data = np.frombuffer(bytes(raw), dtype=dtype)
|
|
|
+ sd.play(audio_data, samplerate=params.framerate, blocking=True)
|
|
|
+ except Exception as e:
|
|
|
+ logging.warning(f"sounddevice 播放失败: {e},回退到 aplay")
|
|
|
+ try:
|
|
|
+ import subprocess
|
|
|
+ subprocess.run(['aplay', '-q', path], timeout=10, check=False)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ # ---------- 工位图标 ----------
|
|
|
+ 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)
|
|
|
+ badge = self._calib_badge_labels.get(bucket.id)
|
|
|
+ if badge is not None:
|
|
|
+ if self._is_bucket_calibrated(bucket):
|
|
|
+ badge.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
|
|
|
+ tip = "标定状态:已标定"
|
|
|
+ for side, side_name in (("left", "左"), ("right", "右")):
|
|
|
+ x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ if x is not None and y is not None:
|
|
|
+ tip += f"\n{side_name}侧 X={x:.3f} Y={y:.3f}"
|
|
|
+ else:
|
|
|
+ badge.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
|
|
|
+ tip = "标定状态:未标定"
|
|
|
+ badge.setToolTip(tip)
|
|
|
+ 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 / (16/9 if self._ratio_16_9 else 4/3))
|
|
|
+ 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 _toggle_fullscreen(self):
|
|
|
+ if self.isFullScreen():
|
|
|
+ self.showNormal()
|
|
|
+ else:
|
|
|
+ self.showFullScreen()
|
|
|
+
|
|
|
+ def _toggle_aspect_ratio(self):
|
|
|
+ self._ratio_16_9 = not self._ratio_16_9
|
|
|
+ self.ratio_btn.setText("16:9" if self._ratio_16_9 else "4:3")
|
|
|
+ self.update_video_size()
|
|
|
+ self.update_players_target_size()
|
|
|
+ self._save_config()
|
|
|
+
|
|
|
+ # ---------- 加载动画和超时 ----------
|
|
|
+ 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 is player_ref:
|
|
|
+ self.left_player.stop()
|
|
|
+ self.left_player = None
|
|
|
+ elif side == "right" and self.right_player is player_ref:
|
|
|
+ self.right_player.stop()
|
|
|
+ self.right_player = None
|
|
|
+ self._start_rtsp_retry(side)
|
|
|
+
|
|
|
+ def _cancel_rtsp_retry(self, side: str):
|
|
|
+ timer = self._rtsp_retry_left if side == "left" else self._rtsp_retry_right
|
|
|
+ if timer:
|
|
|
+ timer.stop()
|
|
|
+ timer.deleteLater()
|
|
|
+ if side == "left":
|
|
|
+ self._rtsp_retry_left = None
|
|
|
+ self._rtsp_retry_delay_left = 1
|
|
|
+ else:
|
|
|
+ self._rtsp_retry_right = None
|
|
|
+ self._rtsp_retry_delay_right = 1
|
|
|
+
|
|
|
+ def _start_rtsp_retry(self, side: str):
|
|
|
+ if not self.active_bucket:
|
|
|
+ return
|
|
|
+ cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
|
|
|
+ if not cam:
|
|
|
+ return
|
|
|
+ if not self._is_camera_online(cam):
|
|
|
+ return
|
|
|
+ label = self.left_label if side == "left" else self.right_label
|
|
|
+ delay = self._rtsp_retry_delay_left if side == "left" else self._rtsp_retry_delay_right
|
|
|
+ label.setText(f"正在重连({delay}s)...")
|
|
|
+ label.setAlignment(Qt.AlignCenter)
|
|
|
+ label.setStyleSheet(label.styleSheet() + "color: orange; font-size: 20px;")
|
|
|
+ timer = QTimer(self)
|
|
|
+ timer.setSingleShot(True)
|
|
|
+ timer.timeout.connect(lambda: self._retry_rtsp(side))
|
|
|
+ timer.start(delay * 1000)
|
|
|
+ if side == "left":
|
|
|
+ self._rtsp_retry_left = timer
|
|
|
+ else:
|
|
|
+ self._rtsp_retry_right = timer
|
|
|
+
|
|
|
+ def _retry_rtsp(self, side: str):
|
|
|
+ if not self.active_bucket:
|
|
|
+ return
|
|
|
+ cam = next((c for c in self.active_bucket.cameras if c.position == (CameraPosition.LEFT if side == "left" else CameraPosition.RIGHT)), None)
|
|
|
+ if not cam:
|
|
|
+ return
|
|
|
+ label = self.left_label if side == "left" else self.right_label
|
|
|
+ if not self._is_camera_online(cam):
|
|
|
+ self.show_timeout_on_label(label)
|
|
|
+ self._cancel_rtsp_retry(side)
|
|
|
+ return
|
|
|
+ self.show_loading_on_label(label)
|
|
|
+ w, h = label.width(), label.height()
|
|
|
+ timeout = self.config.video.rtsp_timeout
|
|
|
+ player = RTSPPlayer(cam.rtsp_url, w, h, timeout_sec=timeout)
|
|
|
+ player.frame_ready.connect(label.setPixmap)
|
|
|
+ player.first_frame_received.connect(lambda s=side: self._on_rtsp_retry_success(s))
|
|
|
+ player.timeout.connect(lambda l=label, p=player, s=side: self.on_player_timeout(l, p, s))
|
|
|
+ old = self.left_player if side == "left" else self.right_player
|
|
|
+ if old:
|
|
|
+ try:
|
|
|
+ old.frame_ready.disconnect()
|
|
|
+ except TypeError:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ old.first_frame_received.disconnect()
|
|
|
+ except TypeError:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ old.timeout.disconnect()
|
|
|
+ except TypeError:
|
|
|
+ pass
|
|
|
+ old.stop()
|
|
|
+ if side == "left":
|
|
|
+ self.left_player = player
|
|
|
+ else:
|
|
|
+ self.right_player = player
|
|
|
+ player.start()
|
|
|
+
|
|
|
+ def _on_rtsp_retry_success(self, side: str):
|
|
|
+ self.hide_loading_on_label(self.left_label if side == "left" else self.right_label)
|
|
|
+ self._cancel_rtsp_retry(side)
|
|
|
+ # 重连成功后恢复 AI 对象标注
|
|
|
+ if self.active_bucket and self.ai_enabled:
|
|
|
+ objects = self._last_left_draw if side == "left" else self._last_right_draw
|
|
|
+ player = self.left_player if side == "left" else self.right_player
|
|
|
+ if player:
|
|
|
+ player.set_ai_objects(objects)
|
|
|
+
|
|
|
+ def _is_camera_online(self, cam) -> bool:
|
|
|
+ m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
|
|
|
+ if not m:
|
|
|
+ return False
|
|
|
+ lat = self._camera_signal.get(m.group(1))
|
|
|
+ # 尚未检测过该 IP 时,默认认为在线,先尝试连接
|
|
|
+ if lat is None:
|
|
|
+ return True
|
|
|
+ return lat >= 0
|
|
|
+ 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):
|
|
|
+ self.rfid_state_signal.emit(online)
|
|
|
+
|
|
|
+ def _on_rfid_state_impl(self, online: bool):
|
|
|
+ if online:
|
|
|
+ self.module_status.setText("通讯状态: RFID正常")
|
|
|
+ else:
|
|
|
+ self.module_status.setText("通讯状态: RFID离线")
|
|
|
+ logger.warning("读卡器离线")
|
|
|
+ if self.voice_enabled and getattr(self, '_started', False):
|
|
|
+ self._speak("读卡器离线")
|
|
|
+
|
|
|
+ # ---------- 硬件初始化 ----------
|
|
|
+ def init_hardware(self):
|
|
|
+ if self.config.module_type == ModuleType.RFID and self.config.rfid:
|
|
|
+ # 收集所有配置的工位卡号,过滤掉读卡器同时读到的无关卡
|
|
|
+ valid_codes = {b.code_x for b in self.buckets if b.code_x}
|
|
|
+ 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,
|
|
|
+ valid_codes=valid_codes
|
|
|
+ )
|
|
|
+ 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.on_mqtt_message)
|
|
|
+ self._connect_current_ai()
|
|
|
+
|
|
|
+ def _connect_current_ai(self, bucket=None):
|
|
|
+ if bucket is None:
|
|
|
+ bucket = self.active_bucket
|
|
|
+ idx = None
|
|
|
+ if bucket and bucket.ai_module_id is not None:
|
|
|
+ idx = bucket.ai_module_id
|
|
|
+ elif self.config.ai_modules:
|
|
|
+ idx = 0
|
|
|
+ if idx is not None and idx < len(self.config.ai_modules):
|
|
|
+ target = self.config.ai_modules[idx]
|
|
|
+ self.mqtt_client.disconnect()
|
|
|
+ self.mqtt_client.connect(target.broker, target.port)
|
|
|
+ self._current_ai_module_id = idx
|
|
|
+ if bucket:
|
|
|
+ topics = [c.topic for c in bucket.cameras if c.topic]
|
|
|
+ if topics:
|
|
|
+ self.mqtt_client.subscribe(topics)
|
|
|
+ self._subscribed_topics = set(topics)
|
|
|
+
|
|
|
+ # ---------- 激活工位 ----------
|
|
|
+ def activate_bucket(self, bucket: BucketModel):
|
|
|
+ if self.active_bucket == bucket:
|
|
|
+ return
|
|
|
+ self._cancel_rtsp_retry("left")
|
|
|
+ self._cancel_rtsp_retry("right")
|
|
|
+ if bucket and self.voice_enabled:
|
|
|
+ self._speak(f"到达{bucket.description}")
|
|
|
+ for player in (self.left_player, self.right_player):
|
|
|
+ if player is None:
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ player.frame_ready.disconnect()
|
|
|
+ except TypeError:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ player.first_frame_received.disconnect()
|
|
|
+ except TypeError:
|
|
|
+ pass
|
|
|
+ try:
|
|
|
+ player.timeout.disconnect()
|
|
|
+ except TypeError:
|
|
|
+ pass
|
|
|
+ player.stop()
|
|
|
+ self.left_player = None
|
|
|
+ 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._refresh_calib_ui()
|
|
|
+
|
|
|
+ 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
|
|
|
+ self._last_hooking_voice_time = 0.0
|
|
|
+ self._pair_traj = {"left": [], "right": []}
|
|
|
+ self._voice_suppress_until = 0.0
|
|
|
+ self._trace_phase = ""
|
|
|
+ self._side_ready = {"left": False, "right": False}
|
|
|
+ self._auto_calib_state = {
|
|
|
+ side: {"ready_times": deque(), "samples": deque()}
|
|
|
+ for side in ("left", "right")
|
|
|
+ }
|
|
|
+ self.update_ui_for_state(HookState.IDLE)
|
|
|
+ self._last_left_draw = []
|
|
|
+ self._last_right_draw = []
|
|
|
+
|
|
|
+ # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
|
|
|
+ if bucket and self.config.ai.enabled and self.mqtt_client:
|
|
|
+ if bucket.ai_module_id != self._current_ai_module_id:
|
|
|
+ self._unsubscribe_all_topics()
|
|
|
+ self._connect_current_ai(bucket)
|
|
|
+
|
|
|
+ if bucket:
|
|
|
+ if self.mqtt_client:
|
|
|
+ topics = [c.topic for c in bucket.cameras if c.topic]
|
|
|
+ stale = self._subscribed_topics - set(topics)
|
|
|
+ for t in stale:
|
|
|
+ self.mqtt_client.unsubscribe(t)
|
|
|
+ new_t = [t for t in topics if t not in self._subscribed_topics]
|
|
|
+ if new_t:
|
|
|
+ self.mqtt_client.subscribe(new_t)
|
|
|
+ self._subscribed_topics = set(topics)
|
|
|
+
|
|
|
+ # 再启 RTSP
|
|
|
+ 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 and self._is_camera_online(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 p=player: self.hide_loading_on_label(self.left_label))
|
|
|
+ player.timeout.connect(lambda p=player: self.on_player_timeout(self.left_label, p, "left"))
|
|
|
+ self.left_player = player
|
|
|
+ player.start()
|
|
|
+ elif left_cam:
|
|
|
+ self.show_timeout_on_label(self.left_label)
|
|
|
+ if self.voice_enabled:
|
|
|
+ self._speak("左侧摄像头离线")
|
|
|
+
|
|
|
+ if right_cam and self._is_camera_online(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 p=player: self.hide_loading_on_label(self.right_label))
|
|
|
+ player.timeout.connect(lambda p=player: self.on_player_timeout(self.right_label, p, "right"))
|
|
|
+ self.right_player = player
|
|
|
+ player.start()
|
|
|
+ elif right_cam:
|
|
|
+ self.show_timeout_on_label(self.right_label)
|
|
|
+ if self.voice_enabled:
|
|
|
+ self._speak("右侧摄像头离线")
|
|
|
+ else:
|
|
|
+ self._unsubscribe_all_topics()
|
|
|
+ self.left_label.clear()
|
|
|
+ self.right_label.clear()
|
|
|
+ QApplication.processEvents()
|
|
|
+ self.left_label.clear()
|
|
|
+ self.right_label.clear()
|
|
|
+
|
|
|
+ def _unsubscribe_all_topics(self):
|
|
|
+ if self.mqtt_client and self._subscribed_topics:
|
|
|
+ for t in self._subscribed_topics:
|
|
|
+ self.mqtt_client.unsubscribe(t)
|
|
|
+ self._subscribed_topics.clear()
|
|
|
+
|
|
|
+ # ---------- RFID 标签回调 ----------
|
|
|
+ def on_rfid_tag(self, tag: str):
|
|
|
+ self.rfid_tag_signal.emit(tag)
|
|
|
+
|
|
|
+ def _on_rfid_tag_impl(self, tag: str):
|
|
|
+ self._last_rfid_tag = tag
|
|
|
+ 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):
|
|
|
+ self.modbus_data_signal.emit(bools)
|
|
|
+
|
|
|
+ def _on_modbus_data_impl(self, bools):
|
|
|
+ for bucket in self.buckets:
|
|
|
+ try:
|
|
|
+ addr_x = int(bucket.code_x)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ continue
|
|
|
+ if addr_x < 0 or addr_x >= len(bools):
|
|
|
+ continue
|
|
|
+ if bucket.code_y and bucket.code_y.strip():
|
|
|
+ try:
|
|
|
+ addr_y = int(bucket.code_y)
|
|
|
+ except (ValueError, TypeError):
|
|
|
+ continue
|
|
|
+ if addr_y < 0 or addr_y >= len(bools):
|
|
|
+ continue
|
|
|
+ if bools[addr_x] and bools[addr_y]:
|
|
|
+ if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
|
|
|
+ self.activate_bucket(bucket)
|
|
|
+ break
|
|
|
+ else:
|
|
|
+ if bools[addr_x]:
|
|
|
+ if self.switch_mode == SwitchMode.AUTO and self.active_bucket != bucket:
|
|
|
+ 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]
|
|
|
+ label = raw_label
|
|
|
+ if 'hoist' in raw_label:
|
|
|
+ label = 'hook'
|
|
|
+ elif 'cup' in raw_label:
|
|
|
+ label = 'lug'
|
|
|
+ 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({
|
|
|
+ "track_id": parts[0],
|
|
|
+ "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):
|
|
|
+ self.mqtt_message_signal.emit(topic, payload)
|
|
|
+
|
|
|
+ def _on_mqtt_message_impl(self, topic: str, payload: dict):
|
|
|
+ if payload is None or not isinstance(payload, dict):
|
|
|
+ return
|
|
|
+ if not self.active_bucket:
|
|
|
+ return
|
|
|
+ if not self.ai_enabled:
|
|
|
+ return
|
|
|
+ now = time.time()
|
|
|
+ if now - self._last_mqtt_process < 0.03:
|
|
|
+ return
|
|
|
+ self._last_mqtt_process = now
|
|
|
+ self._overlay_cleared = False
|
|
|
+ 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
|
|
|
+
|
|
|
+ all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
|
|
|
+
|
|
|
+ # ready 标签仅用于自动标定触发,不进检测对象/不参与状态判定
|
|
|
+ side = "left" if cam.position == CameraPosition.LEFT else "right"
|
|
|
+ self._side_ready[side] = any('ready' in o['label'].lower() for o in all_objects)
|
|
|
+ objects = [o for o in all_objects if 'ready' not in o['label'].lower()]
|
|
|
+ if cam.position == CameraPosition.LEFT:
|
|
|
+ self.left_objects = objects
|
|
|
+ else:
|
|
|
+ self.right_objects = objects
|
|
|
+
|
|
|
+ self.evaluate_hook_state()
|
|
|
+ if self._calib_active:
|
|
|
+ self._sample_calibration()
|
|
|
+ self._update_auto_calibration(side, self._side_ready[side])
|
|
|
+
|
|
|
+ draw_objects = []
|
|
|
+ for obj in all_objects:
|
|
|
+ if 'ready' in obj['label'].lower():
|
|
|
+ continue
|
|
|
+ obj['alpha'] = 0.4
|
|
|
+ draw_objects.append(obj)
|
|
|
+
|
|
|
+ draw_objects.extend(self._trace_objects(side))
|
|
|
+ status = self._trace_status_text()
|
|
|
+ if status:
|
|
|
+ color = (0, 0, 255) if self._trace_phase else (0, 200, 255)
|
|
|
+ draw_objects.append({"text": status, "pos": (10, 20), "anchor": "tr", "color": color})
|
|
|
+
|
|
|
+ if cam.position == CameraPosition.LEFT:
|
|
|
+ self._last_left_draw = draw_objects
|
|
|
+ if self.left_player:
|
|
|
+ self.left_player.set_ai_objects(draw_objects)
|
|
|
+ else:
|
|
|
+ self._last_right_draw = draw_objects
|
|
|
+ if self.right_player:
|
|
|
+ self.right_player.set_ai_objects(draw_objects)
|
|
|
+ break
|
|
|
+
|
|
|
+ # ---------- 挂钩状态评估 ----------
|
|
|
+ def _nearest_pair(self, hooks, lugs):
|
|
|
+ """在 hook/lug 列表中选中心距最近的配对"""
|
|
|
+ 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
|
|
|
+
|
|
|
+ def _best_intersecting_pair(self, side: str):
|
|
|
+ """该侧最近且框相交的 hook/lug 配对;无则返回 None"""
|
|
|
+ if side == "left":
|
|
|
+ objects = self.left_objects
|
|
|
+ else:
|
|
|
+ objects = self.right_objects
|
|
|
+ hooks = [o for o in objects if 'hook' in o['label'].lower()]
|
|
|
+ lugs = [o for o in objects if 'lug' in o['label'].lower()]
|
|
|
+ pairs = []
|
|
|
+ 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
|
|
|
+ pairs.append(((hx - lx) ** 2 + (hy - ly) ** 2, h, l))
|
|
|
+ pairs.sort(key=lambda t: t[0])
|
|
|
+ for _, h, l in pairs:
|
|
|
+ if self.rects_intersect(h, l):
|
|
|
+ return (h, l)
|
|
|
+ return None
|
|
|
+
|
|
|
+ def _compute_offset_ratios(self, side: str):
|
|
|
+ """该侧最近相交配对的归一化偏移 (xr, yr);无有效配对返回 None"""
|
|
|
+ pair = self._best_intersecting_pair(side)
|
|
|
+ if not pair:
|
|
|
+ return None
|
|
|
+ h, l = pair
|
|
|
+ if self._lug_touches_top(l):
|
|
|
+ return None
|
|
|
+ lw, lh = l['w'], l['h']
|
|
|
+ if lw <= 0 or lh <= 0:
|
|
|
+ return None
|
|
|
+ xr = (h['x'] + h['w'] / 2 - (l['x'] + l['w'] / 2)) / lw
|
|
|
+ yr = ((h['y'] + h['h']) - (l['y'] + l['h'])) / lh
|
|
|
+ return (xr, yr)
|
|
|
+
|
|
|
+ # ---------- 标定(可以起吊偏移) ----------
|
|
|
+ @staticmethod
|
|
|
+ def _is_bucket_calibrated(bucket) -> bool:
|
|
|
+ for side in ("left", "right"):
|
|
|
+ x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ if x is not None and y is not None:
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+ def on_calibrate_clicked(self):
|
|
|
+ if self._calib_active:
|
|
|
+ return
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket:
|
|
|
+ QMessageBox.information(self, "标定", "请先选择一个工位")
|
|
|
+ return
|
|
|
+ if not self.ai_enabled:
|
|
|
+ QMessageBox.information(self, "标定", "请先启用 AI")
|
|
|
+ return
|
|
|
+ if self._is_bucket_calibrated(bucket):
|
|
|
+ ret = QMessageBox.question(
|
|
|
+ self, "标定",
|
|
|
+ f"{bucket.description} 已标定,要重新标定吗?",
|
|
|
+ QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel,
|
|
|
+ QMessageBox.No,
|
|
|
+ )
|
|
|
+ if ret == QMessageBox.Cancel:
|
|
|
+ return
|
|
|
+ if ret == QMessageBox.No:
|
|
|
+ self._reset_calibration()
|
|
|
+ return
|
|
|
+ self._start_calibration()
|
|
|
+
|
|
|
+ def _start_calibration(self):
|
|
|
+ self._calib_active = True
|
|
|
+ self._calib_until = time.time() + self.CALIB_DURATION_SECS
|
|
|
+ self._calib_samples = {}
|
|
|
+ self._calib_warned.clear()
|
|
|
+ self.calib_btn.setEnabled(False)
|
|
|
+ self.calib_btn.setText("标定中...")
|
|
|
+
|
|
|
+ def _sample_calibration(self):
|
|
|
+ now = time.time()
|
|
|
+ if now >= self._calib_until:
|
|
|
+ self._finish_calibration()
|
|
|
+ return
|
|
|
+ for side in ("left", "right"):
|
|
|
+ ratios = self._compute_offset_ratios(side)
|
|
|
+ if ratios is None:
|
|
|
+ continue
|
|
|
+ buf = self._calib_samples.setdefault(side, [0.0, 0.0, 0])
|
|
|
+ buf[0] += ratios[0]
|
|
|
+ buf[1] += ratios[1]
|
|
|
+ buf[2] += 1
|
|
|
+
|
|
|
+ def _finish_calibration(self):
|
|
|
+ self._calib_active = False
|
|
|
+ if self.calib_btn:
|
|
|
+ self.calib_btn.setEnabled(True)
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket:
|
|
|
+ self._refresh_calib_ui()
|
|
|
+ return
|
|
|
+ sides = {"left": None, "right": None}
|
|
|
+ for side in ("left", "right"):
|
|
|
+ buf = self._calib_samples.get(side)
|
|
|
+ if buf and buf[2] > 0:
|
|
|
+ xr = buf[0] / buf[2]
|
|
|
+ yr = buf[1] / buf[2]
|
|
|
+ setattr(bucket, f"hook_lug_x_offset_ratio_{side}", xr)
|
|
|
+ setattr(bucket, f"hook_lug_y_offset_ratio_{side}", yr)
|
|
|
+ sides[side] = (xr, yr)
|
|
|
+ self._save_config()
|
|
|
+ self.refresh_bucket_icons()
|
|
|
+ self._refresh_calib_ui()
|
|
|
+ lines = [f"{bucket.description} 标定完成"]
|
|
|
+ for side in ("left", "right"):
|
|
|
+ v = sides[side]
|
|
|
+ if v is None:
|
|
|
+ lines.append(f" {side}侧: 未采到有效配对(未标定)")
|
|
|
+ else:
|
|
|
+ lines.append(f" {side}侧: x={v[0]:.3f} y={v[1]:.3f}")
|
|
|
+ QMessageBox.information(self, "标定", "\n".join(lines))
|
|
|
+
|
|
|
+ def _reset_calibration(self):
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket:
|
|
|
+ return
|
|
|
+ for side in ("left", "right"):
|
|
|
+ setattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ setattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ self._save_config()
|
|
|
+ self.refresh_bucket_icons()
|
|
|
+ self._refresh_calib_ui()
|
|
|
+
|
|
|
+ # ---------- 自动标定(连续 ready) ----------
|
|
|
+ def on_reset_calibrate_clicked(self):
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket:
|
|
|
+ return
|
|
|
+ if not self._is_bucket_calibrated(bucket):
|
|
|
+ QMessageBox.information(self, "重置标定", "当前工位尚未标定,无需重置")
|
|
|
+ return
|
|
|
+ ret = QMessageBox.question(
|
|
|
+ self, "重置标定",
|
|
|
+ f"确认清除 {bucket.description} 的标定数据?\n清除后该工位会在识别到 ready 时自动重新标定。",
|
|
|
+ QMessageBox.Yes | QMessageBox.No,
|
|
|
+ QMessageBox.No,
|
|
|
+ )
|
|
|
+ if ret != QMessageBox.Yes:
|
|
|
+ return
|
|
|
+ for side in ("left", "right"):
|
|
|
+ setattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ setattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ self._save_config()
|
|
|
+ self.refresh_bucket_icons()
|
|
|
+ self._refresh_calib_ui()
|
|
|
+ logger.info(f"标定已重置: {bucket.description}")
|
|
|
+
|
|
|
+ def _update_auto_calibration(self, side: str, ready: bool):
|
|
|
+ """该侧窗口内 ready 帧数达标后采样取平均,自动写入未标定侧的标定值。
|
|
|
+
|
|
|
+ 已标定侧不覆盖;切换到未标定工位后识别到 ready 即自动标定,无需手动操作。
|
|
|
+ DeepStream 跳帧不可靠,采用窗口计数而非连续帧:
|
|
|
+ 1 秒(auto_calib_duration_secs)内出现 auto_calib_ready_frames 帧 ready 即触发;
|
|
|
+ 非 ready 消息不清零,天然容忍跳帧与偶发闪烁。
|
|
|
+ """
|
|
|
+ if self._calib_active:
|
|
|
+ return
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket:
|
|
|
+ return
|
|
|
+ has_cam = any(c.position.value == side for c in bucket.cameras)
|
|
|
+ if not has_cam:
|
|
|
+ return
|
|
|
+ x_attr = f"hook_lug_x_offset_ratio_{side}"
|
|
|
+ y_attr = f"hook_lug_y_offset_ratio_{side}"
|
|
|
+ if getattr(bucket, x_attr) is not None and getattr(bucket, y_attr) is not None:
|
|
|
+ return
|
|
|
+ if not ready:
|
|
|
+ return
|
|
|
+ st = self._auto_calib_state[side]
|
|
|
+ now = time.time()
|
|
|
+ window = self.config.ai.auto_calib_duration_secs
|
|
|
+ st["ready_times"].append(now)
|
|
|
+ while st["ready_times"] and now - st["ready_times"][0] > window:
|
|
|
+ st["ready_times"].popleft()
|
|
|
+ ratios = self._compute_offset_ratios(side)
|
|
|
+ if ratios is not None:
|
|
|
+ st["samples"].append((now, ratios[0], ratios[1]))
|
|
|
+ while st["samples"] and now - st["samples"][0][0] > window:
|
|
|
+ st["samples"].popleft()
|
|
|
+ if len(st["ready_times"]) < self.config.ai.auto_calib_ready_frames:
|
|
|
+ return
|
|
|
+ if len(st["samples"]) < self.config.ai.auto_calib_min_samples:
|
|
|
+ return
|
|
|
+ x = sum(s[1] for s in st["samples"]) / len(st["samples"])
|
|
|
+ y = sum(s[2] for s in st["samples"]) / len(st["samples"])
|
|
|
+ setattr(bucket, x_attr, x)
|
|
|
+ setattr(bucket, y_attr, y)
|
|
|
+ st["ready_times"].clear()
|
|
|
+ st["samples"].clear()
|
|
|
+ self._save_config()
|
|
|
+ self.refresh_bucket_icons()
|
|
|
+ self._refresh_calib_ui()
|
|
|
+ logger.info(f"自动标定完成 {side}侧: x={x:.3f}, y={y:.3f} (工位{bucket.id})")
|
|
|
+
|
|
|
+ def _refresh_calib_ui(self):
|
|
|
+ if not self.calib_btn:
|
|
|
+ return
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket or not self.ai_enabled:
|
|
|
+ self.calib_btn.setEnabled(False)
|
|
|
+ self.calib_btn.setText("标定")
|
|
|
+ self.calib_btn.setToolTip("标定可以起吊的 hook/lug 偏移(当前工位)")
|
|
|
+ if self.reset_calib_btn:
|
|
|
+ self.reset_calib_btn.setEnabled(False)
|
|
|
+ return
|
|
|
+ self.calib_btn.setEnabled(True)
|
|
|
+ if self.reset_calib_btn:
|
|
|
+ self.reset_calib_btn.setEnabled(self._is_bucket_calibrated(bucket))
|
|
|
+ parts = []
|
|
|
+ for side in ("left", "right"):
|
|
|
+ x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ if x is not None and y is not None:
|
|
|
+ parts.append(f"{side}侧 已标定 (x={x:.3f}, y={y:.3f})")
|
|
|
+ else:
|
|
|
+ parts.append(f"{side}侧 未标定")
|
|
|
+ self.calib_btn.setToolTip(bucket.description + "\n" + "\n".join(parts))
|
|
|
+ if self._is_bucket_calibrated(bucket):
|
|
|
+ self.calib_btn.setText("已标定")
|
|
|
+ self.calib_btn.setStyleSheet("""
|
|
|
+ QPushButton {
|
|
|
+ color: #67c23a; font-weight: bold; font-size: 12px;
|
|
|
+ border: 1px solid #67c23a; border-radius: 4px;
|
|
|
+ padding: 2px 6px; background: transparent;
|
|
|
+ }
|
|
|
+ """)
|
|
|
+ else:
|
|
|
+ self.calib_btn.setText("标定")
|
|
|
+ self.calib_btn.setStyleSheet("""
|
|
|
+ QPushButton {
|
|
|
+ color: #606266; font-weight: bold; font-size: 12px;
|
|
|
+ border: 1px solid #dcdfe6; border-radius: 4px;
|
|
|
+ padding: 2px 6px; background: transparent;
|
|
|
+ }
|
|
|
+ QPushButton:hover { border-color: #409eff; color: #409eff; }
|
|
|
+ """)
|
|
|
+
|
|
|
+ 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()]
|
|
|
+
|
|
|
+ left_pair = self._nearest_pair(left_hooks, left_lugs)
|
|
|
+ right_pair = self._nearest_pair(right_hooks, right_lugs)
|
|
|
+
|
|
|
+ self._push_pair_traj("left", left_pair)
|
|
|
+ self._push_pair_traj("right", right_pair)
|
|
|
+ self._update_suppress_window()
|
|
|
+
|
|
|
+ # 吊耳贴着画面上沿(框顶被裁):不再判定状态,状态框不显示
|
|
|
+ if (left_pair and self._lug_touches_top(left_pair[1])) or \
|
|
|
+ (right_pair and self._lug_touches_top(right_pair[1])):
|
|
|
+ self.hook_state = HookState.IDLE
|
|
|
+ self.prev_state = HookState.IDLE
|
|
|
+ self.state_counter = 0
|
|
|
+ self._clear_state_display()
|
|
|
+ return
|
|
|
+
|
|
|
+ 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:
|
|
|
+ # 可以起吊:hook/lug 双轴归一化偏移落在标定容差内且框相交(语义≠挂牢)
|
|
|
+ can_lift = False
|
|
|
+ if self.active_bucket:
|
|
|
+ can_lift = (self._side_match("left", left_pair)
|
|
|
+ or self._side_match("right", right_pair))
|
|
|
+ if can_lift:
|
|
|
+ new_state = HookState.READY
|
|
|
+ elif self._has_intersecting_pair(left_pair, right_pair):
|
|
|
+ new_state = HookState.HOOKING
|
|
|
+ else:
|
|
|
+ new_state = HookState.IDLE
|
|
|
+
|
|
|
+ # 防抖:进出 READY 对称用 ready_debounce(防单帧丢配对闪红绿),
|
|
|
+ # 非 READY → IDLE 即时,其余过渡用 STATE_THRESHOLD
|
|
|
+ if new_state == HookState.IDLE and self.hook_state != HookState.READY:
|
|
|
+ threshold = 1
|
|
|
+ elif new_state == HookState.READY or self.hook_state == HookState.READY:
|
|
|
+ threshold = self.config.ai.ready_debounce
|
|
|
+ else:
|
|
|
+ threshold = self.STATE_THRESHOLD
|
|
|
+ if new_state == self.prev_state:
|
|
|
+ self.state_counter += 1
|
|
|
+ else:
|
|
|
+ self.prev_state = new_state
|
|
|
+ self.state_counter = 1
|
|
|
+
|
|
|
+ if self.state_counter >= threshold:
|
|
|
+ if new_state != self.hook_state:
|
|
|
+ self.hook_state = new_state
|
|
|
+ self.update_ui_for_state(new_state)
|
|
|
+ self.state_counter = 0
|
|
|
+
|
|
|
+ for obj in self.left_objects + self.right_objects:
|
|
|
+ obj.pop('paired', None)
|
|
|
+ if left_pair:
|
|
|
+ left_pair[0]['paired'] = True
|
|
|
+ left_pair[1]['paired'] = True
|
|
|
+ if right_pair:
|
|
|
+ right_pair[0]['paired'] = True
|
|
|
+ right_pair[1]['paired'] = True
|
|
|
+
|
|
|
+ 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)
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _lug_touches_top(l) -> bool:
|
|
|
+ """吊耳检测框是否贴着画面上沿(框顶被画面裁切)"""
|
|
|
+ return l['y'] <= 0
|
|
|
+
|
|
|
+ def _has_intersecting_pair(self, left_pair, right_pair):
|
|
|
+ if left_pair:
|
|
|
+ h, l = left_pair
|
|
|
+ if self.rects_intersect(h, l):
|
|
|
+ return True
|
|
|
+ if right_pair:
|
|
|
+ h, l = right_pair
|
|
|
+ if self.rects_intersect(h, l):
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+ def _side_match(self, side: str, pair) -> bool:
|
|
|
+ """该侧是否满足"可以起吊":hook/lug 框相交,且双轴归一化偏移都在标定容差内。
|
|
|
+ X 用中心点、Y 用框底边:升到一定高度后检测框顶部会被裁掉,中心 Y 不可靠,底边贴近吊耳坐实位置不受裁切影响。"""
|
|
|
+ bucket = self.active_bucket
|
|
|
+ if not bucket or not pair:
|
|
|
+ return False
|
|
|
+ h, l = pair
|
|
|
+ if not self.rects_intersect(h, l):
|
|
|
+ return False
|
|
|
+ if self._lug_touches_top(l):
|
|
|
+ return False
|
|
|
+ lw, lh = l['w'], l['h']
|
|
|
+ if lw <= 0 or lh <= 0:
|
|
|
+ return False
|
|
|
+ xr = (h['x'] + h['w'] / 2 - (l['x'] + l['w'] / 2)) / lw
|
|
|
+ yr = ((h['y'] + h['h']) - (l['y'] + l['h'])) / lh
|
|
|
+ cal_x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ cal_y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ if cal_x is None or cal_y is None:
|
|
|
+ if side not in self._calib_warned:
|
|
|
+ self._calib_warned.add(side)
|
|
|
+ logger.warning(f"工位 {bucket.id} 未标定 {side} 侧偏移,跳过可以起吊判定")
|
|
|
+ return False
|
|
|
+ tol = self.config.ai.offset_ratio_tolerance
|
|
|
+ return abs(xr - cal_x) <= tol and abs(yr - cal_y) <= tol
|
|
|
+
|
|
|
+ def _push_pair_traj(self, side: str, pair):
|
|
|
+ """把当前帧配对中心与距离追加进轨迹队列"""
|
|
|
+ if not pair:
|
|
|
+ self._pair_traj[side] = []
|
|
|
+ return
|
|
|
+ h, l = pair
|
|
|
+ hx, hy = h['x'] + h['w'] / 2, h['y'] + h['h'] / 2
|
|
|
+ lx, ly = l['x'] + l['w'] / 2, l['y'] + l['h'] / 2
|
|
|
+ dist = ((hx - lx) ** 2 + (hy - ly) ** 2) ** 0.5
|
|
|
+ now = time.time()
|
|
|
+ traj = self._pair_traj[side]
|
|
|
+ traj.append({"t": now, "hook_cx": hx, "hook_cy": hy,
|
|
|
+ "lug_cx": lx, "lug_cy": ly, "lug_w": l['w'], "lug_h": l['h'], "dist": dist})
|
|
|
+ while len(traj) > self.TRAJ_MAX_FRAMES:
|
|
|
+ traj.pop(0)
|
|
|
+ if traj[-1]['t'] - traj[0]['t'] > self.TRAJ_WINDOW_SECS:
|
|
|
+ traj.pop(0)
|
|
|
+
|
|
|
+ def _window_traj(self, side: str):
|
|
|
+ """返回该侧轨迹窗口(最近 TRAJ_WINDOW_SECS)"""
|
|
|
+ traj = self._pair_traj[side]
|
|
|
+ if not traj:
|
|
|
+ return traj
|
|
|
+ now = traj[-1]['t']
|
|
|
+ return [p for p in traj if now - p['t'] <= self.TRAJ_WINDOW_SECS]
|
|
|
+
|
|
|
+ def _update_suppress_window(self):
|
|
|
+ """基于轨迹判定放包/分离阶段,命中则顺延屏蔽语音"""
|
|
|
+ now = time.time()
|
|
|
+ hit = ""
|
|
|
+ for side in ("left", "right"):
|
|
|
+ win = self._window_traj(side)
|
|
|
+ if len(win) < 5:
|
|
|
+ continue
|
|
|
+ first, last = win[0], win[-1]
|
|
|
+ dy_h = last['hook_cy'] - first['hook_cy']
|
|
|
+ dy_l = last['lug_cy'] - first['lug_cy']
|
|
|
+ min_dist = min(p['dist'] for p in win)
|
|
|
+ max_dist = max(p['dist'] for p in win)
|
|
|
+ # 放包:hook 与 lug 同步下移,且距离保持贴合
|
|
|
+ if dy_h >= self.RELEASE_TRAJ_Y and dy_l >= self.RELEASE_TRAJ_Y and min_dist < self.ENGAGE_DIST:
|
|
|
+ hit = "放包"
|
|
|
+ # 分离:距离相离(任意方向),且之前曾贴合
|
|
|
+ elif max_dist - min_dist >= self.SEPARATE_TRAJ_DIST and min_dist < self.ENGAGE_DIST:
|
|
|
+ hit = "分离"
|
|
|
+ if hit:
|
|
|
+ self._voice_suppress_until = max(now, self._voice_suppress_until) + self.SUPPRESS_SECONDS
|
|
|
+ self._trace_phase = hit
|
|
|
+ else:
|
|
|
+ self._trace_phase = ""
|
|
|
+
|
|
|
+ def _is_voice_suppressed(self) -> bool:
|
|
|
+ return time.time() < self._voice_suppress_until
|
|
|
+
|
|
|
+ def _maybe_clear_stale_overlay(self):
|
|
|
+ """MQTT 消息超时后清除残留画框/轨迹,避免画面卡住旧框"""
|
|
|
+ now = time.time()
|
|
|
+ if not self.ai_enabled or self._overlay_cleared:
|
|
|
+ return
|
|
|
+ if self._last_mqtt_process > 0 and now - self._last_mqtt_process > self.MQTT_CLEAR_TIMEOUT:
|
|
|
+ self._last_left_draw = []
|
|
|
+ self._last_right_draw = []
|
|
|
+ self._pair_traj = {"left": [], "right": []}
|
|
|
+ self._trace_phase = ""
|
|
|
+ self._voice_suppress_until = 0.0
|
|
|
+ if self.left_player:
|
|
|
+ self.left_player.set_ai_objects([])
|
|
|
+ if self.right_player:
|
|
|
+ self.right_player.set_ai_objects([])
|
|
|
+ self._overlay_cleared = True
|
|
|
+
|
|
|
+ def _trace_objects(self, side: str) -> list:
|
|
|
+ """构造叠加对象:挂钩轨迹、吊耳轨迹、挂钩-吊耳连线、阶段文本"""
|
|
|
+ if not self.debug_trace:
|
|
|
+ return []
|
|
|
+ win = self._window_traj(side)
|
|
|
+ if len(win) < 2:
|
|
|
+ return []
|
|
|
+ hook_pts = [(p['hook_cx'], p['hook_cy']) for p in win]
|
|
|
+ lug_pts = [(p['lug_cx'], p['lug_cy']) for p in win]
|
|
|
+ objs = [
|
|
|
+ {"points": hook_pts, "color": (0, 255, 0), "alpha": 0.4, "thickness": 1},
|
|
|
+ {"points": lug_pts, "color": (0, 255, 255), "alpha": 0.4, "thickness": 1},
|
|
|
+ ]
|
|
|
+ hx, hy = win[-1]['hook_cx'], win[-1]['hook_cy']
|
|
|
+ lx, ly = win[-1]['lug_cx'], win[-1]['lug_cy']
|
|
|
+ color = (0, 0, 255) if self._trace_phase == "分离" else (255, 165, 0)
|
|
|
+ objs.append({"points": [(hx, hy), (lx, ly)], "color": color, "alpha": 0.4, "thickness": 1})
|
|
|
+ return objs
|
|
|
+
|
|
|
+ def _trace_status_text(self) -> str:
|
|
|
+ if not self.debug_trace:
|
|
|
+ return ""
|
|
|
+ if self._trace_phase:
|
|
|
+ remain = max(0.0, self._voice_suppress_until - time.time())
|
|
|
+ tag = "RELEASE" if self._trace_phase == "放包" else "SEP"
|
|
|
+ return f"{tag} {remain:.1f}s"
|
|
|
+ return ""
|
|
|
+
|
|
|
+ # ---------- UI 更新与语音 ----------
|
|
|
+ def _clear_state_display(self):
|
|
|
+ """吊耳被画面上沿裁切时不判定:状态文字清空,边框恢复默认"""
|
|
|
+ self.hook_status_label.setText("")
|
|
|
+ style = """
|
|
|
+ QLabel {
|
|
|
+ border: 4px solid #dcdfe6;
|
|
|
+ border-radius: 8px;
|
|
|
+ background-color: black;
|
|
|
+ }
|
|
|
+ """
|
|
|
+ self.left_label.setStyleSheet(style)
|
|
|
+ self.right_label.setStyleSheet(style)
|
|
|
+
|
|
|
+ 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):
|
|
|
+ now = time.time()
|
|
|
+ if self._is_voice_suppressed():
|
|
|
+ return
|
|
|
+ if state == HookState.HOOKING:
|
|
|
+ if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
|
|
|
+ self._speak("挂包中")
|
|
|
+ self.last_voice_state = state
|
|
|
+ self._last_hooking_voice_time = now
|
|
|
+ elif state != self.last_voice_state:
|
|
|
+ self._speak("挂包成功")
|
|
|
+ self.last_voice_state = state
|
|
|
+
|
|
|
+ # ---------- 定时任务 ----------
|
|
|
+ def check_idle(self):
|
|
|
+ if self.rfid_client:
|
|
|
+ self.rfid_client.check_idle()
|
|
|
+ self._maybe_clear_stale_overlay()
|
|
|
+ if self._calib_active:
|
|
|
+ self._sample_calibration()
|
|
|
+ now = time.time()
|
|
|
+ if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
|
|
|
+ if now - self._last_mqtt_connect_attempt > 30:
|
|
|
+ self._last_mqtt_connect_attempt = now
|
|
|
+ self._connect_current_ai()
|
|
|
+
|
|
|
+ # 每 5 秒检测一次摄像头信号
|
|
|
+ if now - self._last_signal_check > 5:
|
|
|
+ self._last_signal_check = now
|
|
|
+ if not getattr(self, '_signal_check_thread', None) or not self._signal_check_thread.is_alive():
|
|
|
+ self._signal_check_thread = threading.Thread(target=self._check_all_cameras, daemon=True)
|
|
|
+ self._signal_check_thread.start()
|
|
|
+
|
|
|
+ def _collect_camera_ips(self) -> list:
|
|
|
+ ips = set()
|
|
|
+ for bucket in self.buckets:
|
|
|
+ for cam in bucket.cameras:
|
|
|
+ m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
|
|
|
+ if m:
|
|
|
+ ips.add(m.group(1))
|
|
|
+ return sorted(ips)
|
|
|
+
|
|
|
+ def _check_all_cameras(self):
|
|
|
+ ips = self._collect_camera_ips()
|
|
|
+ if not ips:
|
|
|
+ return
|
|
|
+
|
|
|
+ def check_one(ip: str) -> tuple:
|
|
|
+ try:
|
|
|
+ import socket
|
|
|
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
|
+ s.settimeout(2)
|
|
|
+ r = s.connect_ex((ip, 554))
|
|
|
+ s.close()
|
|
|
+ return (ip, 0 if r == 0 else -1)
|
|
|
+ except Exception:
|
|
|
+ return (ip, -1)
|
|
|
+
|
|
|
+ with ThreadPoolExecutor(max_workers=len(ips)) as pool:
|
|
|
+ results = dict(pool.map(check_one, ips))
|
|
|
+ self.camera_signal_signal.emit(results)
|
|
|
+
|
|
|
+ def _on_camera_signal(self, results: dict):
|
|
|
+ was_offline = {ip for ip, lat in self._camera_signal.items() if lat is not None and lat < 0}
|
|
|
+ self._camera_signal.update(results)
|
|
|
+ now_online = {ip for ip, lat in results.items() if lat >= 0}
|
|
|
+ revived = now_online & was_offline
|
|
|
+ went_offline = {ip for ip, lat in results.items() if lat < 0}
|
|
|
+ if went_offline and self.active_bucket:
|
|
|
+ for cam in self.active_bucket.cameras:
|
|
|
+ m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
|
|
|
+ if m and m.group(1) in went_offline:
|
|
|
+ ip = m.group(1)
|
|
|
+ if ip not in self._offline_reported and self.voice_enabled:
|
|
|
+ self._offline_reported.add(ip)
|
|
|
+ side_name = "左" if cam.position == CameraPosition.LEFT else "右"
|
|
|
+ self._speak(f"{side_name}侧摄像头离线")
|
|
|
+ logger.warning(f"摄像头离线: {ip}")
|
|
|
+ side = "left" if cam.position == CameraPosition.LEFT else "right"
|
|
|
+ label = self.left_label if side == "left" else self.right_label
|
|
|
+ cur = self.left_player if side == "left" else self.right_player
|
|
|
+ if cur is not None:
|
|
|
+ cur.stop()
|
|
|
+ if side == "left":
|
|
|
+ self.left_player = None
|
|
|
+ else:
|
|
|
+ self.right_player = None
|
|
|
+ self._cancel_rtsp_retry(side)
|
|
|
+ self.show_timeout_on_label(label)
|
|
|
+ if revived and self.active_bucket:
|
|
|
+ for cam in self.active_bucket.cameras:
|
|
|
+ m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
|
|
|
+ if m and m.group(1) in revived:
|
|
|
+ self._offline_reported.discard(m.group(1))
|
|
|
+ side = "left" if cam.position == CameraPosition.LEFT else "right"
|
|
|
+ cur = self.left_player if side == "left" else self.right_player
|
|
|
+ if cur is None:
|
|
|
+ self._cancel_rtsp_retry(side)
|
|
|
+ self._retry_rtsp(side)
|
|
|
+ for bucket in self.buckets:
|
|
|
+ bars = self._bucket_signal_labels.get(bucket.id)
|
|
|
+ if not bars:
|
|
|
+ continue
|
|
|
+ left_bar, right_bar = bars
|
|
|
+ for bar, cam in zip((left_bar, right_bar), bucket.cameras):
|
|
|
+ m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
|
|
|
+ if not m:
|
|
|
+ bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
|
|
|
+ continue
|
|
|
+ ip = m.group(1)
|
|
|
+ lat = self._camera_signal.get(ip)
|
|
|
+ if lat is None:
|
|
|
+ bar.setStyleSheet("background-color: #dcdfe6; border-radius: 2px;")
|
|
|
+ elif lat < 0:
|
|
|
+ bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
|
|
|
+ elif lat < 50:
|
|
|
+ bar.setStyleSheet("background-color: #67c23a; border-radius: 2px;")
|
|
|
+ elif lat < 200:
|
|
|
+ bar.setStyleSheet("background-color: #e6a23c; border-radius: 2px;")
|
|
|
+ else:
|
|
|
+ bar.setStyleSheet("background-color: #f56c6c; border-radius: 2px;")
|
|
|
+
|
|
|
+ def toggle_switch_mode(self, checked: bool):
|
|
|
+ if checked:
|
|
|
+ self.switch_mode = SwitchMode.AUTO
|
|
|
+ self.auto_switch_btn.setText("自动模式")
|
|
|
+ if self._last_rfid_tag:
|
|
|
+ self._on_rfid_tag_impl(self._last_rfid_tag)
|
|
|
+ elif self.active_bucket:
|
|
|
+ self.activate_bucket(None)
|
|
|
+ else:
|
|
|
+ self.switch_mode = SwitchMode.HAND
|
|
|
+ self.auto_switch_btn.setText("手动模式")
|
|
|
+
|
|
|
+ # ---------- 配置保存 ----------
|
|
|
+ def _find_bucket_section(self, cfg, bucket_id):
|
|
|
+ for section in cfg.sections():
|
|
|
+ if not re.match(r'^Bucket:\d+$', section):
|
|
|
+ continue
|
|
|
+ try:
|
|
|
+ if cfg.getint(section, "id") == bucket_id:
|
|
|
+ return section
|
|
|
+ except (ValueError, configparser.Error):
|
|
|
+ continue
|
|
|
+ return None
|
|
|
+
|
|
|
+ def _save_config(self):
|
|
|
+ import configparser
|
|
|
+ cfg = configparser.ConfigParser()
|
|
|
+ cfg.read(self._config_path, encoding="utf-8")
|
|
|
+ if not cfg.has_section("AI"):
|
|
|
+ cfg.add_section("AI")
|
|
|
+ val = "true" if self.config.ai.tts_enabled else "false"
|
|
|
+ cfg.set("AI", "tts_enabled", val)
|
|
|
+ cfg.set("AI", "offset_ratio_tolerance", str(self.config.ai.offset_ratio_tolerance))
|
|
|
+ cfg.set("AI", "ready_debounce", str(self.config.ai.ready_debounce))
|
|
|
+ cfg.set("AI", "auto_calib_ready_frames", str(self.config.ai.auto_calib_ready_frames))
|
|
|
+ cfg.set("AI", "auto_calib_duration_secs", str(self.config.ai.auto_calib_duration_secs))
|
|
|
+ cfg.set("AI", "auto_calib_min_samples", str(self.config.ai.auto_calib_min_samples))
|
|
|
+ for bucket in self.buckets:
|
|
|
+ section = self._find_bucket_section(cfg, bucket.id)
|
|
|
+ if not section:
|
|
|
+ continue
|
|
|
+ for side in ("left", "right"):
|
|
|
+ x = getattr(bucket, f"hook_lug_x_offset_ratio_{side}", None)
|
|
|
+ y = getattr(bucket, f"hook_lug_y_offset_ratio_{side}", None)
|
|
|
+ key_x = f"hook_lug_x_offset_ratio_{side}"
|
|
|
+ key_y = f"hook_lug_y_offset_ratio_{side}"
|
|
|
+ if x is not None and y is not None:
|
|
|
+ cfg.set(section, key_x, f"{x:.6f}")
|
|
|
+ cfg.set(section, key_y, f"{y:.6f}")
|
|
|
+ else:
|
|
|
+ cfg.remove_option(section, key_x)
|
|
|
+ cfg.remove_option(section, key_y)
|
|
|
+ if not cfg.has_section("Video"):
|
|
|
+ cfg.add_section("Video")
|
|
|
+ cfg.set("Video", "ratio_16_9", "true" if self._ratio_16_9 else "false")
|
|
|
+ with open(self._config_path, "w", encoding="utf-8") as f:
|
|
|
+ cfg.write(f)
|
|
|
+ logger.info(f"配置已保存: {self._config_path}")
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _autostart_config_dir():
|
|
|
+ if sys.platform == "win32":
|
|
|
+ return os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
|
|
|
+ return os.path.join(os.path.expanduser("~"), ".config", "HookVision")
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _autostart_config_path():
|
|
|
+ return os.path.join(MainWindow._autostart_config_dir(), "autostart.txt") # 改为 txt
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _autostart_desktop_path():
|
|
|
+ return os.path.expanduser("~/.config/autostart/HookVision.desktop")
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _read_autostart_flag():
|
|
|
+ path = MainWindow._autostart_config_path()
|
|
|
+ try:
|
|
|
+ with open(path, "r", encoding="utf-8") as f:
|
|
|
+ content = f.read().strip().lower()
|
|
|
+ return content == "true"
|
|
|
+ except:
|
|
|
+ return False
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _write_autostart_flag(enabled: bool):
|
|
|
+ path = MainWindow._autostart_config_path()
|
|
|
+ os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
|
+ with open(path, "w", encoding="utf-8") as f:
|
|
|
+ f.write("true" if enabled else "false")
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _autostart_command():
|
|
|
+ if getattr(sys, 'frozen', False):
|
|
|
+ return sys.executable
|
|
|
+ else:
|
|
|
+ main_script = os.path.abspath(sys.argv[0])
|
|
|
+ python_exe = sys.executable
|
|
|
+ if ' ' in python_exe:
|
|
|
+ python_exe = f'"{python_exe}"'
|
|
|
+ if ' ' in main_script:
|
|
|
+ main_script = f'"{main_script}"'
|
|
|
+ if sys.platform == "win32":
|
|
|
+ pythonw = python_exe.replace('python.exe', 'pythonw.exe')
|
|
|
+ if pythonw != python_exe and os.path.exists(pythonw):
|
|
|
+ python_exe = pythonw
|
|
|
+ return f'{python_exe} {main_script}'
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _apply_system_autostart(enabled: bool) -> bool:
|
|
|
+ cmd = MainWindow._autostart_command()
|
|
|
+ try:
|
|
|
+ if sys.platform == "win32":
|
|
|
+ import winreg
|
|
|
+ key = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
|
|
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
|
|
|
+ if enabled:
|
|
|
+ winreg.SetValueEx(rk, MainWindow.APPTITLE, 0, winreg.REG_SZ, cmd)
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ winreg.DeleteValue(rk, MainWindow.APPTITLE)
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
+ return True
|
|
|
+ else:
|
|
|
+ desktop_path = MainWindow._autostart_desktop_path()
|
|
|
+ if enabled:
|
|
|
+ os.makedirs(os.path.dirname(desktop_path), exist_ok=True)
|
|
|
+ with open(desktop_path, "w", encoding="utf-8") as f:
|
|
|
+ f.write(f"""[Desktop Entry]
|
|
|
+Type=Application
|
|
|
+Name={MainWindow.APPTITLE}
|
|
|
+Exec={cmd}
|
|
|
+Terminal=false
|
|
|
+X-GNOME-Autostart-enabled=true
|
|
|
+""")
|
|
|
+ else:
|
|
|
+ try:
|
|
|
+ os.remove(desktop_path)
|
|
|
+ except OSError:
|
|
|
+ pass
|
|
|
+ return True
|
|
|
+ except Exception as e:
|
|
|
+ logger.error(f"设置开机自启失败: {e}")
|
|
|
+ return False
|
|
|
+
|
|
|
+ def _on_autostart_toggle(self, state):
|
|
|
+ enabled = (state == Qt.Checked)
|
|
|
+ self._write_autostart_flag(enabled)
|
|
|
+ ok = self._apply_system_autostart(enabled)
|
|
|
+ if not ok:
|
|
|
+ QMessageBox.warning(
|
|
|
+ self,
|
|
|
+ "设置失败",
|
|
|
+ "开机自启动设置失败,请检查权限或手动添加。\n"
|
|
|
+ "Windows: 将本程序快捷方式放入 '启动' 文件夹。\n"
|
|
|
+ "Linux: 检查 ~/.config/autostart/ 目录权限。"
|
|
|
+ )
|