|
|
@@ -1,12 +1,17 @@
|
|
|
import os
|
|
|
+import sys
|
|
|
import time
|
|
|
+import re
|
|
|
import logging
|
|
|
import threading
|
|
|
import queue
|
|
|
+import subprocess
|
|
|
+from collections import defaultdict
|
|
|
from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
|
QPushButton, QLabel, QGridLayout, QScrollArea,
|
|
|
- QToolButton, QFrame, QApplication, QCheckBox)
|
|
|
-from PySide6.QtCore import Qt, QTimer, QSize
|
|
|
+ QToolButton, QFrame, QApplication, QCheckBox,
|
|
|
+ QMessageBox, QSlider)
|
|
|
+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
|
|
|
@@ -21,6 +26,15 @@ class MainWindow(QMainWindow):
|
|
|
STATE_THRESHOLD = 3
|
|
|
# 放包判断位移阈值(像素)
|
|
|
RELEASE_DELTA_THRESHOLD = 5
|
|
|
+ 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)
|
|
|
|
|
|
def __init__(self, config: Config):
|
|
|
super().__init__()
|
|
|
@@ -28,6 +42,8 @@ class MainWindow(QMainWindow):
|
|
|
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
|
|
|
@@ -35,6 +51,7 @@ class MainWindow(QMainWindow):
|
|
|
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.left_label = None
|
|
|
@@ -58,6 +75,7 @@ class MainWindow(QMainWindow):
|
|
|
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 = []
|
|
|
@@ -65,7 +83,30 @@ class MainWindow(QMainWindow):
|
|
|
self._voice_thread = threading.Thread(target=self._voice_worker, daemon=True)
|
|
|
self._voice_thread.start()
|
|
|
|
|
|
+ # 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._camera_signal: dict = {} # ip → 延迟(ms), -1=不可达
|
|
|
+ self._bucket_signal_labels: dict = {} # bucket.id → QLabel
|
|
|
+ self._last_signal_check: float = 0.0
|
|
|
+
|
|
|
+ # 连接跨线程信号到主线程槽
|
|
|
+ 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.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()
|
|
|
@@ -99,6 +140,13 @@ class MainWindow(QMainWindow):
|
|
|
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)
|
|
|
@@ -119,11 +167,16 @@ class MainWindow(QMainWindow):
|
|
|
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)
|
|
|
|
|
|
@@ -143,30 +196,23 @@ class MainWindow(QMainWindow):
|
|
|
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
|
|
|
-
|
|
|
+ # 按 code_x 分组纵向排列
|
|
|
groups = defaultdict(list)
|
|
|
for bucket in self.buckets:
|
|
|
groups[bucket.code_x].append(bucket)
|
|
|
|
|
|
sorted_keys = sorted(groups.keys())
|
|
|
- self.bucket_buttons = [] # 重置
|
|
|
+ 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)
|
|
|
@@ -181,17 +227,28 @@ class MainWindow(QMainWindow):
|
|
|
}
|
|
|
""")
|
|
|
btn.clicked.connect(lambda checked, b=bucket: self.on_bucket_clicked(b))
|
|
|
- self.bucket_grid.addWidget(btn, row, col)
|
|
|
+ cell_layout.addWidget(btn)
|
|
|
+
|
|
|
+ # 摄像头信号指示
|
|
|
+ has_rtsp = any(c.rtsp_url for c in bucket.cameras)
|
|
|
+ sig_label = QLabel("" if not has_rtsp else "● --")
|
|
|
+ sig_label.setAlignment(Qt.AlignCenter)
|
|
|
+ sig_label.setFixedHeight(18)
|
|
|
+ if has_rtsp:
|
|
|
+ sig_label.setStyleSheet("font-size: 12px; color: #909399;")
|
|
|
+ cell_layout.addWidget(sig_label)
|
|
|
+ self._bucket_signal_labels[bucket.id] = sig_label
|
|
|
+
|
|
|
+ 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)
|
|
|
@@ -240,31 +297,50 @@ class MainWindow(QMainWindow):
|
|
|
self.voice_checkbox.setStyleSheet("color: #606266; font-weight: bold;")
|
|
|
self.voice_checkbox.stateChanged.connect(self.on_voice_toggle)
|
|
|
|
|
|
+ self.volume_slider = QSlider(Qt.Horizontal)
|
|
|
+ self.volume_slider.setRange(0, 100)
|
|
|
+ self.volume_slider.setValue(self._voice_volume)
|
|
|
+ self.volume_slider.setFixedWidth(80)
|
|
|
+ self.volume_slider.setToolTip(f"音量: {self._voice_volume}%")
|
|
|
+ self.volume_slider.valueChanged.connect(self._on_volume_changed)
|
|
|
+ self.volume_slider.setStyleSheet("""
|
|
|
+ QSlider::groove:horizontal { height: 4px; background: #dcdfe6; border-radius: 2px; }
|
|
|
+ QSlider::handle:horizontal { width: 12px; margin: -4px 0; border-radius: 6px; background: #409eff; }
|
|
|
+ QSlider::sub-page:horizontal { background: #409eff; border-radius: 2px; }
|
|
|
+ """)
|
|
|
+
|
|
|
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 = QPushButton("自动模式")
|
|
|
- self.auto_switch_btn.setCheckable(True)
|
|
|
+ self.auto_switch_btn = QCheckBox("自动模式")
|
|
|
self.auto_switch_btn.setChecked(True)
|
|
|
- self.auto_switch_btn.setStyleSheet("border-radius: 15px; padding: 5px 15px; font-weight: bold;")
|
|
|
+ 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.volume_slider)
|
|
|
status_layout.addWidget(self.ai_checkbox)
|
|
|
status_layout.addWidget(self.auto_switch_btn)
|
|
|
+ status_layout.addWidget(self.autostart_checkbox)
|
|
|
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:
|
|
|
@@ -281,11 +357,23 @@ class MainWindow(QMainWindow):
|
|
|
def on_voice_toggle(self, state):
|
|
|
self.voice_enabled = (state == Qt.Checked)
|
|
|
|
|
|
+ def _on_volume_changed(self, value: int):
|
|
|
+ self._voice_volume = value
|
|
|
+ self.volume_slider.setToolTip(f"音量: {value}%")
|
|
|
+
|
|
|
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
|
|
|
@@ -302,20 +390,22 @@ class MainWindow(QMainWindow):
|
|
|
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)
|
|
|
|
|
|
def _speak(self, msg: str):
|
|
|
+ self.voice_signal.emit(msg)
|
|
|
+
|
|
|
+ def _speak_impl(self, msg: str):
|
|
|
self._voice_queue.put(msg)
|
|
|
|
|
|
def _voice_worker(self):
|
|
|
try:
|
|
|
import pythoncom
|
|
|
pythoncom.CoInitialize()
|
|
|
- except ImportError:
|
|
|
+ except Exception:
|
|
|
pass
|
|
|
try:
|
|
|
import win32com.client as _win32com
|
|
|
@@ -329,6 +419,7 @@ class MainWindow(QMainWindow):
|
|
|
try:
|
|
|
speaker = _win32com.Dispatch("SAPI.SpVoice")
|
|
|
speaker.Rate = 2
|
|
|
+ speaker.Volume = self._voice_volume
|
|
|
speaker.Speak(msg)
|
|
|
except Exception as e:
|
|
|
logging.warning(f"语音播报失败: {e}")
|
|
|
@@ -419,6 +510,12 @@ class MainWindow(QMainWindow):
|
|
|
else:
|
|
|
super().keyPressEvent(event)
|
|
|
|
|
|
+ def _toggle_fullscreen(self):
|
|
|
+ if self.isFullScreen():
|
|
|
+ self.showNormal()
|
|
|
+ else:
|
|
|
+ self.showFullScreen()
|
|
|
+
|
|
|
# ---------- 加载动画和超时 ----------
|
|
|
def show_loading_on_label(self, label: QLabel):
|
|
|
label.setText("加载中...")
|
|
|
@@ -442,12 +539,80 @@ class MainWindow(QMainWindow):
|
|
|
|
|
|
def on_player_timeout(self, label: QLabel, player_ref, side: str):
|
|
|
self.show_timeout_on_label(label)
|
|
|
- if side == "left" and self.left_player:
|
|
|
+ # 只停止当前活动的播放器(防止旧信号误杀新播放器)
|
|
|
+ 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:
|
|
|
+ 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
|
|
|
+ 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
|
|
|
+ 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: self._on_rtsp_retry_success(side))
|
|
|
+ player.timeout.connect(lambda: self.on_player_timeout(label, player, side))
|
|
|
+ if side == "left":
|
|
|
+ if self.left_player:
|
|
|
+ self.left_player.stop()
|
|
|
+ self.left_player = player
|
|
|
+ else:
|
|
|
+ if self.right_player:
|
|
|
+ self.right_player.stop()
|
|
|
+ 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 on_bucket_clicked(self, bucket: BucketModel):
|
|
|
@@ -458,6 +623,9 @@ class MainWindow(QMainWindow):
|
|
|
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:
|
|
|
@@ -486,13 +654,34 @@ class MainWindow(QMainWindow):
|
|
|
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()
|
|
|
+ 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}")
|
|
|
if self.left_player:
|
|
|
@@ -523,8 +712,26 @@ class MainWindow(QMainWindow):
|
|
|
self.prev_right_lug = None
|
|
|
self.last_voice_state = None
|
|
|
self._last_hooking_voice_time = 0.0
|
|
|
+ self.update_ui_for_state(HookState.IDLE)
|
|
|
+
|
|
|
+ # 先切 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)
|
|
|
|
|
|
@@ -549,17 +756,24 @@ class MainWindow(QMainWindow):
|
|
|
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._unsubscribe_all_topics()
|
|
|
self.left_label.clear()
|
|
|
self.right_label.clear()
|
|
|
+ QApplication.processEvents()
|
|
|
+
|
|
|
+ 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:
|
|
|
@@ -577,15 +791,32 @@ class MainWindow(QMainWindow):
|
|
|
|
|
|
# ---------- 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 = 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
|
|
|
+ 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,
|
|
|
@@ -606,23 +837,21 @@ class MainWindow(QMainWindow):
|
|
|
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) # 红色
|
|
|
+ color = (0, 0, 255)
|
|
|
elif 'lug' in label:
|
|
|
- color = (255, 0, 0) # 蓝色
|
|
|
+ color = (255, 0, 0)
|
|
|
elif 'ready' in label:
|
|
|
if not include_ready:
|
|
|
continue
|
|
|
- color = (0, 255, 255) # 黄色(不在画面上显示,仅用于内部)
|
|
|
+ color = (0, 255, 255)
|
|
|
else:
|
|
|
- color = (128, 128, 128) # 灰色(其他类别)
|
|
|
+ color = (128, 128, 128)
|
|
|
objects.append({
|
|
|
"x": x,
|
|
|
"y": y,
|
|
|
@@ -637,6 +866,9 @@ class MainWindow(QMainWindow):
|
|
|
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:
|
|
|
@@ -656,7 +888,6 @@ class MainWindow(QMainWindow):
|
|
|
display_width = 640
|
|
|
display_height = 360
|
|
|
|
|
|
- # 解析所有对象(包括 ready)
|
|
|
all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
|
|
|
|
|
|
if cam.position == CameraPosition.LEFT:
|
|
|
@@ -664,10 +895,8 @@ class MainWindow(QMainWindow):
|
|
|
else:
|
|
|
self.right_objects = all_objects
|
|
|
|
|
|
- # 触发状态评估(会标记配对的 hook/lug)
|
|
|
self.evaluate_hook_state()
|
|
|
|
|
|
- # 构造绘制对象(排除 ready,无关目标变淡)
|
|
|
draw_objects = [o for o in all_objects if 'ready' not in o['label'].lower()]
|
|
|
for obj in draw_objects:
|
|
|
if not obj.get('paired'):
|
|
|
@@ -712,7 +941,6 @@ class MainWindow(QMainWindow):
|
|
|
|
|
|
is_releasing = False
|
|
|
|
|
|
- # 处理左侧配对
|
|
|
if left_pair:
|
|
|
h, l = left_pair
|
|
|
if self.prev_left_hook and self.prev_left_lug:
|
|
|
@@ -726,7 +954,6 @@ class MainWindow(QMainWindow):
|
|
|
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:
|
|
|
@@ -740,7 +967,6 @@ class MainWindow(QMainWindow):
|
|
|
self.prev_right_hook = None
|
|
|
self.prev_right_lug = None
|
|
|
|
|
|
- # 状态判断
|
|
|
if is_releasing:
|
|
|
new_state = HookState.RELEASING
|
|
|
else:
|
|
|
@@ -753,8 +979,10 @@ class MainWindow(QMainWindow):
|
|
|
else:
|
|
|
new_state = HookState.IDLE
|
|
|
|
|
|
- # 防抖(非对称:进入非空闲状态需 STATE_THRESHOLD 帧确认,回到空闲只需 1 帧)
|
|
|
- threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
|
|
|
+ if self.hook_state == HookState.READY and new_state != HookState.READY:
|
|
|
+ threshold = 15
|
|
|
+ else:
|
|
|
+ threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
|
|
|
if new_state == self.prev_state:
|
|
|
self.state_counter += 1
|
|
|
else:
|
|
|
@@ -767,7 +995,6 @@ class MainWindow(QMainWindow):
|
|
|
self.update_ui_for_state(new_state)
|
|
|
self.state_counter = 0
|
|
|
|
|
|
- # 标记配对的 hook/lug(用于绘制时变淡无关目标)
|
|
|
for obj in self.left_objects + self.right_objects:
|
|
|
obj.pop('paired', None)
|
|
|
if left_pair:
|
|
|
@@ -811,10 +1038,8 @@ class MainWindow(QMainWindow):
|
|
|
border_color = "#dcdfe6"
|
|
|
text = ""
|
|
|
|
|
|
- # 更新状态栏标签
|
|
|
self.hook_status_label.setText(f"挂钩状态: {text}")
|
|
|
|
|
|
- # 更新视频边框
|
|
|
style = f"""
|
|
|
QLabel {{
|
|
|
border: 4px solid {border_color};
|
|
|
@@ -825,7 +1050,6 @@ class MainWindow(QMainWindow):
|
|
|
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 state == HookState.HOOKING:
|
|
|
@@ -841,11 +1065,191 @@ class MainWindow(QMainWindow):
|
|
|
def check_idle(self):
|
|
|
if self.rfid_client:
|
|
|
self.rfid_client.check_idle()
|
|
|
+ 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()
|
|
|
+
|
|
|
+ # 每 30 秒检测一次摄像头信号
|
|
|
+ if now - self._last_signal_check > 30:
|
|
|
+ self._last_signal_check = now
|
|
|
+ threading.Thread(target=self._check_all_cameras, daemon=True).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
|
|
|
+ results = {}
|
|
|
+ for ip in ips:
|
|
|
+ try:
|
|
|
+ if sys.platform == "win32":
|
|
|
+ r = subprocess.run(["ping", "-n", "1", ip], capture_output=True, timeout=5, text=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
|
+ else:
|
|
|
+ r = subprocess.run(["ping", "-c", "1", ip], capture_output=True, timeout=5, text=True)
|
|
|
+ if r.returncode != 0:
|
|
|
+ results[ip] = -1
|
|
|
+ else:
|
|
|
+ m = re.search(r'time[=<]\s*(\d+(?:\.\d+)?)\s*ms', r.stdout, re.IGNORECASE)
|
|
|
+ if m:
|
|
|
+ results[ip] = float(m.group(1))
|
|
|
+ else:
|
|
|
+ results[ip] = 0
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
+ results[ip] = -1
|
|
|
+ except Exception:
|
|
|
+ results[ip] = -1
|
|
|
+ self.camera_signal_signal.emit(results)
|
|
|
+
|
|
|
+ def _on_camera_signal(self, results: dict):
|
|
|
+ self._camera_signal.update(results)
|
|
|
+ for bucket in self.buckets:
|
|
|
+ label = self._bucket_signal_labels.get(bucket.id)
|
|
|
+ if not label:
|
|
|
+ continue
|
|
|
+ best = None
|
|
|
+ for cam in bucket.cameras:
|
|
|
+ m = re.search(r'rtsp://(?:[^@]+@)?([^:/]+)', cam.rtsp_url)
|
|
|
+ if not m:
|
|
|
+ continue
|
|
|
+ ip = m.group(1)
|
|
|
+ if ip in self._camera_signal:
|
|
|
+ lat = self._camera_signal[ip]
|
|
|
+ if best is None or (lat >= 0 and (best < 0 or lat < best)):
|
|
|
+ best = lat
|
|
|
+ if best is None:
|
|
|
+ label.setText("")
|
|
|
+ label.setStyleSheet("font-size: 12px; color: #909399;")
|
|
|
+ elif best < 0:
|
|
|
+ label.setText("● 断开")
|
|
|
+ label.setStyleSheet("font-size: 12px; color: #f56c6c;")
|
|
|
+ elif best < 50:
|
|
|
+ label.setText(f"● {int(best)}ms")
|
|
|
+ label.setStyleSheet("font-size: 12px; color: #67c23a;")
|
|
|
+ elif best < 200:
|
|
|
+ label.setText(f"● {int(best)}ms")
|
|
|
+ label.setStyleSheet("font-size: 12px; color: #e6a23c;")
|
|
|
+ else:
|
|
|
+ label.setText(f"● {int(best)}ms")
|
|
|
+ label.setStyleSheet("font-size: 12px; color: #f56c6c;")
|
|
|
|
|
|
- def toggle_switch_mode(self, checked):
|
|
|
+ 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("手动模式")
|
|
|
+ self.auto_switch_btn.setText("手动模式")
|
|
|
+
|
|
|
+ # ---------- 开机自启 ----------
|
|
|
+ @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/ 目录权限。"
|
|
|
+ )
|