Bläddra i källkod

feat: 打包为可执行 exe,修复 PyInstaller 下配置文件路径问题

- 添加 PyInstaller 打包支持及构建产物
- 修复 main.py 中 frozen 环境下 config.ini 路径解析
- 更新 config_loader.py 签名,接收显式配置文件路径
- 添加缺失的 hookvision 模块文件(models, config_loader, main_window, mqtt_client)
- 添加 assets、config.ini 等运行时资源
- 更新 README.md
- 更新 .gitignore 排除 .aider* 文件
lsfoo 4 månader sedan
förälder
incheckning
c2d626b14b
12 ändrade filer med 1123 tillägg och 2 borttagningar
  1. 1 0
      .gitignore
  2. 44 2
      README.md
  3. 0 0
      __init__.py
  4. 1 0
      assets/bucket_active.svg
  5. 1 0
      assets/bucket_normal.svg
  6. 63 0
      config.ini
  7. 83 0
      hookvision/config_loader.py
  8. 756 0
      hookvision/main_window.py
  9. 89 0
      hookvision/models.py
  10. 56 0
      hookvision/mqtt_client.py
  11. 25 0
      main.py
  12. 4 0
      requirements.txt.py

+ 1 - 0
.gitignore

@@ -58,3 +58,4 @@ docs/_build/
 # PyBuilder
 target/
 
+.aider*

+ 44 - 2
README.md

@@ -1,3 +1,45 @@
-# hookvision
+# 钢包工位视频监测系统
 
-挂钩视觉
+基于 Python + PySide6 + OpenCV 的跨平台钢包工位监测系统,支持 RTSP 视频预览、RFID/Modbus 自动工位切换、AI 识别标注(MQTT)。
+
+## 功能特性
+
+- 双路 RTSP 实时视频预览(左/右摄像头)
+- RFID Socket 或 Modbus TCP 自动触发工位切换
+- 自动/手动模式切换
+- AI 模块 MQTT 接入,支持目标检测标注
+- 空闲超时自动清空工位
+- 多工位配置管理
+- 跨平台支持(Windows / Linux)
+
+## 安装与运行
+
+1. 安装 Python 3.8+
+2. 安装依赖:`pip install -r requirements.txt`
+3. 根据现场修改 `config.ini`
+4. 运行:`python main.py`
+
+## 配置文件说明
+
+- `[RFID]`:RFID 读卡器 TCP 连接参数
+- `[Modbus]`:Modbus TCP 连接及轮询参数
+- `[AI]`:MQTT 服务器地址及订阅主题
+- `[NVR]`:硬盘录像机信息(预留)
+- `[Bucket]`:工位信息,包含左右摄像头 RTSP 地址和 MQTT 主题
+
+## 依赖库
+
+- PySide6
+- opencv-python
+- pymodbus
+- paho-mqtt
+
+## 注意事项
+
+- RTSP 地址需包含用户名密码(如 `rtsp://user:pass@ip:port/...`)
+- 确保摄像头或 NVR 的 RTSP 服务已开启
+- Linux 下可能需要安装系统 FFmpeg:`sudo apt install ffmpeg`
+
+## 许可证
+
+内部使用

+ 0 - 0
__init__.py


+ 1 - 0
assets/bucket_active.svg

@@ -0,0 +1 @@
+<svg t="1781572218648" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6715" width="200" height="200"><path d="M232.500513 403.08841c36.864 11.172103 81.670564 16.764718 134.43282 16.764718 79.149949 0 89.901949-16.764718 158.601846-16.764718 68.713026 0 59.326359 16.764718 137.189744 16.764718 51.935179 0 103.949128-5.592615 156.094359-16.764718l-28.251897 471.578257H260.174769L232.500513 403.08841z" fill="#EA5423" p-id="6716"></path><path d="M835.085128 101.74359H216.077128a26.25641 26.25641 0 0 0-26.230154 27.437948l33.28 746.653539a26.25641 26.25641 0 0 0 26.230154 25.088h541.210257a26.25641 26.25641 0 0 0 26.217025-24.694154l44.504616-746.666667A26.25641 26.25641 0 0 0 835.085128 101.74359z m-27.871179 52.51282l-41.393231 694.153846H274.458256L243.515077 154.25641h563.698872z" fill="#262626" p-id="6717"></path><path d="M213.582769 190.923487a26.25641 26.25641 0 0 1 1.956103 52.44718l-1.969231 0.065641h-80.646564v248.228102h80.659692a26.25641 26.25641 0 0 1 26.177641 24.300308l0.078769 1.969231a26.25641 26.25641 0 0 1-24.300307 26.177641l-1.969231 0.065641H106.679795a26.25641 26.25641 0 0 1-26.190769-24.28718l-0.065641-1.96923V217.179897a26.25641 26.25641 0 0 1 24.287179-26.177641l1.969231-0.078769h106.916102zM938.666667 185.120821a26.25641 26.25641 0 0 1 26.190769 24.287179l0.065641 1.969231v300.740923a26.25641 26.25641 0 0 1-24.28718 26.190769l-1.96923 0.065641h-106.916103a26.25641 26.25641 0 0 1-1.956102-52.434051l1.96923-0.078769H912.410256V237.633641h-80.659692a26.25641 26.25641 0 0 1-26.177641-24.287179l-0.078769-1.969231a26.25641 26.25641 0 0 1 24.300308-26.177641l1.96923-0.078769H938.666667z" fill="#262626" p-id="6718"></path></svg>

+ 1 - 0
assets/bucket_normal.svg

@@ -0,0 +1 @@
+<svg t="1781572250249" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6882" width="200" height="200"><path d="M199.808 192h624.384l-81.28 704H281.088L199.808 192z m-35.904-64a32 32 0 0 0-31.808 35.648l88.64 768a32 32 0 0 0 31.808 28.352h518.912a32 32 0 0 0 31.808-28.352l88.64-768A32 32 0 0 0 860.096 128H163.904z" fill="#8a8a8a" p-id="6883"></path><path d="M32 288a64 64 0 0 1 64-64H192v64H96v256h128v64h-128a64 64 0 0 1-64-64v-256z m896 0h-64v-64h64a64 64 0 0 1 64 64v256a64 64 0 0 1-64 64h-128v-64h128v-256z" fill="#8a8a8a" p-id="6884"></path></svg>

+ 63 - 0
config.ini

@@ -0,0 +1,63 @@
+[App]
+name = 钢包工位视频监测系统
+copyright = 恒莱达
+screen = 0
+
+[Video]
+rtsp_timeout =  10
+
+[Hardware]
+module_type = RFID
+# module_type = HD3200
+
+[RFID]
+ip = 192.168.1.250
+port = 60000
+stability_time = 5
+stability_count = 3
+
+[Modbus]
+ip = 192.168.1.200
+port = 502
+frequency = 100
+stability_count = 3
+
+[AI]
+enabled = true
+broker = 192.168.1.248
+port = 18830
+topics =
+    ds/channel/#
+
+[NVR]
+count = 1
+
+[NVR:0]
+name = MainNVR
+ip = 192.168.1.64
+port = 8000
+username = admin
+password = 12345
+
+[Bucket]
+count = 2
+
+[Bucket:0]
+id = 1
+description = 1#钢包位
+code_x = 10000001
+code_y = 1
+left_rtsp = rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/101
+left_topic = ds/channel/0
+right_topic = ds/channel/1
+
+[Bucket:1]
+id = 2
+description = 2#钢包位
+code_x = 10000001
+code_y =2
+left_rtsp = rtsp://admin:12345@192.168.1.64:554/Streaming/Channels/201
+right_rtsp = rtsp://admin:12345@192.168.1.64:554/Streaming/Channels/202
+left_topic = ladle/left/cam2
+right_topic = ladle/right/cam2

+ 83 - 0
hookvision/config_loader.py

@@ -0,0 +1,83 @@
+import configparser
+from .models import *
+
+
+def load_config(config_path: str) -> Config:
+    cfg = configparser.ConfigParser()
+    cfg.read(config_path, encoding='utf-8')
+
+    config = Config()
+    config.app_name = cfg.get("App", "name", fallback="钢包工位视频监测系统")
+    config.copyright = cfg.get("App", "copyright", fallback="恒莱达")
+    config.screen = cfg.getint("App", "screen", fallback=0)
+
+    module_type_str = cfg.get("Hardware", "module_type", fallback="RFID")
+    config.module_type = ModuleType.RFID if module_type_str == "RFID" else ModuleType.HD3200
+
+    if cfg.has_section("Video"):
+        config.video.rtsp_timeout = cfg.getfloat("Video", "rtsp_timeout", fallback=5.0)
+
+    # RFID
+    if config.module_type == ModuleType.RFID:
+        config.rfid = SocketConfig(
+            ip=cfg.get("RFID", "ip"),
+            port=cfg.getint("RFID", "port"),
+            stability_time=cfg.getint("RFID", "stability_time", fallback=20),
+            stability_count=cfg.getint("RFID", "stability_count", fallback=3)
+        )
+
+    # Modbus
+    if config.module_type == ModuleType.HD3200:
+        config.modbus = ModbusConfig(
+            ip=cfg.get("Modbus", "ip"),
+            port=cfg.getint("Modbus", "port", fallback=502),
+            frequency=cfg.getint("Modbus", "frequency", fallback=100),
+            stability_count=cfg.getint("Modbus", "stability_count", fallback=3)
+        )
+
+    # AI
+    config.ai.enabled = cfg.getboolean("AI", "enabled", fallback=False)
+    if config.ai.enabled:
+        config.ai.broker = cfg.get("AI", "broker")
+        config.ai.port = cfg.getint("AI", "port", fallback=1883)
+        topics_str = cfg.get("AI", "topics", fallback="")
+        config.ai.topics = [t.strip() for t in topics_str.split('\n') if t.strip()]
+
+    # NVRs
+    nvr_count = cfg.getint("NVR", "count", fallback=0)
+    for i in range(nvr_count):
+        section = f"NVR:{i}"
+        nvr = NVRModel(
+            id=i,
+            name=cfg.get(section, "name"),
+            ip=cfg.get(section, "ip"),
+            port=cfg.getint(section, "port", fallback=8000),
+            username=cfg.get(section, "username", fallback="admin"),
+            password=cfg.get(section, "password", fallback="")
+        )
+        config.nvrs.append(nvr)
+
+    # Buckets
+    bucket_count = cfg.getint("Bucket", "count", fallback=0)
+    for i in range(bucket_count):
+        section = f"Bucket:{i}"
+        bucket = BucketModel(
+            id=cfg.getint(section, "id"),
+            description=cfg.get(section, "description"),
+            code_x=cfg.get(section, "code_x"),
+            code_y=cfg.get(section, "code_y", fallback="")
+        )
+        left_rtsp = cfg.get(section, "left_rtsp")
+        right_rtsp = cfg.get(section, "right_rtsp")
+        left_topic = cfg.get(section, "left_topic", fallback="")
+        right_topic = cfg.get(section, "right_topic", fallback="")
+
+        cameras = []
+        if left_rtsp:
+            cameras.append(CameraModel(position=CameraPosition.LEFT, rtsp_url=left_rtsp, topic=left_topic))
+        if right_rtsp:
+            cameras.append(CameraModel(position=CameraPosition.RIGHT, rtsp_url=right_rtsp, topic=right_topic))
+        bucket.cameras = cameras
+        config.buckets.append(bucket)
+
+    return config

+ 756 - 0
hookvision/main_window.py

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

+ 89 - 0
hookvision/models.py

@@ -0,0 +1,89 @@
+from dataclasses import dataclass, field
+from typing import List, Optional
+from enum import Enum
+
+class ModuleType(Enum):
+    HD3200 = "HD3200"
+    RFID = "RFID"
+
+class CameraPosition(Enum):
+    LEFT = "left"
+    RIGHT = "right"
+
+class CameraState(Enum):
+    ONLINE = "online"
+    OFFLINE = "offline"
+
+class SwitchMode(Enum):
+    AUTO = "auto"
+    HAND = "hand"
+
+@dataclass
+class CameraModel:
+    position: CameraPosition
+    rtsp_url: str
+    topic: Optional[str] = None
+    state: CameraState = CameraState.OFFLINE
+class HookState(Enum):
+    IDLE = "idle"
+    RELEASING = "releasing"   # 放包中
+    HOOKING = "hooking"       # 挂包中
+    READY = "ready"           # 挂包成功
+
+@dataclass
+class BucketModel:
+    id: int
+    description: str
+    code_x: str          # 大车标识(RFID卡号或Modbus点号)
+    code_y: str = ""     # 小车标识(垂直工位)
+    cameras: List[CameraModel] = field(default_factory=list)
+    is_active: bool = False
+    ready_x: bool = False
+    ready_y: bool = True   # 无垂直工位时为True
+
+@dataclass
+class NVRModel:
+    id: int
+    name: str
+    ip: str
+    port: int = 8000
+    username: str = "admin"
+    password: str = ""
+
+@dataclass
+class AIConfig:
+    enabled: bool = False
+    broker: str = "127.0.0.1"
+    port: int = 1883
+    topics: List[str] = field(default_factory=list)
+
+@dataclass
+class ModbusConfig:
+    ip: str
+    port: int = 502
+    frequency: int = 100
+    stability_count: int = 3
+
+@dataclass
+class SocketConfig:
+    ip: str
+    port: int = 6000
+    stability_time: int = 20
+    stability_count: int = 3
+
+@dataclass
+class VideoConfig:
+    rtsp_timeout: float = 5.0   # 默认 5 秒
+
+@dataclass
+class Config:
+    app_name: str = "钢包工位视频监测系统"
+    copyright: str = "恒莱达"
+    screen: int = 0
+    module_type: ModuleType = ModuleType.RFID
+    rfid: Optional[SocketConfig] = None
+    modbus: Optional[ModbusConfig] = None
+    ai: AIConfig = field(default_factory=AIConfig)
+    buckets: List[BucketModel] = field(default_factory=list)
+    nvrs: List[NVRModel] = field(default_factory=list)
+    video: VideoConfig = field(default_factory=VideoConfig)

+ 56 - 0
hookvision/mqtt_client.py

@@ -0,0 +1,56 @@
+import paho.mqtt.client as mqtt
+import json
+import logging
+from typing import Callable
+
+class MQTTClient:
+    def __init__(self, config, on_message_callback: Callable[[str, dict], None]):
+        self.config = config
+        self.on_message_callback = on_message_callback
+        self.client = mqtt.Client()
+        self.client.on_connect = self._on_connect
+        self.client.on_message = self._on_message
+        self.connected = False
+
+    def connect(self):
+        if not self.config.enabled:
+            return
+        try:
+            self.client.connect(self.config.broker, self.config.port, 60)
+            self.client.loop_start()
+            self.connected = True
+        except Exception as e:
+            logging.error(f"MQTT连接失败: {e}")
+            self.connected = False
+
+    def disconnect(self):
+        if self.connected:
+            self.client.loop_stop()
+            self.client.disconnect()
+
+    def subscribe(self, topics):
+        if not self.connected:
+            return
+        for topic in topics:
+            self.client.subscribe(topic)
+
+    def _on_connect(self, client, userdata, flags, rc):
+        if rc == 0:
+            self.connected = True
+            for topic in self.config.topics:
+                client.subscribe(topic)
+        else:
+            logging.warning(f"MQTT连接失败,返回码: {rc}")
+
+    def _on_message(self, client, userdata, msg):
+        try:
+            payload_str = msg.payload.decode('utf-8')
+            if not payload_str.strip():
+                return
+            payload = json.loads(payload_str)
+            if payload is not None:
+                self.on_message_callback(msg.topic, payload)
+            else:
+                logging.debug("收到 null payload,忽略")
+        except Exception as e:
+            logging.exception(f"MQTT消息解析错误")  # 打印堆栈

+ 25 - 0
main.py

@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+import sys
+import os
+from PySide6.QtWidgets import QApplication
+from qt_material import apply_stylesheet
+from hookvision.config_loader import load_config
+from hookvision.main_window import MainWindow
+import logging
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+
+def main():
+    app = QApplication(sys.argv)
+    apply_stylesheet(app, theme='dark_teal.xml')
+
+    if getattr(sys, 'frozen', False):
+        config_path = os.path.join(sys._MEIPASS, 'config.ini')
+    else:
+        config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'config.ini')
+    config = load_config(config_path)
+    window = MainWindow(config)
+    window.show()
+    sys.exit(app.exec())
+
+if __name__ == "__main__":
+    main()

+ 4 - 0
requirements.txt.py

@@ -0,0 +1,4 @@
+PySide6>=6.5.0
+opencv-python>=4.8.0
+pymodbus>=3.5.0
+paho-mqtt>=1.6.0