Browse Source

新增RK3578二进制部署、AI标定与自动重启功能;main入口切换至main_window_v2

lsfoo 6 days ago
parent
commit
e8c7a5a255

+ 4 - 0
.gitignore

@@ -60,3 +60,7 @@ target/
 
 
 .aider*
 .aider*
 autostart_diag.txt
 autostart_diag.txt
+
+# 构建产物(部署包)
+deploy/hookvision_rk3578/
+deploy/*.tar.gz

+ 99 - 15
README.md

@@ -14,32 +14,116 @@
 
 
 ## 安装与运行
 ## 安装与运行
 
 
+### 方式一:源码运行(开发环境)
+
 1. 安装 Python 3.8+
 1. 安装 Python 3.8+
 2. 安装依赖:`pip install -r requirements.txt`
 2. 安装依赖:`pip install -r requirements.txt`
-3. 根据现场修改 `config.ini`
-4. 运行:`python main.py`
+3. 根据现场修改 `config.dev.ini`
+4. 运行:`python main.py --dev`
+
+### 方式二:二进制一键部署(生产环境,推荐)
+
+部署包为 PyInstaller 编译的二进制版本,**不含 Python 源码**,无需安装 Python 环境,**支持离线部署**。
+
+#### 一键部署
+
+```bash
+bash deploy/deploy.sh <目标IP> <用户名> <密码>
+
+# 示例
+bash deploy/deploy.sh 192.168.1.211 root 123456
+```
+
+脚本自动完成:构建 → 传输 → 离线安装依赖 → 注册 systemd 服务 → 开机自启
+
+#### 手动部署
+
+```bash
+# 1. 构建部署包
+bash deploy/pack.sh deploy/
+
+# 2. 传输到目标设备
+scp -r deploy/hookvision_rk3578 root@<RK3578_IP>:/tmp/
+
+# 3. 在目标设备上安装
+cd /tmp/hookvision_rk3578 && sudo bash install.sh
+```
+
+## 部署目录结构
+
+目标设备安装后的目录:
+
+```
+/opt/hookvision/                    # 安装目录
+├── hookvision/                     # 二进制可执行文件
+│   ├── HookVision                  # 主程序(ELF 可执行)
+│   ├── _internal/                  # PyInstaller 打包的依赖库
+│   ├── config.ini                  # 配置文件
+│   ├── config.prod.ini             # 生产环境配置
+│   └── config.dev.ini              # 开发环境配置
+├── start.sh                        # 启动脚本
+└── install.sh                      # 安装脚本
+```
+
+## 配置文件
+
+| 文件 | 用途 |
+|------|------|
+| `/opt/hookvision/config.prod.ini` | 生产环境配置(部署后修改此文件) |
+| `/opt/hookvision/config.dev.ini` | 开发环境配置 |
+
+修改配置后重启服务生效:
+
+```bash
+sudo systemctl restart hookvision
+```
+
+### 配置项说明
+
+| 配置节 | 说明 |
+|--------|------|
+| `[RFID]` | RFID 读卡器 TCP 连接参数 |
+| `[Modbus]` | Modbus TCP 连接及轮询参数 |
+| `[AI]` | MQTT 服务器地址及订阅主题 |
+| `[AI:N]` | AI 模块 N 的 broker 地址和端口 |
+| `[Bucket:N]` | 工位 N 的左右摄像头 RTSP 地址、MQTT 主题、AI 模块编号 |
+
+## 服务管理
+
+部署后通过 systemd 管理服务:
+
+```bash
+systemctl start hookvision         # 启动
+systemctl stop hookvision          # 停止
+systemctl restart hookvision       # 重启
+systemctl status hookvision        # 查看状态
+journalctl -u hookvision -f        # 查看实时日志
+```
 
 
-## 配置文件说明
+开机自启已默认启用。
 
 
-- `[RFID]`:RFID 读卡器 TCP 连接参数
-- `[Modbus]`:Modbus TCP 连接及轮询参数
-- `[AI]`:MQTT 服务器地址及订阅主题
-- `[NVR]`:硬盘录像机信息(预留)
-- `[Bucket]`:工位信息,包含左右摄像头 RTSP 地址和 MQTT 主题
+部署时还会自动设置**屏幕永不熄屏**(禁用屏幕保护和 DPMS 休眠),确保设备常亮显示。
 
 
-## 依赖库
+## 项目文件说明
 
 
-- PySide6
-- opencv-python
-- pymodbus
-- paho-mqtt
+| 文件/目录 | 说明 |
+|-----------|------|
+| `main.py` | 程序入口 |
+| `hookvision/` | 核心业务代码 |
+| `assets/` | 图标、语音等资源 |
+| `config.dev.ini` | 开发环境配置 |
+| `config.prod.ini` | 生产环境配置 |
+| `HookVision-linux.spec` | PyInstaller Linux 打包配置 |
+| `deploy/pack.sh` | 部署包构建脚本 |
+| `deploy/deploy.sh` | 一键部署脚本 |
+| `deploy/hookvision_rk3578/debs/` | 离线 .deb 系统依赖包 |
 
 
 ## 注意事项
 ## 注意事项
 
 
 - RTSP 地址需包含用户名密码(如 `rtsp://user:pass@ip:port/...`)
 - RTSP 地址需包含用户名密码(如 `rtsp://user:pass@ip:port/...`)
 - 确保摄像头或 NVR 的 RTSP 服务已开启
 - 确保摄像头或 NVR 的 RTSP 服务已开启
-- Linux 下可能需要安装系统 FFmpeg:`sudo apt install ffmpeg`
+- 一键部署需要目标设备 SSH 可达,且安装了 `sshpass`(脚本会自动安装)
 
 
 ## 许可证
 ## 许可证
 
 
-内部使用
+内部使用

+ 146 - 0
assets/手动标定原理示意图.drawio

@@ -0,0 +1,146 @@
+<mxfile host="app.diagrams.net" modified="2026-08-15T00:00:00.000Z" agent="opencode" version="24.7.17" type="device">
+  <diagram id="calib-principle" name="手动标定原理">
+    <mxGraphModel dx="1400" dy="820" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="820" math="0" shadow="0">
+      <root>
+        <mxCell id="0"/>
+        <mxCell id="1" parent="0"/>
+
+        <mxCell id="10" value="手动标定原理 —— 吊钩/吊耳归一化偏移(可以起吊判定基准)" style="text;html=1;align=center;verticalAlign=middle;fontSize=20;fontStyle=1;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="330" y="12" width="740" height="34" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="20" value="" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f4f7fb;strokeColor=#8ba7c9;dashed=1;verticalAlign=top;fontColor=#8ba7c9;" vertex="1" parent="1">
+          <mxGeometry x="40" y="140" width="500" height="500" as="geometry"/>
+        </mxCell>
+        <mxCell id="21" value="AI 检测画面(单侧)" style="text;html=1;align=center;verticalAlign=middle;fontSize=13;fontStyle=1;fontColor=#8ba7c9;" vertex="1" parent="1">
+          <mxGeometry x="150" y="148" width="300" height="20" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="30" value="吊耳 lug" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f7ef;strokeColor=#1e9e5a;strokeWidth=2;verticalAlign=top;spacingTop=6;align=center;fontSize=13;fontStyle=1;fontColor=#1e9e5a;" vertex="1" parent="1">
+          <mxGeometry x="110" y="410" width="140" height="140" as="geometry"/>
+        </mxCell>
+        <mxCell id="31" value="吊钩 hook" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fdeeee;strokeColor=#d64541;strokeWidth=2;verticalAlign=top;spacingTop=6;align=center;fontSize=14;fontStyle=1;fontColor=#d64541;" vertex="1" parent="1">
+          <mxGeometry x="150" y="300" width="200" height="220" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="32" value="" style="ellipse;fillColor=#1e9e5a;strokeColor=#1e9e5a;" vertex="1" parent="1">
+          <mxGeometry x="176" y="476" width="8" height="8" as="geometry"/>
+        </mxCell>
+        <mxCell id="33" value="耳心 (lx, ly)" style="text;html=1;align=center;verticalAlign=middle;fontSize=11;fontColor=#1e9e5a;" vertex="1" parent="1">
+          <mxGeometry x="96" y="456" width="100" height="18" as="geometry"/>
+        </mxCell>
+        <mxCell id="34" value="" style="ellipse;fillColor=#d64541;strokeColor=#d64541;" vertex="1" parent="1">
+          <mxGeometry x="246" y="406" width="8" height="8" as="geometry"/>
+        </mxCell>
+        <mxCell id="35" value="钩心 (hx, hy)" style="text;html=1;align=center;verticalAlign=middle;fontSize=11;fontColor=#d64541;" vertex="1" parent="1">
+          <mxGeometry x="228" y="330" width="110" height="18" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="40" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="180" y="480" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="41" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="250" y="480" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="42" value="" style="endArrow=classic;startArrow=classic;endFill=1;startFill=1;html=1;rounded=0;strokeColor=#2f6fd0;strokeWidth=2;" edge="1" parent="1" source="40" target="41">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="43" value="ΔX = 钩心X - 耳心X" style="text;html=1;align=center;verticalAlign=middle;fontSize=12.5;fontStyle=1;fontColor=#2f6fd0;" vertex="1" parent="1">
+          <mxGeometry x="150" y="452" width="160" height="20" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="44" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="350" y="520" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="45" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="350" y="550" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="46" value="" style="endArrow=classic;startArrow=classic;endFill=1;startFill=1;html=1;rounded=0;strokeColor=#2f6fd0;strokeWidth=2;" edge="1" parent="1" source="44" target="45">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="47" value="ΔY = 钩底Y - 耳底Y" style="text;html=1;align=center;verticalAlign=middle;fontSize=12.5;fontStyle=1;fontColor=#2f6fd0;" vertex="1" parent="1">
+          <mxGeometry x="352" y="525" width="200" height="20" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="48" value="" style="rounded=0;fillColor=#d64541;strokeColor=#d64541;" vertex="1" parent="1">
+          <mxGeometry x="150" y="519" width="200" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="49" value="" style="rounded=0;fillColor=#1e9e5a;strokeColor=#1e9e5a;" vertex="1" parent="1">
+          <mxGeometry x="110" y="549" width="140" height="2" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="50" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="110" y="565" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="51" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="250" y="565" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="52" value="" style="endArrow=classic;startArrow=classic;endFill=1;startFill=1;html=1;rounded=0;strokeColor=#606266;strokeWidth=1.5;" edge="1" parent="1" source="50" target="51">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="53" value="耳宽 lug_w(归一化基准)" style="text;html=1;align=center;verticalAlign=middle;fontSize=11;fontColor=#606266;" vertex="1" parent="1">
+          <mxGeometry x="90" y="572" width="180" height="20" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="54" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="80" y="550" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="55" value="" style="ellipse;fillColor=none;strokeColor=none;" vertex="1" parent="1">
+          <mxGeometry x="80" y="410" width="2" height="2" as="geometry"/>
+        </mxCell>
+        <mxCell id="56" value="" style="endArrow=classic;startArrow=classic;endFill=1;startFill=1;html=1;rounded=0;strokeColor=#606266;strokeWidth=1.5;" edge="1" parent="1" source="54" target="55">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="57" value="耳高 lug_h" style="text;html=1;align=center;verticalAlign=middle;fontSize=11;fontColor=#606266;" vertex="1" parent="1">
+          <mxGeometry x="16" y="452" width="58" height="20" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="60" value="归一化偏移:&lt;br&gt;xr = (钩心X − 耳心X) / 耳宽&lt;br&gt;yr = (钩底Y − 耳底Y) / 耳高&lt;br&gt;&lt;br&gt;X 用中心点;Y 用框底边:&lt;br&gt;吊钩升起后顶边被画面裁切,&lt;br&gt;中心 Y 不可靠,底边不受影响" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#fdf6ec;strokeColor=#e6a23c;fontSize=12;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="560" y="170" width="360" height="260" as="geometry"/>
+        </mxCell>
+        <mxCell id="61" value="取最近相交配对:中心距最近 且 两框相交" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#faf3e3;strokeColor=#e6a23c;fontSize=12.5;fontStyle=1;fontColor=#8a6d3b;" vertex="1" parent="1">
+          <mxGeometry x="560" y="460" width="360" height="50" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="70" value="标定 → 运行判定流程" style="text;html=1;align=center;verticalAlign=middle;fontSize=13;fontStyle=1;fontColor=#8ba7c9;" vertex="1" parent="1">
+          <mxGeometry x="970" y="120" width="360" height="22" as="geometry"/>
+        </mxCell>
+        <mxCell id="71" value="① 点击【标定】按钮&lt;br&gt;当前工位(左右侧分别标定)" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#eaf3ff;strokeColor=#2f6fd0;fontSize=12;fontStyle=1;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="970" y="150" width="360" height="56" as="geometry"/>
+        </mxCell>
+        <mxCell id="72" value="② 连续采样约 1 秒&lt;br&gt;逐帧计算最近相交配对的 (xr, yr)" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#eaf3ff;strokeColor=#2f6fd0;fontSize=12;fontStyle=1;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="970" y="230" width="360" height="60" as="geometry"/>
+        </mxCell>
+        <mxCell id="73" value="③ 全部样本取平均&lt;br&gt;写入 hook_lug_x/y_offset_ratio_{left|right}" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#f3fbea;strokeColor=#1e9e5a;fontSize=12;fontStyle=1;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="970" y="315" width="360" height="60" as="geometry"/>
+        </mxCell>
+        <mxCell id="74" value="④ 运行中判定“可以起吊”:&lt;br&gt;框相交 且 |xr−标定x| ≤ 容差 且 |yr−标定y| ≤ 容差" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#f3fbea;strokeColor=#1e9e5a;fontSize=12;fontStyle=1;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="970" y="400" width="360" height="70" as="geometry"/>
+        </mxCell>
+        <mxCell id="75" value="⑤ 达标 → 判定【挂包成功 / 可以起吊】" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#fdf0f0;strokeColor=#d64541;fontSize=12;fontStyle=1;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="970" y="495" width="360" height="50" as="geometry"/>
+        </mxCell>
+        <mxCell id="76" value="容差 offset_ratio_tolerance(默认 0.25);不同工位/侧视角不同需分别标定" style="text;html=1;align=center;verticalAlign=middle;fontSize=11;fontColor=#909399;" vertex="1" parent="1">
+          <mxGeometry x="970" y="565" width="360" height="22" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="81" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=block;endFill=1;strokeColor=#909399;strokeWidth=2;" edge="1" parent="1" source="71" target="72">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="82" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=block;endFill=1;strokeColor=#909399;strokeWidth=2;" edge="1" parent="1" source="72" target="73">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="83" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=block;endFill=1;strokeColor=#909399;strokeWidth=2;" edge="1" parent="1" source="73" target="74">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+        <mxCell id="84" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=block;endFill=1;strokeColor=#909399;strokeWidth=2;" edge="1" parent="1" source="74" target="75">
+          <mxGeometry relative="1" as="geometry"/>
+        </mxCell>
+
+        <mxCell id="90" value="核心:AI 只能识别 hook/lug 类别,无法判断吊钩是否真正“坐实”到位;通过标定挂钩成功瞬间的归一化相对偏移,作为该工位该侧“可以起吊”的判定基准。" style="rounded=1;whiteSpace=wrap;html=1;align=center;verticalAlign=middle;fillColor=#f5f7fa;strokeColor=#c0c4cc;fontSize=12;fontColor=#1f2d3d;" vertex="1" parent="1">
+          <mxGeometry x="300" y="690" width="900" height="56" as="geometry"/>
+        </mxCell>
+      </root>
+    </mxGraphModel>
+  </diagram>
+</mxfile>

BIN
assets/手动标定原理示意图.png


+ 1 - 1
build_exe.bat

@@ -8,7 +8,7 @@ if %errorlevel% neq 0 exit /b %errorlevel%
 
 
 REM Step 2: Build the executable
 REM Step 2: Build the executable
 echo === Building HookVision.exe ===
 echo === Building HookVision.exe ===
-pyinstaller --noconsole --name=HookVision --icon="assets\logo.ico" --paths="." --additional-hooks-dir="hooks" --add-data="config.prod.ini;." --add-data="assets;assets" --hidden-import=av --hidden-import=pymodbus --hidden-import=pymodbus.client --hidden-import=paho.mqtt --hidden-import=paho.mqtt.client --hidden-import=cv2 --hidden-import=qt_material --hidden-import=configparser --hidden-import=numpy --hidden-import=win32com --hidden-import=pythoncom --hidden-import=struct --hidden-import=winsound --hidden-import=wave --hidden-import=hookvision --hidden-import=hookvision.config_loader --hidden-import=hookvision.main_window --hidden-import=hookvision.mqtt_client --hidden-import=hookvision.modbus_client --hidden-import=hookvision.rfid_client --hidden-import=hookvision.rtsp_player --hidden-import=hookvision.models --collect-all=pywin32 main.py
+pyinstaller --noconsole --name=HookVision --icon="assets\logo.ico" --paths="." --additional-hooks-dir="hooks" --add-data="config.prod.ini;." --add-data="assets;assets" --hidden-import=av --hidden-import=pymodbus --hidden-import=pymodbus.client --hidden-import=paho.mqtt --hidden-import=paho.mqtt.client --hidden-import=cv2 --hidden-import=qt_material --hidden-import=configparser --hidden-import=numpy --hidden-import=win32com --hidden-import=pythoncom --hidden-import=struct --hidden-import=winsound --hidden-import=wave --hidden-import=hookvision --hidden-import=hookvision.config_loader --hidden-import=hookvision.main_window_v2 --hidden-import=hookvision.mqtt_client --hidden-import=hookvision.modbus_client --hidden-import=hookvision.rfid_client --hidden-import=hookvision.rtsp_player --hidden-import=hookvision.models --collect-all=pywin32 main.py
 if %errorlevel% neq 0 exit /b %errorlevel%
 if %errorlevel% neq 0 exit /b %errorlevel%
 
 
 echo === Build complete! Run the following to install shortcuts: ===
 echo === Build complete! Run the following to install shortcuts: ===

+ 9 - 3
config.dev.ini

@@ -5,6 +5,7 @@ screen = 0
 
 
 [Video]
 [Video]
 rtsp_timeout = 10
 rtsp_timeout = 10
+ratio_16_9 = false
 
 
 [Hardware]
 [Hardware]
 module_type = RFID
 module_type = RFID
@@ -24,6 +25,11 @@ stability_count = 3
 [AI]
 [AI]
 enabled = true
 enabled = true
 tts_enabled = false
 tts_enabled = false
+offset_ratio_tolerance = 0.25
+ready_debounce = 3
+auto_calib_ready_frames = 3
+auto_calib_duration_secs = 1.0
+auto_calib_min_samples = 2
 
 
 [AI:0]
 [AI:0]
 broker = 192.168.100.100
 broker = 192.168.100.100
@@ -42,9 +48,9 @@ id = 1
 description = 1号工位
 description = 1号工位
 code_x = 00000001
 code_x = 00000001
 code_y = 
 code_y = 
-ai_module = 0
+ai_module = 1
 left_rtsp = rtsp://admin:hd135792468@192.168.1.124:554/Streaming/Channels/101
 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
+right_rtsp = rtsp://admin:hd135792468@192.168.1.125:554/Streaming/Channels/102
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
 
 
@@ -55,7 +61,7 @@ code_x = 10000002
 code_y = 
 code_y = 
 ai_module = 0
 ai_module = 0
 left_rtsp = rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101
 left_rtsp = rtsp://admin:hd135792468@192.168.100.102:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/101
+right_rtsp = rtsp://admin:hd135792468@192.168.100.103:554/Streaming/Channels/102
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
 
 

+ 41 - 17
config.ini

@@ -4,11 +4,10 @@ copyright = 恒莱达
 screen = 0
 screen = 0
 
 
 [Video]
 [Video]
-rtsp_timeout =  10
+rtsp_timeout = 10
 
 
 [Hardware]
 [Hardware]
 module_type = RFID
 module_type = RFID
-# module_type = HD3200
 
 
 [RFID]
 [RFID]
 ip = 192.168.100.113
 ip = 192.168.100.113
@@ -16,6 +15,28 @@ port = 60000
 stability_time = 5
 stability_time = 5
 stability_count = 3
 stability_count = 3
 
 
+[AutoRestart]
+enabled = false
+interval = day
+hour = 3
+minute = 0
+target = app
+weekdays = 0
+day_of_month = 1
+countdown = 60
+
+#[AutoRestart]
+#enabled = true
+#interval = day
+#hour = 7
+#minute =55
+#target = device
+#weekdays = 0
+#day_of_month = 1
+#countdown = 60
+
+
+
 [Modbus]
 [Modbus]
 ip = 192.168.1.200
 ip = 192.168.1.200
 port = 502
 port = 502
@@ -26,34 +47,36 @@ stability_count = 3
 enabled = true
 enabled = true
 broker = 192.168.1.248
 broker = 192.168.1.248
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 module_count = 2
 module_count = 2
+auto_calib_ready_frames = 3
+auto_calib_duration_secs = 1.0
+auto_calib_min_samples = 2
 
 
 [AI:0]
 [AI:0]
 broker = 192.168.100.144
 broker = 192.168.100.144
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 
 [AI:1]
 [AI:1]
 broker = 192.168.100.101
 broker = 192.168.100.101
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 
 [AI:2]
 [AI:2]
 broker = 192.168.100.102
 broker = 192.168.100.102
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 
 [AI:3]
 [AI:3]
 broker = 192.168.100.100
 broker = 192.168.100.100
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
-
+topics = 
+	ds/channel/#
 
 
 [NVR]
 [NVR]
 count = 1
 count = 1
@@ -83,7 +106,7 @@ right_topic = ds/channel/1
 id = 2
 id = 2
 description = 2号工位
 description = 2号工位
 code_x = 00000002
 code_x = 00000002
-code_y =
+code_y = 
 ai_module = 0
 ai_module = 0
 left_rtsp = rtsp://admin:hd135792468@192.168.100.143:554/Streaming/Channels/101
 left_rtsp = rtsp://admin:hd135792468@192.168.100.143:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.142:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.142:554/Streaming/Channels/101
@@ -94,7 +117,7 @@ right_topic = ds/channel/2
 id = 3
 id = 3
 description = 3号工位
 description = 3号工位
 code_x = 00000003
 code_x = 00000003
-code_y =
+code_y = 
 ai_module = 1
 ai_module = 1
 left_rtsp = rtsp://admin:hd135792468@192.168.100.120:554/Streaming/Channels/101
 left_rtsp = rtsp://admin:hd135792468@192.168.100.120:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.119:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.119:554/Streaming/Channels/101
@@ -105,7 +128,7 @@ right_topic = ds/channel/1
 id = 4
 id = 4
 description = 4号工位
 description = 4号工位
 code_x = 00000004
 code_x = 00000004
-code_y =
+code_y = 
 ai_module = 2
 ai_module = 2
 left_rtsp = rtsp://admin:hd135792468@192.168.100.115:554/Streaming/Channels/101
 left_rtsp = rtsp://admin:hd135792468@192.168.100.115:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.116:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.116:554/Streaming/Channels/101
@@ -116,9 +139,10 @@ right_topic = ds/channel/1
 id = 5
 id = 5
 description = 5号工位
 description = 5号工位
 code_x = 00000005
 code_x = 00000005
-code_y =
+code_y = 
 ai_module = 3
 ai_module = 3
 left_rtsp = rtsp://admin:hd135792468@192.168.100.118:554/Streaming/Channels/101
 left_rtsp = rtsp://admin:hd135792468@192.168.100.118:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.121:554/Streaming/Channels/101
 right_rtsp = rtsp://admin:hd135792468@192.168.100.121:554/Streaming/Channels/101
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
+

+ 36 - 25
config.prod.ini

@@ -4,11 +4,11 @@ copyright = 恒莱达
 screen = 0
 screen = 0
 
 
 [Video]
 [Video]
-rtsp_timeout =  10
+rtsp_timeout = 10
+ratio_16_9 = false
 
 
 [Hardware]
 [Hardware]
 module_type = RFID
 module_type = RFID
-# module_type = HD3200
 
 
 [RFID]
 [RFID]
 ip = 192.168.100.113
 ip = 192.168.100.113
@@ -25,31 +25,35 @@ stability_count = 3
 [AI]
 [AI]
 enabled = true
 enabled = true
 tts_enabled = false
 tts_enabled = false
+offset_ratio_tolerance = 0.3
+ready_debounce = 3
+auto_calib_ready_frames = 2
+auto_calib_duration_secs = 2.0
+auto_calib_min_samples = 2
 
 
 [AI:0]
 [AI:0]
 broker = 192.168.100.144
 broker = 192.168.100.144
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 
 [AI:1]
 [AI:1]
 broker = 192.168.100.101
 broker = 192.168.100.101
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 
 [AI:2]
 [AI:2]
 broker = 192.168.100.102
 broker = 192.168.100.102
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 
 [AI:3]
 [AI:3]
 broker = 192.168.100.100
 broker = 192.168.100.100
 port = 18830
 port = 18830
-topics =
-    ds/channel/#
-
+topics = 
+	ds/channel/#
 
 
 [Bucket:0]
 [Bucket:0]
 id = 1
 id = 1
@@ -57,8 +61,8 @@ description = 1号工位
 code_x = 00000001
 code_x = 00000001
 code_y = 
 code_y = 
 ai_module = 0
 ai_module = 0
-left_rtsp = rtsp://admin:hd135792468@192.168.100.122:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.100.117:554/Streaming/Channels/101
+left_rtsp = rtsp://admin:hd135792468@192.168.100.122:554/Streaming/Channels/102
+right_rtsp = rtsp://admin:hd135792468@192.168.100.117:554/Streaming/Channels/102
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
 
 
@@ -66,32 +70,38 @@ right_topic = ds/channel/1
 id = 2
 id = 2
 description = 2号工位
 description = 2号工位
 code_x = 00000002
 code_x = 00000002
-code_y =
+code_y = 
 ai_module = 0
 ai_module = 0
-left_rtsp = rtsp://admin:hd135792468@192.168.100.143:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.100.142:554/Streaming/Channels/101
+left_rtsp = rtsp://admin:hd135792468@192.168.100.143:554/Streaming/Channels/102
+right_rtsp = rtsp://admin:hd135792468@192.168.100.142:554/Streaming/Channels/102
 left_topic = ds/channel/3
 left_topic = ds/channel/3
 right_topic = ds/channel/2
 right_topic = ds/channel/2
+hook_lug_x_offset_ratio_left = 0.057483
+hook_lug_y_offset_ratio_left = 0.464481
+hook_lug_x_offset_ratio_right = -0.047619
+hook_lug_y_offset_ratio_right = 0.518519
 
 
 [Bucket:2]
 [Bucket:2]
 id = 3
 id = 3
 description = 3号工位
 description = 3号工位
 code_x = 00000003
 code_x = 00000003
-code_y =
+code_y = 
 ai_module = 1
 ai_module = 1
-left_rtsp = rtsp://admin:hd135792468@192.168.100.120:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.100.119:554/Streaming/Channels/101
+left_rtsp = rtsp://admin:hd135792468@192.168.100.120:554/Streaming/Channels/102
+right_rtsp = rtsp://admin:hd135792468@192.168.100.119:554/Streaming/Channels/102
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
+hook_lug_x_offset_ratio_right = -0.049103
+hook_lug_y_offset_ratio_right = 0.435473
 
 
 [Bucket:3]
 [Bucket:3]
 id = 4
 id = 4
 description = 4号工位
 description = 4号工位
 code_x = 00000004
 code_x = 00000004
-code_y =
+code_y = 
 ai_module = 2
 ai_module = 2
-left_rtsp = rtsp://admin:hd135792468@192.168.100.115:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.100.116:554/Streaming/Channels/101
+left_rtsp = rtsp://admin:hd135792468@192.168.100.115:554/Streaming/Channels/102
+right_rtsp = rtsp://admin:hd135792468@192.168.100.116:554/Streaming/Channels/102
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
 
 
@@ -99,9 +109,10 @@ right_topic = ds/channel/1
 id = 5
 id = 5
 description = 5号工位
 description = 5号工位
 code_x = 00000005
 code_x = 00000005
-code_y =
+code_y = 
 ai_module = 3
 ai_module = 3
-left_rtsp = rtsp://admin:hd135792468@192.168.100.118:554/Streaming/Channels/101
-right_rtsp = rtsp://admin:hd135792468@192.168.100.121:554/Streaming/Channels/101
+left_rtsp = rtsp://admin:hd135792468@192.168.100.118:554/Streaming/Channels/102
+right_rtsp = rtsp://admin:hd135792468@192.168.100.121:554/Streaming/Channels/102
 left_topic = ds/channel/0
 left_topic = ds/channel/0
 right_topic = ds/channel/1
 right_topic = ds/channel/1
+

+ 81 - 0
deploy/deploy.sh

@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+# deploy.sh - 一键部署 HookVision 到目标设备
+# 用法: bash deploy/deploy.sh [IP] [用户名] [密码]
+# 示例: bash deploy/deploy.sh 192.168.1.211 root 123456
+
+set -euo pipefail
+
+TARGET_IP="${1:-192.168.1.211}"
+TARGET_USER="${2:-root}"
+TARGET_PASS="${3:-123456}"
+REMOTE_DIR="/tmp/hookvision_rk3578"
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+DEPLOY_DIR="${SCRIPT_DIR}/hookvision_rk3578"
+
+echo "=== HookVision 一键部署 ==="
+echo "目标: ${TARGET_USER}@${TARGET_IP}"
+echo ""
+
+# 构建部署包(每次都重新构建)
+echo "构建部署包..."
+bash "${SCRIPT_DIR}/pack.sh" "${SCRIPT_DIR}"
+
+# 安装 sshpass(如果没有)
+if ! command -v sshpass &>/dev/null; then
+    echo "安装 sshpass..."
+    apt-get install -y -qq sshpass 2>/dev/null || \
+    yum install -y -q sshpass 2>/dev/null || {
+        echo "错误: 无法安装 sshpass,请手动安装"
+        exit 1
+    }
+fi
+
+# 定义远程执行函数
+remote_exec() {
+    local cmd="$1"
+    sshpass -p "${TARGET_PASS}" ssh -o StrictHostKeyChecking=no \
+        "${TARGET_USER}@${TARGET_IP}" "${cmd}"
+}
+
+# 检查设备连通性
+echo "[1/5] 检查设备连通性..."
+if ! ping -c 1 -W 2 "${TARGET_IP}" &>/dev/null; then
+    echo "错误: 无法连接到 ${TARGET_IP}"
+    exit 1
+fi
+echo "设备可达"
+
+# 停止已有服务
+echo "[2/5] 停止已有服务..."
+sshpass -p "${TARGET_PASS}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
+    "${TARGET_USER}@${TARGET_IP}" "pkill -9 -f HookVision 2>/dev/null; exit 0" || true
+echo "已停止"
+
+# 传输部署包
+echo "[3/5] 传输部署包到 ${REMOTE_DIR}..."
+sshpass -p "${TARGET_PASS}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
+    "${TARGET_USER}@${TARGET_IP}" "rm -rf ${REMOTE_DIR}" 2>/dev/null || true
+sshpass -p "${TARGET_PASS}" scp -o StrictHostKeyChecking=no -r \
+    "${DEPLOY_DIR}" "${TARGET_USER}@${TARGET_IP}:/tmp/"
+echo "传输完成"
+
+# 执行安装
+echo "[4/5] 执行安装..."
+sshpass -p "${TARGET_PASS}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
+    "${TARGET_USER}@${TARGET_IP}" "cd ${REMOTE_DIR} && bash install.sh"
+echo "安装完成"
+
+# 启动服务
+echo "[5/5] 启动 HookVision 服务..."
+sshpass -p "${TARGET_PASS}" ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
+    "${TARGET_USER}@${TARGET_IP}" "systemctl start hookvision" 2>/dev/null || true
+sleep 2
+
+echo ""
+echo "=== 部署完成 ==="
+echo "HookVision 已部署为 systemd 服务"
+echo "查看状态: systemctl status hookvision"
+echo "重启服务: systemctl restart hookvision"
+echo "停止服务: systemctl stop hookvision"

+ 253 - 0
deploy/pack.sh

@@ -0,0 +1,253 @@
+#!/usr/bin/env bash
+# pack.sh - RK3578 二进制部署打包脚本
+# 用法: bash deploy/pack.sh [输出目录]
+#
+# 前置条件:
+#   在项目根目录执行: pyinstaller HookVision-linux.spec --noconfirm
+#
+# 输出:
+#   hookvision_rk3578/  - 可直接传输到 RK3578 的二进制部署目录(不含源码)
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
+PACKAGE_NAME="hookvision_rk3578"
+BUILD_DIR="/tmp/${PACKAGE_NAME}"
+
+echo "=== HookVision RK3578 二进制部署包构建 ==="
+echo "项目根目录: ${PROJECT_ROOT}"
+echo "构建目录:   ${BUILD_DIR}"
+
+# 检查 PyInstaller 产出
+DIST_DIR="${PROJECT_ROOT}/dist/HookVision"
+if [ ! -f "${DIST_DIR}/HookVision" ]; then
+    echo "错误: 未找到 PyInstaller 构建产物 ${DIST_DIR}/HookVision"
+    echo "请先在项目根目录执行: pyinstaller HookVision-linux.spec --noconfirm"
+    exit 1
+fi
+
+# 清理旧构建
+rm -rf "${BUILD_DIR}"
+mkdir -p "${BUILD_DIR}/hookvision"
+
+# ---------- 复制二进制 ----------
+echo "[1/5] 复制二进制可执行文件..."
+cp "${DIST_DIR}/HookVision" "${BUILD_DIR}/hookvision/"
+cp -r "${DIST_DIR}/_internal" "${BUILD_DIR}/hookvision/"
+
+# ---------- 复制配置文件 ----------
+echo "[2/5] 复制配置文件..."
+cp "${PROJECT_ROOT}/config.ini"          "${BUILD_DIR}/hookvision/"
+cp "${PROJECT_ROOT}/config.prod.ini"     "${BUILD_DIR}/hookvision/"
+cp "${PROJECT_ROOT}/config.dev.ini"      "${BUILD_DIR}/hookvision/"
+
+# ---------- 复制离线依赖 ----------
+echo "[3/5] 复制离线 .deb 依赖..."
+if [ -d "${PROJECT_ROOT}/deploy/hookvision_rk3578/debs" ]; then
+    cp -r "${PROJECT_ROOT}/deploy/hookvision_rk3578/debs" "${BUILD_DIR}/"
+fi
+
+# ---------- 生成 install.sh ----------
+echo "[4/5] 生成 install.sh..."
+cat > "${BUILD_DIR}/install.sh" << 'INSTALL_EOF'
+#!/usr/bin/env bash
+# install.sh - RK3578 二进制部署安装脚本
+# 用法: sudo bash install.sh
+#
+# 说明: 此包为 PyInstaller 编译的二进制版本,无需安装 Python 依赖
+#       系统依赖以离线 .deb 包提供,无需联网
+
+set -euo pipefail
+
+INSTALL_DIR="/opt/hookvision"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "=== HookVision RK3578 二进制安装 ==="
+
+# 卸载旧版本
+echo "卸载旧版本..."
+if [ -d "${INSTALL_DIR}" ]; then
+    systemctl stop hookvision 2>/dev/null || true
+    systemctl disable hookvision 2>/dev/null || true
+    rm -rf "${INSTALL_DIR}"
+    echo "旧版本已卸载"
+else
+    echo "未找到旧版本,跳过"
+fi
+
+# 安装离线系统依赖
+echo "[1/3] 安装离线系统依赖..."
+if [ -d "${SCRIPT_DIR}/debs" ] && ls "${SCRIPT_DIR}/debs/"*.deb >/dev/null 2>&1; then
+    dpkg -i "${SCRIPT_DIR}/debs/"*.deb 2>/dev/null || true
+    apt-get install -y -f 2>/dev/null || true
+    echo "离线依赖安装完成"
+else
+    echo "未找到离线依赖包,跳过"
+fi
+
+# 备份目标设备上的已有配置文件
+echo "[2/4] 检查已有配置文件..."
+BACKUP_DIR=""
+if [ -d "${INSTALL_DIR}" ]; then
+    BACKUP_DIR="/tmp/hookvision_config_backup_$$"
+    mkdir -p "${BACKUP_DIR}"
+    for f in config.ini config.prod.ini config.dev.ini; do
+        if [ -f "${INSTALL_DIR}/hookvision/$f" ]; then
+            cp "${INSTALL_DIR}/hookvision/$f" "${BACKUP_DIR}/"
+            echo "  备份: $f"
+        fi
+    done
+fi
+
+# 创建安装目录
+echo "[3/4] 创建安装目录 ${INSTALL_DIR}..."
+mkdir -p "${INSTALL_DIR}"
+
+# 复制文件
+echo "[4/4] 复制应用文件..."
+cp -r "${SCRIPT_DIR}/hookvision" "${INSTALL_DIR}/"
+cp -r "${SCRIPT_DIR}/start.sh"   "${INSTALL_DIR}/"
+chmod +x "${INSTALL_DIR}/hookvision/HookVision"
+
+# 恢复目标设备上的配置文件
+if [ -n "${BACKUP_DIR}" ] && [ -d "${BACKUP_DIR}" ]; then
+    echo "恢复已有配置文件..."
+    for f in config.ini config.prod.ini config.dev.ini; do
+        if [ -f "${BACKUP_DIR}/$f" ]; then
+            cp "${BACKUP_DIR}/$f" "${INSTALL_DIR}/hookvision/"
+            echo "  恢复: $f"
+        fi
+    done
+    rm -rf "${BACKUP_DIR}"
+fi
+
+# 安装 systemd 服务
+echo "安装 systemd 服务..."
+cat > /etc/systemd/system/hookvision.service << 'SERVICE_EOF'
+[Unit]
+Description=HookVision - 钢包工位视频监测系统
+After=network-online.target graphical.target
+Wants=network-online.target
+Requires=display-manager.service
+
+[Service]
+Type=simple
+ExecStart=/opt/hookvision/start.sh
+WorkingDirectory=/opt/hookvision
+Restart=on-failure
+RestartSec=5
+Environment=DISPLAY=:0
+Environment=QT_QPA_PLATFORM=xcb
+
+[Install]
+WantedBy=graphical.target
+SERVICE_EOF
+
+systemctl daemon-reload
+systemctl enable hookvision
+echo "systemd 服务已安装并设置开机自启"
+
+# 设置屏幕永不熄屏
+echo "设置屏幕永不熄屏..."
+if [ -d /etc/xdg/autostart ]; then
+    cat > /etc/xdg/autostart/screensaver-disable.desktop << 'DESKTOP_EOF'
+[Desktop Entry]
+Type=Application
+Name=Disable Screensaver
+Exec=bash -c 'xset s off && xset -dpms && xset s noblank'
+Hidden=false
+NoDisplay=true
+X-GNOME-Autostart-enabled=true
+DESKTOP_EOF
+fi
+if command -v xset &>/dev/null; then
+    xset s off 2>/dev/null || true
+    xset -dpms 2>/dev/null || true
+    xset s noblank 2>/dev/null || true
+fi
+# 禁用 lightdm 屏幕保护(如使用 lightdm)
+if [ -f /etc/lightdm/lightdm.conf ]; then
+    sed -i 's/^greeter-setup-script=.*/greeter-setup-script=xset s off \&\& xset -dpms \&\& xset s noblank/' /etc/lightdm/lightdm.conf 2>/dev/null || true
+fi
+echo "屏幕永不熄屏已设置"
+
+# 创建软链接
+ln -sf "${INSTALL_DIR}/install.sh" /usr/local/bin/hv-install 2>/dev/null || true
+
+echo ""
+echo "=== 安装完成 ==="
+echo "启动服务: systemctl start hookvision"
+echo "查看状态: systemctl status hookvision"
+INSTALL_EOF
+chmod +x "${BUILD_DIR}/install.sh"
+
+# ---------- 生成 start.sh ----------
+echo "[5/5] 生成 start.sh..."
+cat > "${BUILD_DIR}/start.sh" << 'START_EOF'
+#!/usr/bin/env bash
+# start.sh - HookVision 二进制启动脚本
+# 用法: bash start.sh [--prod|--dev]
+
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# 默认使用生产配置
+MODE_FLAG="--prod"
+
+case "${1:-}" in
+    --dev)
+        MODE_FLAG="--dev"
+        ;;
+    --prod)
+        MODE_FLAG="--prod"
+        ;;
+    *)
+        if [ -f "${SCRIPT_DIR}/config.prod.ini" ]; then
+            MODE_FLAG="--prod"
+        elif [ -f "${SCRIPT_DIR}/config.dev.ini" ]; then
+            MODE_FLAG="--dev"
+        fi
+        ;;
+esac
+
+cd "${SCRIPT_DIR}"
+
+# 捕获信号,优雅退出
+cleanup() {
+    echo "收到退出信号,正在停止 HookVision..."
+    kill "$PID" 2>/dev/null
+    wait "$PID" 2>/dev/null
+    echo "已退出"
+    exit 0
+}
+trap cleanup SIGINT SIGTERM SIGHUP
+
+echo "启动 HookVision (${MODE_FLAG})..."
+./hookvision/HookVision ${MODE_FLAG} &
+PID=$!
+wait "$PID"
+START_EOF
+chmod +x "${BUILD_DIR}/start.sh"
+
+# ---------- 拷贝到输出目录 ----------
+OUTPUT_DIR="${1:-$(dirname "${PROJECT_ROOT}")}"
+OUTPUT_PATH="${OUTPUT_DIR}/${PACKAGE_NAME}"
+
+rm -rf "${OUTPUT_PATH}"
+cp -r "${BUILD_DIR}" "${OUTPUT_PATH}"
+
+# 清理构建目录
+rm -rf "${BUILD_DIR}"
+
+echo ""
+echo "=== 构建完成 ==="
+echo "部署目录: ${OUTPUT_PATH}"
+echo "大小:     $(du -sh "${OUTPUT_PATH}" | cut -f1)"
+echo ""
+echo "部署到 RK3578:"
+echo "  scp -r ${OUTPUT_PATH} root@<RK3578_IP>:/tmp/"
+echo "  ssh root@<RK3578_IP>"
+echo "    cd /tmp/hookvision_rk3578 && sudo bash install.sh"
+echo "    bash /opt/hookvision/start.sh"

+ 116 - 0
hookvision/auto_restart.py

@@ -0,0 +1,116 @@
+import os
+import sys
+import time
+import logging
+import subprocess
+import threading
+from datetime import datetime, timedelta
+from typing import Optional, Callable
+from .models import AutoRestartConfig, RestartInterval, RestartTarget
+
+logger = logging.getLogger(__name__)
+
+class AutoRestartScheduler:
+    def __init__(self, config: AutoRestartConfig, app_path: Optional[str] = None):
+        self.config = config
+        self.app_path = app_path or sys.executable
+        self._running = False
+        self._thread: Optional[threading.Thread] = None
+        self._last_trigger_minute = None
+        self.on_restart_triggered: Optional[Callable[[], None]] = None
+
+    def start(self):
+        if not self.config.enabled:
+            logger.info("AutoRestart disabled")
+            return
+        if self._running:
+            return
+        self._running = True
+        self._thread = threading.Thread(target=self._loop, daemon=True)
+        self._thread.start()
+        logger.info(f"AutoRestart started: interval={self.config.interval.value}, "
+                    f"time={self.config.hour:02d}:{self.config.minute:02d}, "
+                    f"target={self.config.target.value}")
+
+    def stop(self):
+        self._running = False
+
+    def _loop(self):
+        while self._running:
+            now = datetime.now()
+            if self._should_trigger(now):
+                minute_key = now.strftime("%Y-%m-%d %H:%M")
+                if self._last_trigger_minute != minute_key:
+                    self._last_trigger_minute = minute_key
+                    logger.info(f"AutoRestart triggered at {now}")
+                    if self.on_restart_triggered:
+                        self.on_restart_triggered()
+                    else:
+                        self.perform_restart()
+            time.sleep(30)
+
+    def _should_trigger(self, now: datetime) -> bool:
+        if now.hour != self.config.hour or now.minute != self.config.minute:
+            return False
+        interval = self.config.interval
+        if interval == RestartInterval.DAY:
+            return True
+        elif interval == RestartInterval.WEEK:
+            return now.weekday() == self.config.weekdays
+        elif interval == RestartInterval.MONTH:
+            return now.day == self.config.day_of_month
+        return False
+
+    def perform_restart(self):
+        logger.info(f"Performing restart: target={self.config.target.value}")
+        if self.config.target == RestartTarget.DEVICE:
+            self._restart_device()
+        else:
+            self._restart_app()
+
+    def _restart_app(self):
+        try:
+            args = sys.argv[:]
+            if getattr(sys, 'frozen', False):
+                subprocess.Popen([self.app_path] + args[1:])
+            else:
+                subprocess.Popen([sys.executable] + args)
+            logger.info("App restart command sent")
+            os._exit(0)
+        except Exception as e:
+            logger.error(f"Failed to restart app: {e}")
+
+    def _restart_device(self):
+        try:
+            if os.name == 'nt':
+                subprocess.run(['shutdown', '/r', '/t', '5', '/c', 'AutoRestart scheduled'], check=False)
+            else:
+                subprocess.run(['sudo', 'reboot'], check=False)
+            logger.info("Device restart command sent")
+        except Exception as e:
+            logger.error(f"Failed to restart device: {e}")
+
+    def next_restart_time(self) -> Optional[datetime]:
+        now = datetime.now()
+        interval = self.config.interval
+        target_time = now.replace(hour=self.config.hour, minute=self.config.minute, second=0, microsecond=0)
+        if target_time <= now:
+            if interval == RestartInterval.DAY:
+                target_time += timedelta(days=1)
+            elif interval == RestartInterval.WEEK:
+                days_ahead = self.config.weekdays - now.weekday()
+                if days_ahead <= 0:
+                    days_ahead += 7
+                target_time += timedelta(days=days_ahead)
+            elif interval == RestartInterval.MONTH:
+                if now.month == 12:
+                    next_month = 1
+                    next_year = now.year + 1
+                else:
+                    next_month = now.month + 1
+                    next_year = now.year
+                try:
+                    target_time = target_time.replace(year=next_year, month=next_month, day=self.config.day_of_month)
+                except ValueError:
+                    target_time += timedelta(days=1)
+        return target_time

+ 51 - 2
hookvision/config_loader.py

@@ -17,6 +17,7 @@ def load_config(config_path: str) -> Config:
 
 
     if cfg.has_section("Video"):
     if cfg.has_section("Video"):
         config.video.rtsp_timeout = cfg.getfloat("Video", "rtsp_timeout", fallback=5.0)
         config.video.rtsp_timeout = cfg.getfloat("Video", "rtsp_timeout", fallback=5.0)
+        config.video.ratio_16_9 = cfg.getboolean("Video", "ratio_16_9", fallback=True)
 
 
     # RFID
     # RFID
     if config.module_type == ModuleType.RFID:
     if config.module_type == ModuleType.RFID:
@@ -39,6 +40,12 @@ def load_config(config_path: str) -> Config:
     # AI
     # AI
     config.ai.enabled = cfg.getboolean("AI", "enabled", fallback=False)
     config.ai.enabled = cfg.getboolean("AI", "enabled", fallback=False)
     config.ai.tts_enabled = cfg.getboolean("AI", "tts_enabled", fallback=False)
     config.ai.tts_enabled = cfg.getboolean("AI", "tts_enabled", fallback=False)
+    config.ai.debug_trace = cfg.getboolean("AI", "debug_trace", fallback=True)
+    config.ai.offset_ratio_tolerance = cfg.getfloat("AI", "offset_ratio_tolerance", fallback=0.25)
+    config.ai.ready_debounce = cfg.getint("AI", "ready_debounce", fallback=3)
+    config.ai.auto_calib_ready_frames = cfg.getint("AI", "auto_calib_ready_frames", fallback=3)
+    config.ai.auto_calib_duration_secs = cfg.getfloat("AI", "auto_calib_duration_secs", fallback=1.0)
+    config.ai.auto_calib_min_samples = cfg.getint("AI", "auto_calib_min_samples", fallback=2)
 
 
     # AI Modules (自动扫描 AI:N 段)
     # AI Modules (自动扫描 AI:N 段)
     for section in sorted(
     for section in sorted(
@@ -65,7 +72,11 @@ def load_config(config_path: str) -> Config:
             description=cfg.get(section, "description"),
             description=cfg.get(section, "description"),
             code_x=cfg.get(section, "code_x"),
             code_x=cfg.get(section, "code_x"),
             code_y=cfg.get(section, "code_y", fallback=""),
             code_y=cfg.get(section, "code_y", fallback=""),
-            ai_module_id=cfg.getint(section, "ai_module", fallback=None)
+            ai_module_id=cfg.getint(section, "ai_module", fallback=None),
+            hook_lug_x_offset_ratio_left=cfg.getfloat(section, "hook_lug_x_offset_ratio_left", fallback=None),
+            hook_lug_y_offset_ratio_left=cfg.getfloat(section, "hook_lug_y_offset_ratio_left", fallback=None),
+            hook_lug_x_offset_ratio_right=cfg.getfloat(section, "hook_lug_x_offset_ratio_right", fallback=None),
+            hook_lug_y_offset_ratio_right=cfg.getfloat(section, "hook_lug_y_offset_ratio_right", fallback=None),
         )
         )
         left_rtsp = cfg.get(section, "left_rtsp")
         left_rtsp = cfg.get(section, "left_rtsp")
         right_rtsp = cfg.get(section, "right_rtsp")
         right_rtsp = cfg.get(section, "right_rtsp")
@@ -80,4 +91,42 @@ def load_config(config_path: str) -> Config:
         bucket.cameras = cameras
         bucket.cameras = cameras
         config.buckets.append(bucket)
         config.buckets.append(bucket)
 
 
-    return config
+    _ensure_auto_calib_defaults(cfg, config_path)
+
+    # AutoRestart
+    if cfg.has_section("AutoRestart"):
+        ar = config.auto_restart
+        ar.enabled = cfg.getboolean("AutoRestart", "enabled", fallback=False)
+        ar.interval = RestartInterval(cfg.get("AutoRestart", "interval", fallback="day"))
+        ar.hour = cfg.getint("AutoRestart", "hour", fallback=3)
+        ar.minute = cfg.getint("AutoRestart", "minute", fallback=0)
+        ar.target = RestartTarget(cfg.get("AutoRestart", "target", fallback="app"))
+        ar.weekdays = cfg.getint("AutoRestart", "weekdays", fallback=0)
+        ar.day_of_month = cfg.getint("AutoRestart", "day_of_month", fallback=1)
+        ar.countdown = cfg.getint("AutoRestart", "countdown", fallback=60)
+
+    return config
+
+
+_AUTO_CALIB_DEFAULTS = {
+    "auto_calib_ready_frames": "3",
+    "auto_calib_duration_secs": "1.0",
+    "auto_calib_min_samples": "2",
+}
+
+
+def _ensure_auto_calib_defaults(cfg, config_path: str):
+    """配置文件缺少自动标定参数时自动补写默认值(仅在文件存在且发生变更时写回)。"""
+    import os
+    if not os.path.exists(config_path):
+        return
+    if not cfg.has_section("AI"):
+        cfg.add_section("AI")
+    changed = False
+    for key, val in _AUTO_CALIB_DEFAULTS.items():
+        if not cfg.has_option("AI", key):
+            cfg.set("AI", key, val)
+            changed = True
+    if changed:
+        with open(config_path, "w", encoding="utf-8") as f:
+            cfg.write(f)

+ 298 - 16
hookvision/main_window.py

@@ -27,6 +27,23 @@ class MainWindow(QMainWindow):
     STATE_THRESHOLD = 3
     STATE_THRESHOLD = 3
     # 放包判断位移阈值(像素)
     # 放包判断位移阈值(像素)
     RELEASE_DELTA_THRESHOLD = 5
     RELEASE_DELTA_THRESHOLD = 5
+    # 轨迹阶段判断阈值(像素)
+    RELEASE_TRAJ_Y = 8
+    ASCEND_TRAJ_Y = 6
+    SEPARATE_TRAJ_DIST = 15
+    ENGAGE_DIST = 40
+    # 命中放包/分离后屏蔽语音的顺延秒数
+    SUPPRESS_SECONDS = 8
+    # 上升 ready 补全:连续命中帧数防抖 + 命中后锁存秒数
+    ASCEND_DEBOUNCE = 2
+    ASCEND_LATCH_SECS = 3.0
+    # READY 粘滞:失去信号后保持绿色的秒数(防红绿来回跳)
+    READY_HOLD_SECS = 2.0
+    # 轨迹窗口:保留最近 2 秒 / 最多 30 帧
+    TRAJ_WINDOW_SECS = 2.0
+    TRAJ_MAX_FRAMES = 30
+    # MQTT 消息超时(秒)后清除残留画框
+    MQTT_CLEAR_TIMEOUT = 1
     APPTITLE = "HookVision"
     APPTITLE = "HookVision"
 
 
     # 跨线程信号
     # 跨线程信号
@@ -56,6 +73,7 @@ class MainWindow(QMainWindow):
         self._last_rfid_tag: Optional[str] = None
         self._last_rfid_tag: Optional[str] = None
 
 
         self.video_aspect_ratio = 16 / 9
         self.video_aspect_ratio = 16 / 9
+        self._ratio_16_9 = config.video.ratio_16_9
         self.left_label = None
         self.left_label = None
         self.right_label = None
         self.right_label = None
 
 
@@ -73,9 +91,25 @@ class MainWindow(QMainWindow):
         self.prev_left_lug = None
         self.prev_left_lug = None
         self.prev_right_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 = ""
+        # 上升 ready 补全的防抖计数与锁存截止
+        self._ascend_frames: int = 0
+        self._ascend_latch_until: float = 0.0
+        # READY 粘滞保持截止(信号短暂丢失时保持绿色)
+        self._ready_hold_until: float = 0.0
+        self.debug_trace = getattr(config.ai, 'debug_trace', True)
+        # MQTT 消息超时后是否已清除画框(防重复清空)
+        self._overlay_cleared = False
+
         # 语音引擎(在工作线程内初始化)
         # 语音引擎(在工作线程内初始化)
         self.last_voice_state = None
         self.last_voice_state = None
         self._last_hooking_voice_time = 0.0
         self._last_hooking_voice_time = 0.0
+        self._ready_traces: dict = {}  # track_id → [(cx, cy, time), ...]
         self.voice_enabled = True
         self.voice_enabled = True
         self._voice_volume = 100
         self._voice_volume = 100
         self.ai_enabled = True
         self.ai_enabled = True
@@ -353,6 +387,11 @@ class MainWindow(QMainWindow):
         status_layout.addWidget(self.ai_checkbox)
         status_layout.addWidget(self.ai_checkbox)
         status_layout.addWidget(self.auto_switch_btn)
         status_layout.addWidget(self.auto_switch_btn)
         status_layout.addWidget(self.autostart_checkbox)
         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)
         main_layout.addWidget(status_widget)
         main_layout.addWidget(status_widget)
 
 
         self.update_video_size()
         self.update_video_size()
@@ -445,11 +484,19 @@ class MainWindow(QMainWindow):
             self.prev_right_hook = None
             self.prev_right_hook = None
             self.prev_left_lug = None
             self.prev_left_lug = None
             self.prev_right_lug = None
             self.prev_right_lug = None
+            self._pair_traj = {"left": [], "right": []}
+            self._voice_suppress_until = 0.0
+            self._trace_phase = ""
+            self._ascend_frames = 0
+            self._ascend_latch_until = 0.0
+            self._ready_hold_until = 0.0
             self.update_ui_for_state(HookState.IDLE)
             self.update_ui_for_state(HookState.IDLE)
-            if self.left_player:
-                self.left_player.set_ai_objects([])
-            if self.right_player:
-                self.right_player.set_ai_objects([])
+        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:
         else:
             if self.left_player and self._last_left_draw:
             if self.left_player and self._last_left_draw:
                 self.left_player.set_ai_objects(self._last_left_draw)
                 self.left_player.set_ai_objects(self._last_left_draw)
@@ -595,7 +642,7 @@ class MainWindow(QMainWindow):
             return
             return
         available_width = container.width() - 15
         available_width = container.width() - 15
         single_width = available_width // 2
         single_width = available_width // 2
-        target_height = int(single_width / self.video_aspect_ratio)
+        target_height = int(single_width / (16/9 if self._ratio_16_9 else 4/3))
         if target_height > container.height():
         if target_height > container.height():
             target_height = container.height()
             target_height = container.height()
         self.left_label.setFixedSize(single_width, target_height)
         self.left_label.setFixedSize(single_width, target_height)
@@ -629,6 +676,13 @@ class MainWindow(QMainWindow):
         else:
         else:
             self.showFullScreen()
             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):
     def show_loading_on_label(self, label: QLabel):
         label.setText("加载中...")
         label.setText("加载中...")
@@ -775,13 +829,15 @@ class MainWindow(QMainWindow):
     # ---------- 硬件初始化 ----------
     # ---------- 硬件初始化 ----------
     def init_hardware(self):
     def init_hardware(self):
         if self.config.module_type == ModuleType.RFID and self.config.rfid:
         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.rfid_client = RFIDClient(
                 self.config.rfid,
                 self.config.rfid,
                 self.on_rfid_tag,
                 self.on_rfid_tag,
                 on_state_changed=self.on_rfid_state_changed,
                 on_state_changed=self.on_rfid_state_changed,
                 tag_offset=18,
                 tag_offset=18,
                 tag_length=4,
                 tag_length=4,
-                hex_output=True
+                hex_output=True,
+                valid_codes=valid_codes
             )
             )
             if self.rfid_client.connect():
             if self.rfid_client.connect():
                 self.module_status.setText("通讯状态: RFID正常")
                 self.module_status.setText("通讯状态: RFID正常")
@@ -865,7 +921,16 @@ class MainWindow(QMainWindow):
         self.prev_right_lug = None
         self.prev_right_lug = None
         self.last_voice_state = None
         self.last_voice_state = None
         self._last_hooking_voice_time = 0.0
         self._last_hooking_voice_time = 0.0
+        self._ready_traces.clear()
+        self._pair_traj = {"left": [], "right": []}
+        self._voice_suppress_until = 0.0
+        self._trace_phase = ""
+        self._ascend_frames = 0
+        self._ascend_latch_until = 0.0
+        self._ready_hold_until = 0.0
         self.update_ui_for_state(HookState.IDLE)
         self.update_ui_for_state(HookState.IDLE)
+        self._last_left_draw = []
+        self._last_right_draw = []
 
 
         # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
         # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
         if bucket and self.config.ai.enabled and self.mqtt_client:
         if bucket and self.config.ai.enabled and self.mqtt_client:
@@ -922,6 +987,8 @@ class MainWindow(QMainWindow):
             self.left_label.clear()
             self.left_label.clear()
             self.right_label.clear()
             self.right_label.clear()
             QApplication.processEvents()
             QApplication.processEvents()
+            self.left_label.clear()
+            self.right_label.clear()
 
 
     def _unsubscribe_all_topics(self):
     def _unsubscribe_all_topics(self):
         if self.mqtt_client and self._subscribed_topics:
         if self.mqtt_client and self._subscribed_topics:
@@ -1014,6 +1081,7 @@ class MainWindow(QMainWindow):
                     else:
                     else:
                         color = (128, 128, 128)
                         color = (128, 128, 128)
                     objects.append({
                     objects.append({
+                        "track_id": parts[0],
                         "x": x,
                         "x": x,
                         "y": y,
                         "y": y,
                         "w": w,
                         "w": w,
@@ -1040,6 +1108,7 @@ class MainWindow(QMainWindow):
         if now - self._last_mqtt_process < 0.03:
         if now - self._last_mqtt_process < 0.03:
             return
             return
         self._last_mqtt_process = now
         self._last_mqtt_process = now
+        self._overlay_cleared = False
         for cam in self.active_bucket.cameras:
         for cam in self.active_bucket.cameras:
             if cam.topic == topic:
             if cam.topic == topic:
                 self.update_video_size()
                 self.update_video_size()
@@ -1055,6 +1124,19 @@ class MainWindow(QMainWindow):
 
 
                 all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
                 all_objects = self.parse_ai_objects(payload, display_width, display_height, include_ready=True)
 
 
+                # 更新 ready 标签轨迹
+                for obj in all_objects:
+                    if 'ready' in obj['label']:
+                        tid = obj.get('track_id')
+                        if tid:
+                            cx = obj['x'] + obj['w'] / 2
+                            cy = obj['y'] + obj['h'] / 2
+                            if tid not in self._ready_traces:
+                                self._ready_traces[tid] = []
+                            self._ready_traces[tid].append((cx, cy, time.time()))
+                            if len(self._ready_traces[tid]) > 20:
+                                self._ready_traces[tid].pop(0)
+
                 if cam.position == CameraPosition.LEFT:
                 if cam.position == CameraPosition.LEFT:
                     self.left_objects = all_objects
                     self.left_objects = all_objects
                 else:
                 else:
@@ -1062,10 +1144,19 @@ class MainWindow(QMainWindow):
 
 
                 self.evaluate_hook_state()
                 self.evaluate_hook_state()
 
 
-                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'):
-                        obj['dimmed'] = True
+                draw_objects = []
+                for obj in all_objects:
+                    if 'ready' in obj['label'].lower():
+                        continue
+                    obj['alpha'] = 0.4
+                    draw_objects.append(obj)
+
+                side = "left" if cam.position == CameraPosition.LEFT else "right"
+                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:
                 if cam.position == CameraPosition.LEFT:
                     self._last_left_draw = draw_objects
                     self._last_left_draw = draw_objects
@@ -1104,6 +1195,10 @@ class MainWindow(QMainWindow):
         left_pair = find_nearest_pair(left_hooks, left_lugs)
         left_pair = find_nearest_pair(left_hooks, left_lugs)
         right_pair = find_nearest_pair(right_hooks, right_lugs)
         right_pair = find_nearest_pair(right_hooks, right_lugs)
 
 
+        self._push_pair_traj("left", left_pair)
+        self._push_pair_traj("right", right_pair)
+        self._update_suppress_window()
+
         is_releasing = False
         is_releasing = False
 
 
         if left_pair:
         if left_pair:
@@ -1137,17 +1232,40 @@ class MainWindow(QMainWindow):
         else:
         else:
             any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
             any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
             has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
             has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
-            if any_ready and has_intersecting_pair:
+            ascending = self._is_ascending_together() and not self._is_voice_suppressed()
+
+            if self.hook_state == HookState.READY:
+                # READY 粘滞:挂包成功后只有确认下降或挂钩离开才退出,
+                # 模型 ready 标签抖动 / 短暂静止不再导致红绿来回跳
+                descending = self._is_descending()
+                if descending:
+                    new_state = HookState.HOOKING
+                elif has_intersecting_pair or ascending:
+                    new_state = HookState.READY
+                    self._ready_hold_until = time.time() + self.READY_HOLD_SECS
+                elif time.time() < self._ready_hold_until:
+                    new_state = HookState.READY
+                else:
+                    new_state = HookState.IDLE
+            elif ascending:
                 new_state = HookState.READY
                 new_state = HookState.READY
+                self._ready_hold_until = time.time() + self.READY_HOLD_SECS
+            elif any_ready and has_intersecting_pair:
+                descending = self._is_descending()
+                if descending is None:
+                    new_state = self.hook_state
+                elif descending:
+                    new_state = HookState.HOOKING
+                else:
+                    new_state = HookState.READY
+                    self._ready_hold_until = time.time() + self.READY_HOLD_SECS
             elif has_intersecting_pair:
             elif has_intersecting_pair:
                 new_state = HookState.HOOKING
                 new_state = HookState.HOOKING
             else:
             else:
                 new_state = HookState.IDLE
                 new_state = HookState.IDLE
 
 
-        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
+        # 防抖:READY 的粘滞/保持已由 sticky + hold 承担,此处不再特殊放行 15 帧
+        threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
         if new_state == self.prev_state:
         if new_state == self.prev_state:
             self.state_counter += 1
             self.state_counter += 1
         else:
         else:
@@ -1185,6 +1303,164 @@ class MainWindow(QMainWindow):
                 return True
                 return True
         return False
         return False
 
 
+    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, "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
+            self._ascend_frames = 0
+            self._ascend_latch_until = 0.0
+            self._ready_hold_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 "OK"
+
+    def _is_ascending_together(self) -> bool:
+        """最近 5 帧挂钩与吊耳同步上移且保持贴合 → 视为挂包成功(补 ready 漏检)。
+        连续 ASCEND_DEBOUNCE 帧命中后锁存 ASCEND_LATCH_SECS,避免上升结束后
+        轨迹窗口滚动导致瞬间回落误判,也抑制单帧抖动触发"""
+        detected = False
+        for side in ("left", "right"):
+            win = self._window_traj(side)
+            if len(win) < 5:
+                continue
+            recent = win[-5:]
+            dy_h = recent[-1]['hook_cy'] - recent[0]['hook_cy']
+            dy_l = recent[-1]['lug_cy'] - recent[0]['lug_cy']
+            if dy_h <= -self.ASCEND_TRAJ_Y and dy_l <= -self.ASCEND_TRAJ_Y:
+                if recent[-1]['dist'] < self.ENGAGE_DIST:
+                    detected = True
+                    break
+        if detected:
+            self._ascend_frames += 1
+        else:
+            self._ascend_frames = 0
+        if self._ascend_frames >= self.ASCEND_DEBOUNCE:
+            self._ascend_latch_until = time.time() + self.ASCEND_LATCH_SECS
+        return time.time() < self._ascend_latch_until
+
+    def _is_descending(self) -> Optional[bool]:
+        """ready 轨迹下降=返回 True;确认静止=返回 False;最近轨迹不足=返回 None。
+        要求窗口净位移与逐步上升帧数同时满足,降低检测框抖动引起的误判"""
+        now = time.time()
+        confirmed = False
+        for tid, trace in self._ready_traces.items():
+            if not trace or now - trace[-1][2] > self.TRAJ_WINDOW_SECS:
+                continue
+            if len(trace) < 5:
+                continue
+            confirmed = True
+            recent = trace[-5:]
+            ys = [p[1] for p in recent]
+            net = ys[-1] - ys[0]
+            rises = sum(1 for i in range(1, len(ys)) if ys[i] > ys[i-1] + 2)
+            if net >= 6 and rises >= 4:
+                return True
+        return None if not confirmed else False
+
+    # def _draw_traces(self, frame, sx=1.0, sy=1.0, x_off=0, y_off=0):
+#     import cv2
+#     for tid, trace in self._ready_traces.items():
+#         if len(trace) < 2:
+#             continue
+#         pts = [(int((p[0] * sx) + x_off), int((p[1] * sy) + y_off)) for p in trace]
+#         for i in range(1, len(pts)):
+#             alpha = i / len(pts)
+#             color = (0, int(255 * alpha), 255)
+#             cv2.line(frame, pts[i-1], pts[i], color, 2)
+
+# def _build_trace_lines(self) -> list:
+#     return [[(p[0], p[1]) for p in trace] for trace in self._ready_traces.values()]
+
     # ---------- UI 更新与语音 ----------
     # ---------- UI 更新与语音 ----------
     def update_ui_for_state(self, state):
     def update_ui_for_state(self, state):
         if state == HookState.IDLE:
         if state == HookState.IDLE:
@@ -1217,6 +1493,8 @@ class MainWindow(QMainWindow):
 
 
         if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
         if self.voice_enabled and state in (HookState.HOOKING, HookState.READY):
             now = time.time()
             now = time.time()
+            if self._is_voice_suppressed():
+                return
             if state == HookState.HOOKING:
             if state == HookState.HOOKING:
                 if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
                 if state != self.last_voice_state or now - self._last_hooking_voice_time > 15:
                     self._speak("挂包中")
                     self._speak("挂包中")
@@ -1230,6 +1508,7 @@ class MainWindow(QMainWindow):
     def check_idle(self):
     def check_idle(self):
         if self.rfid_client:
         if self.rfid_client:
             self.rfid_client.check_idle()
             self.rfid_client.check_idle()
+        self._maybe_clear_stale_overlay()
         now = time.time()
         now = time.time()
         if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
         if self.config.ai.enabled and self.mqtt_client and not self.mqtt_client.connected:
             if now - self._last_mqtt_connect_attempt > 30:
             if now - self._last_mqtt_connect_attempt > 30:
@@ -1353,9 +1632,12 @@ class MainWindow(QMainWindow):
             cfg.add_section("AI")
             cfg.add_section("AI")
         val = "true" if self.config.ai.tts_enabled else "false"
         val = "true" if self.config.ai.tts_enabled else "false"
         cfg.set("AI", "tts_enabled", val)
         cfg.set("AI", "tts_enabled", val)
+        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:
         with open(self._config_path, "w", encoding="utf-8") as f:
             cfg.write(f)
             cfg.write(f)
-        logger.info(f"TTS 设置已保存: {self._config_path} → tts_enabled={val}")
+        logger.info(f"配置已保存: {self._config_path}")
 
 
     @staticmethod
     @staticmethod
     def _autostart_config_dir():
     def _autostart_config_dir():

+ 2156 - 0
hookvision/main_window_v2.py

@@ -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/ 目录权限。"
+            )

+ 35 - 0
hookvision/models.py

@@ -41,6 +41,11 @@ class BucketModel:
     is_active: bool = False
     is_active: bool = False
     ready_x: bool = False
     ready_x: bool = False
     ready_y: bool = True   # 无垂直工位时为True
     ready_y: bool = True   # 无垂直工位时为True
+    # 可以起吊判定:hook/lug 归一化偏移标定值(X 用中心点、Y 用框底边,每工位每侧,空=未标定)
+    hook_lug_x_offset_ratio_left: Optional[float] = None
+    hook_lug_y_offset_ratio_left: Optional[float] = None
+    hook_lug_x_offset_ratio_right: Optional[float] = None
+    hook_lug_y_offset_ratio_right: Optional[float] = None
 
 
 @dataclass
 @dataclass
 class AIModuleModel:
 class AIModuleModel:
@@ -53,6 +58,14 @@ class AIModuleModel:
 class AIConfig:
 class AIConfig:
     enabled: bool = False
     enabled: bool = False
     tts_enabled: bool = False
     tts_enabled: bool = False
+    debug_trace: bool = True
+    # 可以起吊:hook/lug 中心归一化偏移容差(x/y 共用)与防抖帧数
+    offset_ratio_tolerance: float = 0.25
+    ready_debounce: int = 3
+    # 自动标定参数:窗口内出现 N 帧 ready 触发(不要求连续)/ 窗口时长(秒)/ 最少有效样本数
+    auto_calib_ready_frames: int = 3
+    auto_calib_duration_secs: float = 1.0
+    auto_calib_min_samples: int = 2
 
 
 @dataclass
 @dataclass
 class ModbusConfig:
 class ModbusConfig:
@@ -68,9 +81,30 @@ class SocketConfig:
     stability_time: int = 20
     stability_time: int = 20
     stability_count: int = 3
     stability_count: int = 3
 
 
+class RestartInterval(Enum):
+    DAY = "day"
+    WEEK = "week"
+    MONTH = "month"
+
+class RestartTarget(Enum):
+    APP = "app"
+    DEVICE = "device"
+
+@dataclass
+class AutoRestartConfig:
+    enabled: bool = False
+    interval: RestartInterval = RestartInterval.DAY
+    hour: int = 3
+    minute: int = 0
+    target: RestartTarget = RestartTarget.APP
+    weekdays: int = 0
+    day_of_month: int = 1
+    countdown: int = 60
+
 @dataclass
 @dataclass
 class VideoConfig:
 class VideoConfig:
     rtsp_timeout: float = 5.0   # 默认 5 秒
     rtsp_timeout: float = 5.0   # 默认 5 秒
+    ratio_16_9: bool = True     # 画面比例 16:9 / 4:3
 
 
 @dataclass
 @dataclass
 class Config:
 class Config:
@@ -84,3 +118,4 @@ class Config:
     ai_modules: List[AIModuleModel] = field(default_factory=list)
     ai_modules: List[AIModuleModel] = field(default_factory=list)
     buckets: List[BucketModel] = field(default_factory=list)
     buckets: List[BucketModel] = field(default_factory=list)
     video: VideoConfig = field(default_factory=VideoConfig)
     video: VideoConfig = field(default_factory=VideoConfig)
+    auto_restart: AutoRestartConfig = field(default_factory=AutoRestartConfig)

+ 99 - 19
hookvision/rfid_client.py

@@ -1,4 +1,6 @@
+import os
 import socket
 import socket
+import subprocess
 import threading
 import threading
 import time
 import time
 import logging
 import logging
@@ -6,28 +8,77 @@ from typing import Callable, Optional
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-_MIN_BACKOFF = 5
+_MIN_BACKOFF = 1
 _MAX_BACKOFF = 60
 _MAX_BACKOFF = 60
 
 
 class RFIDClient:
 class RFIDClient:
     def __init__(self, config, on_tag_callback: Callable[[Optional[str]], None],
     def __init__(self, config, on_tag_callback: Callable[[Optional[str]], None],
-                 on_state_changed=None, tag_offset: int = -6, tag_length: int = 6, hex_output: bool = True):
+                 on_state_changed=None, tag_offset: int = -6, tag_length: int = 6, hex_output: bool = True,
+                 valid_codes: Optional[set] = None):
         self.config = config
         self.config = config
         self.on_tag_callback = on_tag_callback
         self.on_tag_callback = on_tag_callback
         self.tag_offset = tag_offset
         self.tag_offset = tag_offset
         self.tag_length = tag_length
         self.tag_length = tag_length
         self.hex_output = hex_output
         self.hex_output = hex_output
+        self.valid_codes = valid_codes  # 允许的卡号集合,None 表示不过滤
         self.sock: Optional[socket.socket] = None
         self.sock: Optional[socket.socket] = None
         self.running = False
         self.running = False
+        self._online: Optional[bool] = None
         self._last_tag: Optional[str] = None
         self._last_tag: Optional[str] = None
         self._stable_count = 0
         self._stable_count = 0
         self._last_time = time.time()
         self._last_time = time.time()
-        self._last_receive_time = time.time()   # 最后一次收到数据的时间
-        self._backoff = _MIN_BACKOFF            # 当前退避间隔(秒)
+        self._last_receive_time = time.time()
+        self._backoff = _MIN_BACKOFF
         self._last_connect_attempt = 0
         self._last_connect_attempt = 0
         self.thread = None
         self.thread = None
         self.buffer = b""
         self.buffer = b""
         self.on_state_changed = on_state_changed
         self.on_state_changed = on_state_changed
+        self._ping_interval = 5        # 周期 ping 间隔(秒)
+        self._ping_threshold = 3       # 连续 ping 失败多少次判定离线
+        self._ping_failures = 0
+        self._ping_running = False
+        self._ping_thread = None
+        self._start_ping_monitor()
+
+    def _start_ping_monitor(self):
+        """启动后台周期 ping,用于检测读卡器设备是否在线(与 TCP 数据连接无关)"""
+        if self._ping_thread and self._ping_thread.is_alive():
+            return
+        self._ping_running = True
+        self._ping_thread = threading.Thread(target=self._ping_loop, daemon=True)
+        self._ping_thread.start()
+
+    def _ping_once(self) -> bool:
+        try:
+            if os.name == 'nt':
+                cmd = ['ping', '-n', '1', '-w', '1000', self.config.ip]
+                flags = 0x08000000   # CREATE_NO_WINDOW,避免弹控制台窗口
+            else:
+                cmd = ['ping', '-c', '1', '-W', '1', self.config.ip]
+                flags = 0
+            r = subprocess.run(cmd, capture_output=True, timeout=3, creationflags=flags)
+            return r.returncode == 0
+        except Exception:
+            return False
+
+    def _ping_loop(self):
+        while self._ping_running:
+            if self._ping_once():
+                self._ping_failures = 0
+                self._notify_state(True)
+            else:
+                self._ping_failures += 1
+                if self._ping_failures >= self._ping_threshold:
+                    self._notify_state(False)
+            time.sleep(self._ping_interval)
+
+    def _notify_state(self, online: bool):
+        """仅在上线/离线状态发生切换时通知上层,避免重试期间重复报警"""
+        if online == self._online:
+            return
+        self._online = online
+        if self.on_state_changed:
+            self.on_state_changed(online)
 
 
     def connect(self) -> bool:
     def connect(self) -> bool:
         """尝试建立连接,若已连接则直接返回 True,否则尝试重连(有间隔限制)"""
         """尝试建立连接,若已连接则直接返回 True,否则尝试重连(有间隔限制)"""
@@ -52,20 +103,19 @@ class RFIDClient:
             self._last_receive_time = time.time()
             self._last_receive_time = time.time()
             self._backoff = _MIN_BACKOFF
             self._backoff = _MIN_BACKOFF
             logger.info(f"RFID连接成功 {self.config.ip}:{self.config.port}")
             logger.info(f"RFID连接成功 {self.config.ip}:{self.config.port}")
-            if self.on_state_changed:
-                self.on_state_changed(True)
+            self._notify_state(True)
             return True
             return True
         except Exception as e:
         except Exception as e:
             self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
             self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
             logger.error(f"RFID连接失败,下次{self._backoff}s后重试: {e}")
             logger.error(f"RFID连接失败,下次{self._backoff}s后重试: {e}")
             self.sock = None
             self.sock = None
-            if self.on_state_changed:
-               self.on_state_changed(False)
+            self._notify_state(False)
             return False
             return False
 
 
     def disconnect(self):
     def disconnect(self):
         """主动断开连接"""
         """主动断开连接"""
         self.running = False
         self.running = False
+        self._ping_running = False
         sock = self.sock
         sock = self.sock
         self.sock = None
         self.sock = None
         if sock:
         if sock:
@@ -74,10 +124,13 @@ class RFIDClient:
             except OSError:
             except OSError:
                 pass
                 pass
         logger.info("RFID已断开")
         logger.info("RFID已断开")
-        if self.on_state_changed:
-            self.on_state_changed(False)
+        self._notify_state(False)
         self.on_tag_callback(None)   # 通知上层
         self.on_tag_callback(None)   # 通知上层
 
 
+    def is_connected(self) -> bool:
+        """检查连接是否正常"""
+        return self.sock is not None and self.running
+
     def _read_loop(self):
     def _read_loop(self):
         while self.running:
         while self.running:
             sock = self.sock
             sock = self.sock
@@ -108,11 +161,22 @@ class RFIDClient:
             except Exception as e:
             except Exception as e:
                 logger.error(f"RFID读取错误: {e}")
                 logger.error(f"RFID读取错误: {e}")
                 break
                 break
-        # 退出循环(连接断开)
+        # 退出循环(读卡器主动断开或网络异常),静默清理并快速重连。
+        # 该读卡器会周期性主动断开连接,属正常行为,在线状态由周期 ping 判定。
         logger.info("RFID读取循环结束,将触发重连")
         logger.info("RFID读取循环结束,将触发重连")
-        if self.on_state_changed:
-            self.on_state_changed(False)
-        self.on_tag_callback(None)   # 通知上层
+        self.running = False
+        # 先重置重连时间戳,避免 check_idle 在清理完成前提前重连(旧线程误报)
+        self._last_connect_attempt = time.time()
+        self._backoff = _MIN_BACKOFF
+        sock = self.sock
+        if sock is not None:
+            self.sock = None
+            try:
+                sock.close()
+            except OSError:
+                pass
+        # 不通知上层:读卡器周期主动断开会话属正常行为,卡号仍有效,
+        # 卡离开只由 check_idle 空闲超时判定
 
 
     def _process_packet(self, packet):
     def _process_packet(self, packet):
         hex_str = packet.hex()
         hex_str = packet.hex()
@@ -139,6 +203,11 @@ class RFIDClient:
             logger.debug("无效卡号(空或全0),忽略")
             logger.debug("无效卡号(空或全0),忽略")
             return
             return
 
 
+        # 过滤不在配置中的卡号(读卡器同时读到多张卡时,只保留配置的工位卡)
+        if self.valid_codes and tag not in self.valid_codes:
+            logger.debug(f"卡号 {tag} 不在配置列表中,过滤")
+            return
+
         logger.debug(f"提取卡号: {tag}")
         logger.debug(f"提取卡号: {tag}")
         self._process_tag(tag)
         self._process_tag(tag)
 
 
@@ -162,8 +231,8 @@ class RFIDClient:
         1. 检查空闲超时(卡离开)
         1. 检查空闲超时(卡离开)
         2. 检查连接状态,若断开或长时间无数据则自动重连
         2. 检查连接状态,若断开或长时间无数据则自动重连
         """
         """
-        # 空闲超时
-        if self._last_tag and (time.time() - self._last_time) > self.config.stability_time:
+        # 空闲超时:仅在连接正常时才触发,避免断开期间误判
+        if self.sock is not None and self._last_tag and (time.time() - self._last_time) > self.config.stability_time:
             logger.info("空闲超时,清空状态")
             logger.info("空闲超时,清空状态")
             self._last_tag = None
             self._last_tag = None
             self._stable_count = 0
             self._stable_count = 0
@@ -173,8 +242,19 @@ class RFIDClient:
         if self.sock is None:
         if self.sock is None:
             # 如果 socket 已断开,尝试重连
             # 如果 socket 已断开,尝试重连
             if time.time() - self._last_connect_attempt > self._backoff:
             if time.time() - self._last_connect_attempt > self._backoff:
+                logger.debug("RFID socket为None,尝试重连")
                 self.connect()
                 self.connect()
         else:
         else:
-            # socket 存在,连接正常,不做额外检测
-            # 只在 _read_loop 中 detect socket 异常断开时才会走到 disconnect 逻辑
-            pass
+            # socket 存在,检查是否长时间无数据(可能连接已假死)
+            idle_time = time.time() - self._last_receive_time
+            if idle_time > self.config.stability_time * 2:
+                # 超过两倍空闲时间无数据,主动断开重连
+                logger.warning(f"RFID连接假死({idle_time:.1f}s无数据),主动重连")
+                self.running = False
+                sock = self.sock
+                self.sock = None
+                if sock:
+                    try:
+                        sock.close()
+                    except OSError:
+                        pass

+ 288 - 35
hookvision/rtsp_player.py

@@ -1,15 +1,167 @@
 import av
 import av
+import sys
+import os
+import socket
+import struct
 import numpy as np
 import numpy as np
 import time
 import time
 import cv2
 import cv2
+import shutil
+import subprocess
 import threading
 import threading
 import logging
 import logging
+import platform
+from urllib.parse import urlparse, urlunparse, quote
 from PySide6.QtCore import QObject, Signal, QMutex
 from PySide6.QtCore import QObject, Signal, QMutex
 from PySide6.QtGui import QImage, QPixmap
 from PySide6.QtGui import QImage, QPixmap
 from typing import List, Dict, Any, Optional
 from typing import List, Dict, Any, Optional
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+
+def _try_av_open(rtsp_url: str, options: dict):
+    """用 av 库打开 RTSP,返回 (container, used_url) 或 (None, None)"""
+    urls = [rtsp_url]
+    parsed = urlparse(rtsp_url)
+    if parsed.username or parsed.password:
+        user = quote(parsed.username or '', safe='')
+        passwd = quote(parsed.password or '', safe='')
+        netloc = f"{user}:{passwd}@{parsed.hostname}"
+        if parsed.port:
+            netloc += f":{parsed.port}"
+        encoded = urlunparse(parsed._replace(netloc=netloc))
+        if encoded != rtsp_url:
+            urls.append(encoded)
+
+    for url in urls:
+        try:
+            container = av.open(url, options=options)
+            return container, url
+        except Exception as e:
+            if '401' in str(e) or 'Unauthorized' in str(e):
+                logger.warning(f"av 库 RTSP 认证失败 ({url}): {e}")
+                continue
+            raise
+    return None, None
+
+
+def _find_system_ffmpeg():
+    """查找系统 ffmpeg 二进制路径(绕过 PyInstaller 打包的版本)"""
+    # 优先用 /usr/bin 下的系统版本
+    for path in ['/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg']:
+        if os.path.isfile(path) and os.access(path, os.X_OK):
+            return path
+    # fallback:查找 PATH 中的 ffmpeg
+    p = shutil.which('ffmpeg')
+    if p:
+        return p
+    return None
+
+
+class _FFmpegSubprocess:
+    """通过 subprocess 调用系统 ffmpeg 读取 RTSP 流,支持 Digest 认证"""
+
+    def __init__(self, rtsp_url: str, width: int = 1920, height: int = 1080, timeout: float = 10.0):
+        self.rtsp_url = rtsp_url
+        self.width = width
+        self.height = height
+        self.timeout = timeout
+        self.proc = None
+        self._stderr_thread = None
+        self._stderr_log = []
+
+    def _read_stderr(self):
+        """后台线程读取 ffmpeg stderr,记录错误"""
+        try:
+            for line in iter(self.proc.stderr.readline, b''):
+                line_str = line.decode('utf-8', errors='replace').strip()
+                if line_str:
+                    self._stderr_log.append(line_str)
+                    logger.error(f"[ffmpeg stderr] {line_str}")
+        except Exception:
+            pass
+
+    def open(self) -> bool:
+        ffmpeg_bin = _find_system_ffmpeg()
+        if not ffmpeg_bin:
+            logger.error("未找到系统 ffmpeg 二进制")
+            return False
+
+        cmd = [
+            ffmpeg_bin,
+            '-hide_banner',
+            '-rtsp_transport', 'tcp',
+            '-i', self.rtsp_url,
+            '-f', 'rawvideo',
+            '-pix_fmt', 'rgb24',
+            '-vf', f'scale={self.width}:{self.height}',
+            '-r', '25',
+            'pipe:1'
+        ]
+        logger.info(f"ffmpeg subprocess 命令: {' '.join(cmd)}")
+        try:
+            self.proc = subprocess.Popen(
+                cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+                bufsize=self.width * self.height * 3
+            )
+        except Exception as e:
+            logger.error(f"ffmpeg subprocess 启动失败: {e}")
+            return False
+
+        # 后台读取 stderr
+        self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
+        self._stderr_thread.start()
+
+        # 等待第一帧数据(带超时)
+        frame_size = self.width * self.height * 3
+        import select
+        try:
+            ready = select.select([self.proc.stdout], [], [], self.timeout)
+            if not ready[0]:
+                logger.error(f"ffmpeg subprocess 超时 ({self.timeout}s) 未输出帧数据")
+                # 打印 stderr 中的错误
+                if self._stderr_log:
+                    logger.error(f"ffmpeg 错误输出: {'; '.join(self._stderr_log)}")
+                self.proc.kill()
+                self.proc = None
+                return False
+            data = self.proc.stdout.read(frame_size)
+            if not data or len(data) < frame_size:
+                logger.error(f"ffmpeg subprocess 首帧数据不足: {len(data) if data else 0}/{frame_size}")
+                if self._stderr_log:
+                    logger.error(f"ffmpeg 错误输出: {'; '.join(self._stderr_log)}")
+                self.proc.kill()
+                self.proc = None
+                return False
+            logger.info(f"ffmpeg subprocess 连接成功: {self.rtsp_url}")
+            return True
+        except Exception as e:
+            logger.error(f"ffmpeg subprocess 读取失败: {e}")
+            if self._stderr_log:
+                logger.error(f"ffmpeg 错误输出: {'; '.join(self._stderr_log)}")
+            if self.proc:
+                self.proc.kill()
+                self.proc = None
+            return False
+
+    def read_frame(self):
+        if not self.proc or not self.proc.stdout:
+            return None
+        frame_size = self.width * self.height * 3
+        data = self.proc.stdout.read(frame_size)
+        if not data or len(data) < frame_size:
+            logger.warning(f"ffmpeg subprocess 帧数据不足: {len(data) if data else 0}/{frame_size}")
+            return None
+        return np.frombuffer(data, dtype=np.uint8).reshape((self.height, self.width, 3))
+
+    def close(self):
+        if self.proc:
+            try:
+                self.proc.kill()
+            except Exception:
+                pass
+            self.proc = None
+
 class RTSPPlayer(QObject):
 class RTSPPlayer(QObject):
     frame_ready = Signal(QPixmap)
     frame_ready = Signal(QPixmap)
     first_frame_received = Signal()
     first_frame_received = Signal()
@@ -27,6 +179,8 @@ class RTSPPlayer(QObject):
         self.ai_objects: List[Dict[str, Any]] = []
         self.ai_objects: List[Dict[str, Any]] = []
         self.target_fps = 25
         self.target_fps = 25
         self.mutex = QMutex()
         self.mutex = QMutex()
+        self._last_frame_time: float = 0.0
+        self._timeout_emitted = False
 
 
     def set_target_size(self, width: int, height: int):
     def set_target_size(self, width: int, height: int):
         self.mutex.lock()
         self.mutex.lock()
@@ -39,6 +193,24 @@ class RTSPPlayer(QObject):
         self.ai_objects = list(objects)
         self.ai_objects = list(objects)
         self.mutex.unlock()
         self.mutex.unlock()
 
 
+    def _build_rtsp_urls(self, url: str) -> list:
+        """构建原始 URL 和 URL 编码版本,用于认证重试"""
+        urls = [url]
+        try:
+            parsed = urlparse(url)
+            if parsed.username or parsed.password:
+                user = quote(parsed.username or '', safe='')
+                passwd = quote(parsed.password or '', safe='')
+                netloc = f"{user}:{passwd}@{parsed.hostname}"
+                if parsed.port:
+                    netloc += f":{parsed.port}"
+                encoded = urlunparse(parsed._replace(netloc=netloc))
+                if encoded != url:
+                    urls.append(encoded)
+        except Exception:
+            pass
+        return urls
+
     def run(self):
     def run(self):
         self.running = True
         self.running = True
         options = {
         options = {
@@ -55,51 +227,96 @@ class RTSPPlayer(QObject):
         start_time = time.time()
         start_time = time.time()
         first_packet = True
         first_packet = True
         container = None
         container = None
+        ffsub = None
+        use_ffmpeg_subprocess = False
 
 
+        # 先尝试 av 库
         try:
         try:
-            container = av.open(self.rtsp_url, options=options)
-            open_time = time.time() - start_time
-            logger.info(f"RTSP av.open 耗时 {open_time:.1f}s: {self.rtsp_url}")
-            video_stream = container.streams.video[0]
-            video_stream.thread_type = 'AUTO'
-            video_stream.thread_count = 1
-
-            last_emit = 0
-            frame_interval = 1.0 / self.target_fps
-
-            for packet in container.demux(video_stream):
-                if not self.running:
-                    break
-                if first_packet:
-                    first_packet = False
-                    logger.info(f"RTSP 首包到达耗时 {time.time() - start_time:.1f}s: {self.rtsp_url}")
-                    self.first_frame_received.emit()
-
-                for frame in packet.decode():
-                    if not self.running:
+            container, used_url = _try_av_open(self.rtsp_url, options)
+            if container:
+                if used_url != self.rtsp_url:
+                    logger.info(f"av 库认证成功(使用编码URL): {used_url}")
+                else:
+                    logger.info(f"av 库连接成功: {self.rtsp_url}")
+        except Exception as e:
+            logger.warning(f"av 库异常: {e}")
+
+        # av 失败时尝试系统 ffmpeg subprocess(支持 Digest 认证)
+        if container is None:
+            logger.info("av 库失败,尝试系统 ffmpeg subprocess...")
+            ffsub = _FFmpegSubprocess(self.rtsp_url, self.target_width, self.target_height, self.timeout_sec)
+            if ffsub.open():
+                use_ffmpeg_subprocess = True
+            else:
+                ffsub = None
+                logger.error(f"所有方式均失败: {self.rtsp_url}")
+                self.timeout.emit()
+                self.running = False
+                return
+
+        try:
+            if use_ffmpeg_subprocess:
+                # ===== 系统 ffmpeg subprocess 模式 =====
+                self.first_frame_received.emit()
+                last_emit = 0
+                frame_interval = 1.0 / self.target_fps
+                while self.running:
+                    img = ffsub.read_frame()
+                    if img is None:
+                        logger.warning("ffmpeg subprocess 读取帧失败,触发重连")
                         break
                         break
-                    img = frame.to_rgb().to_ndarray()
                     now = time.time()
                     now = time.time()
                     if now - last_emit >= frame_interval:
                     if now - last_emit >= frame_interval:
                         self.process_frame(img)
                         self.process_frame(img)
                         last_emit = now
                         last_emit = now
+                    self._last_frame_time = time.time()
+                self._timeout_emitted = True
+                self.timeout.emit()
+            else:
+                # ===== av 库模式 =====
+                video_stream = container.streams.video[0]
+                video_stream.thread_type = 'AUTO'
+                video_stream.thread_count = 1
+                last_emit = 0
+                frame_interval = 1.0 / self.target_fps
 
 
-                if first_packet and (time.time() - start_time) > self.timeout_sec:
-                    logger.warning("RTSP连接超时(未收到任何包)")
-                    self.timeout.emit()
-                    self.running = False
-                    break
-
-                if not self.running:
-                    break
+                for packet in container.demux(video_stream):
+                    if not self.running:
+                        break
+                    if first_packet:
+                        first_packet = False
+                        logger.info(f"RTSP 首包到达耗时 {time.time() - start_time:.1f}s: {self.rtsp_url}")
+                        self.first_frame_received.emit()
+                    for frame in packet.decode():
+                        if not self.running:
+                            break
+                        img = frame.to_rgb().to_ndarray()
+                        now = time.time()
+                        if now - last_emit >= frame_interval:
+                            self.process_frame(img)
+                            last_emit = now
+                        self._last_frame_time = time.time()
+                    if self._last_frame_time and (time.time() - self._last_frame_time) > self.timeout_sec:
+                        logger.warning("RTSP帧超时,触发重连")
+                        break
+                    if first_packet and (time.time() - start_time) > self.timeout_sec:
+                        logger.warning("RTSP连接超时")
+                        break
+                    if not self.running:
+                        break
+                self._timeout_emitted = True
+                self.timeout.emit()
 
 
         except Exception as e:
         except Exception as e:
             logger.error(f"RTSP错误: {e}")
             logger.error(f"RTSP错误: {e}")
-            if first_packet:
+            if not self._timeout_emitted:
+                self._timeout_emitted = True
                 self.timeout.emit()
                 self.timeout.emit()
         finally:
         finally:
             if container:
             if container:
                 container.close()
                 container.close()
+            if ffsub:
+                ffsub.close()
             self.running = False
             self.running = False
 
 
     def start(self):
     def start(self):
@@ -139,18 +356,54 @@ class RTSPPlayer(QObject):
             x_off = y_off = 0
             x_off = y_off = 0
 
 
         for obj in objects:
         for obj in objects:
+            if obj.get('text'):
+                pos = obj.get('pos', (8, 20))
+                color = obj.get('color', (0, 255, 0))
+                if obj.get('anchor') == 'tr':
+                    w = cv2.getTextSize(obj['text'], cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0][0]
+                    tx = frame.shape[1] - w - int(pos[0])
+                    ty = int(pos[1])
+                else:
+                    tx = int(pos[0] * sx + x_off)
+                    ty = int(pos[1] * sy + y_off)
+                cv2.putText(frame, obj['text'], (tx, ty),
+                            cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
+                continue
+            if obj.get('points'):
+                pts = [(int((p[0] * sx) + x_off), int((p[1] * sy) + y_off)) for p in obj['points']]
+                color = obj.get('color', (0, 255, 0))
+                alpha = obj.get('alpha', 1.0)
+                thickness = obj.get('thickness', 2)
+                label = obj.get('label', '')
+                if len(pts) >= 2:
+                    if alpha < 1.0:
+                        overlay = frame.copy()
+                        cv2.polylines(overlay, [np.array(pts, dtype=np.int32)], False, color, thickness)
+                        cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
+                    else:
+                        cv2.polylines(frame, [np.array(pts, dtype=np.int32)], False, color, thickness)
+                if label and pts:
+                    tx, ty = pts[-1]
+                    cv2.putText(frame, label, (tx + 3, ty - 3), cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1)
+                continue
             x = int(obj.get('x', 0) * sx + x_off)
             x = int(obj.get('x', 0) * sx + x_off)
             y = int(obj.get('y', 0) * sy + y_off)
             y = int(obj.get('y', 0) * sy + y_off)
             w = int(obj.get('w', 0) * sx)
             w = int(obj.get('w', 0) * sx)
             h = int(obj.get('h', 0) * sy)
             h = int(obj.get('h', 0) * sy)
             label = obj.get('label', '')
             label = obj.get('label', '')
             color = obj.get('color', (0, 255, 0))
             color = obj.get('color', (0, 255, 0))
-            dimmed = obj.get('dimmed', False)
-            thickness = 1 if dimmed else 2
-            if dimmed:
+            alpha = obj.get('alpha', 1.0)
+            thickness = 1 if alpha < 1.0 else (1 if obj.get('dimmed') else 2)
+            if obj.get('dimmed') and alpha >= 1.0:
                 color = tuple(c // 2 for c in color)
                 color = tuple(c // 2 for c in color)
-            cv2.rectangle(frame, (x, y), (x+w, y+h), color, thickness)
-            cv2.putText(frame, label, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, thickness)
+            if alpha < 1.0:
+                overlay = frame.copy()
+                cv2.rectangle(overlay, (x, y), (x+w, y+h), color, thickness)
+                cv2.putText(overlay, label, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, thickness)
+                cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
+            else:
+                cv2.rectangle(frame, (x, y), (x+w, y+h), color, thickness)
+                cv2.putText(frame, label, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, thickness)
 
 
         h, w, ch = frame.shape
         h, w, ch = frame.shape
         bytes_per_line = ch * w
         bytes_per_line = ch * w

+ 14 - 16
main.py

@@ -5,19 +5,18 @@ import json
 from PySide6.QtWidgets import QApplication
 from PySide6.QtWidgets import QApplication
 from qt_material import apply_stylesheet
 from qt_material import apply_stylesheet
 from hookvision.config_loader import load_config
 from hookvision.config_loader import load_config
-from hookvision.main_window import MainWindow
+from hookvision.main_window_v2 import MainWindow
 import logging
 import logging
 log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hookvision.log")
 log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hookvision.log")
 logging.basicConfig(level=logging.WARNING, format='%(asctime)s - %(levelname)s - %(message)s',
 logging.basicConfig(level=logging.WARNING, format='%(asctime)s - %(levelname)s - %(message)s',
                     handlers=[logging.FileHandler(log_path, encoding='utf-8'), logging.StreamHandler()])
                     handlers=[logging.FileHandler(log_path, encoding='utf-8'), logging.StreamHandler()])
 
 
 def _autostart_diag():
 def _autostart_diag():
-    import winreg
     log = []
     log = []
     log.append(f"sys.executable = {sys.executable}")
     log.append(f"sys.executable = {sys.executable}")
     log.append(f"sys.platform = {sys.platform}")
     log.append(f"sys.platform = {sys.platform}")
     log.append(f"frozen = {getattr(sys, 'frozen', False)}")
     log.append(f"frozen = {getattr(sys, 'frozen', False)}")
-    config_dir = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "HookVision")
+    config_dir = os.path.join(os.path.expanduser("~"), ".config", "HookVision")
     config_path = os.path.join(config_dir, "autostart.json")
     config_path = os.path.join(config_dir, "autostart.json")
     log.append(f"config_path = {config_path}")
     log.append(f"config_path = {config_path}")
     try:
     try:
@@ -30,17 +29,9 @@ def _autostart_diag():
         os.remove(config_path)
         os.remove(config_path)
     except Exception as e:
     except Exception as e:
         log.append(f"JSON FAIL: {e}")
         log.append(f"JSON FAIL: {e}")
-    key = r"Software\Microsoft\Windows\CurrentVersion\Run"
-    try:
-        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
-            winreg.SetValueEx(rk, "HookVision", 0, winreg.REG_SZ, sys.executable)
-        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_READ) as rk:
-            val, _ = winreg.QueryValueEx(rk, "HookVision")
-        log.append(f"Registry OK: {val}")
-        with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key, 0, winreg.KEY_SET_VALUE) as rk:
-            winreg.DeleteValue(rk, "HookVision")
-    except Exception as e:
-        log.append(f"Registry FAIL: {e}")
+    if sys.platform != "win32":
+        desktop_path = os.path.expanduser("~/.config/autostart/HookVision.desktop")
+        log.append(f"Linux autostart path = {desktop_path}")
     msg = "\n".join(log)
     msg = "\n".join(log)
     print(msg)
     print(msg)
     with open("autostart_diag.txt", "w", encoding="utf-8") as f:
     with open("autostart_diag.txt", "w", encoding="utf-8") as f:
@@ -61,8 +52,15 @@ def main():
     if "--dev" in sys.argv:
     if "--dev" in sys.argv:
         config_name = "config.dev.ini"
         config_name = "config.dev.ini"
 
 
-    if getattr(sys, 'frozen', False):
-        config_path = os.path.join(sys._MEIPASS, config_name)
+    if is_frozen:
+        # PyInstaller 打包后,优先读取可执行文件同目录的配置(部署目录)
+        # 而不是临时解压目录 sys._MEIPASS(打包时嵌入的默认配置)
+        app_dir = os.path.dirname(sys.executable)
+        app_config = os.path.join(app_dir, config_name)
+        if os.path.exists(app_config):
+            config_path = app_config
+        else:
+            config_path = os.path.join(sys._MEIPASS, config_name)
     else:
     else:
         config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), config_name)
         config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), config_name)
     config = load_config(config_path)
     config = load_config(config_path)

+ 27 - 0
project_description.txt

@@ -0,0 +1,27 @@
+项目名称:HookVision - 钢包工位视频监测系统
+
+项目简介:
+工业级桌面监控应用,部署于钢铁生产现场,实现钢包工位的实时视频监测、自动化工位识别与AI目标检测标注。采用PyInstaller打包为Windows可执行程序,支持开机自启动。
+
+技术栈:
+- 开发语言:Python 3.8+
+- GUI框架:PySide6(Qt for Python)
+- 视频处理:OpenCV(RTSP视频流解码与预览)
+- 工业通信:pymodbus(Modbus TCP)、RFID Socket通信
+- 消息队列:paho-mqtt(AI识别模块集成)
+- AI推理:NVIDIA DeepStream SDK
+- 打包工具:PyInstaller
+- 辅助库:pywin32(Windows注册表操作)、numpy
+
+主要功能与职责:
+1. 双路RTSP实时视频预览系统开发,支持多工位摄像头切换
+2. RFID/Modbus自动工位识别模块,实现无感工位切换
+3. 基于NVIDIA DeepStream的AI推理模块开发,负责目标检测推理,通过MQTT协议将检测结果实时下发并叠加标注到视频画面
+4. 多工位配置管理(INI配置文件),支持5个工位独立参数配置
+5. 自动/手动模式切换,空闲超时清空等智能化功能
+6. 跨平台兼容设计,支持Windows和Linux系统
+
+项目成果:
+- 成功部署于工业现场,提升钢包工位监控自动化水平
+- 实现RFID自动识别与AI检测的无缝集成
+- 系统稳定运行,支持长时间连续监控

+ 1 - 1
requirements.txt

@@ -2,4 +2,4 @@ PySide6>=6.5.0
 opencv-python>=4.8.0
 opencv-python>=4.8.0
 pymodbus>=3.5.0
 pymodbus>=3.5.0
 paho-mqtt>=1.6.0
 paho-mqtt>=1.6.0
-pywin32>=306
+sounddevice>=0.4.6

+ 361 - 0
tests/test_calibration.py

@@ -0,0 +1,361 @@
+"""标定功能测试:偏移计算、1 秒平均采集、持久化与标定后 READY。"""
+
+import time
+
+import pytest
+
+from hookvision.models import HookState, Config, BucketModel
+from hookvision.config_loader import load_config
+from hookvision import main_window_v2 as mwmod
+from hookvision.main_window_v2 import MainWindow
+
+
+def _obj(label, x, y, w=20, h=20):
+    return {"x": x, "y": y, "w": w, "h": h, "label": label}
+
+
+def _fresh(mw):
+    mw.hook_state = HookState.IDLE
+    mw.prev_state = HookState.IDLE
+    mw.state_counter = 0
+    mw._pair_traj = {"left": [], "right": []}
+    mw._voice_suppress_until = 0.0
+    mw.prev_left_hook = None
+    mw.prev_left_lug = None
+    mw.prev_right_hook = None
+    mw.prev_right_lug = None
+
+
+def _write_minimal_config(path):
+    path.write_text(
+        """[App]
+name = 测试系统
+copyright = 测试
+screen = 0
+
+[Video]
+rtsp_timeout = 5
+
+[Hardware]
+module_type = RFID
+
+[RFID]
+ip = 192.168.1.1
+port = 60000
+stability_time = 5
+stability_count = 3
+
+[AI]
+enabled = true
+
+[Bucket:0]
+id = 1
+description = 测试工位
+code_x = TEST001
+left_rtsp =
+right_rtsp =
+left_topic =
+right_topic =
+""", encoding="utf-8"
+    )
+
+
+class TestComputeOffsetRatios:
+    def test_normalized_ratios(self, qapp):
+        mw = MainWindow(Config())
+        # hook 中心 (40,40),lug 中心 (50,50),lug 20x20 → xr=yr=-0.5
+        mw.left_objects = [_obj("hook", 30, 30), _obj("lug", 40, 40)]
+        assert mw._compute_offset_ratios("left") == (-0.5, -0.5)
+
+    def test_no_intersecting_pair_none(self, qapp):
+        mw = MainWindow(Config())
+        mw.left_objects = [_obj("hook", 0, 0), _obj("lug", 200, 200)]
+        assert mw._compute_offset_ratios("left") is None
+
+    def test_missing_objects_none(self, qapp):
+        mw = MainWindow(Config())
+        assert mw._compute_offset_ratios("left") is None
+        mw.right_objects = [_obj("hook", 0, 0)]
+        assert mw._compute_offset_ratios("right") is None
+
+    def test_best_intersecting_pair_ignores_far_pair(self, qapp):
+        mw = MainWindow(Config())
+        # 远处的配对不相交,应被忽略;只有近处相交配对参与
+        mw.left_objects = [_obj("hook", 0, 0), _obj("lug", 200, 200),
+                           _obj("hook", 30, 30), _obj("lug", 40, 40)]
+        assert mw._compute_offset_ratios("left") == (-0.5, -0.5)
+
+    def test_is_bucket_calibrated(self, qapp):
+        mw = MainWindow(Config())
+        b = BucketModel(id=1, description="t", code_x="X")
+        assert mw._is_bucket_calibrated(b) is False
+        b.hook_lug_x_offset_ratio_left = 0.0
+        b.hook_lug_y_offset_ratio_left = 0.0
+        assert mw._is_bucket_calibrated(b) is True
+
+    def test_y_axis_uses_bottom_edge_not_center(self, qapp):
+        mw = MainWindow(Config())
+        # hook(30,30,20,40):底边 y+h=70,中心 y=50;lug(40,50,20,20):底边 70,中心 60
+        # 底边对齐 → yr=0.0;若按中心算会是 (50-60)/20=-0.5
+        mw.left_objects = [_obj("hook", 30, 30, w=20, h=40), _obj("lug", 40, 50)]
+        assert mw._compute_offset_ratios("left") == (-0.5, 0.0)
+
+    def test_compute_ratios_skips_lug_at_top(self, qapp):
+        # 吊耳贴着画面上沿:采样无效,不参与标定
+        mw = MainWindow(Config())
+        mw.left_objects = [_obj("hook", 40, 0), _obj("lug", 40, 0)]
+        assert mw._compute_offset_ratios("left") is None
+
+
+class TestCalibrationFlow:
+    def _make_mw(self, tmp_path):
+        cfg_path = tmp_path / "config.ini"
+        _write_minimal_config(cfg_path)
+        config = load_config(str(cfg_path))
+        mw = MainWindow(config)
+        _fresh(mw)
+        mw._config_path = str(cfg_path)
+        mw.active_bucket = config.buckets[0]
+        return mw, cfg_path
+
+    def test_sample_and_finish_writes_offsets(self, qapp, tmp_path, monkeypatch):
+        monkeypatch.setattr(mwmod.QMessageBox, "information", lambda *a, **k: None)
+        mw, _ = self._make_mw(tmp_path)
+        bucket = mw.active_bucket
+        mw.left_objects = [_obj("hook", 30, 30), _obj("lug", 40, 40)]
+        mw._start_calibration()
+        for _ in range(5):
+            mw._sample_calibration()
+        assert bucket.hook_lug_x_offset_ratio_left is None
+        mw._calib_until = time.time() - 1
+        mw._sample_calibration()
+        assert bucket.hook_lug_x_offset_ratio_left is not None
+        assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(-0.5, abs=1e-6)
+        assert bucket.hook_lug_y_offset_ratio_left == pytest.approx(-0.5, abs=1e-6)
+
+    def test_sample_requires_intersecting_pair(self, qapp, tmp_path, monkeypatch):
+        monkeypatch.setattr(mwmod.QMessageBox, "information", lambda *a, **k: None)
+        mw, _ = self._make_mw(tmp_path)
+        bucket = mw.active_bucket
+        mw.left_objects = [_obj("hook", 0, 0), _obj("lug", 200, 200)]
+        mw._start_calibration()
+        for _ in range(5):
+            mw._sample_calibration()
+        mw._calib_until = time.time() - 1
+        mw._sample_calibration()
+        assert bucket.hook_lug_x_offset_ratio_left is None
+        assert mw._is_bucket_calibrated(bucket) is False
+
+    def test_calibrated_bucket_reaches_ready(self, qapp, tmp_path, monkeypatch):
+        monkeypatch.setattr(mwmod.QMessageBox, "information", lambda *a, **k: None)
+        mw, _ = self._make_mw(tmp_path)
+        # 先标定(中心对齐,偏移 0,0)
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        mw._start_calibration()
+        for _ in range(5):
+            mw._sample_calibration()
+        mw._calib_until = time.time() - 1
+        mw._sample_calibration()
+        assert mw.active_bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
+        # 重新喂帧 → 标定值匹配 → READY
+        _fresh(mw)
+        for _ in range(3):
+            mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+
+    def test_reset_calibration(self, qapp, tmp_path):
+        mw, _ = self._make_mw(tmp_path)
+        bucket = mw.active_bucket
+        bucket.hook_lug_x_offset_ratio_left = -0.5
+        bucket.hook_lug_y_offset_ratio_left = -0.5
+        assert mw._is_bucket_calibrated(bucket) is True
+        mw._reset_calibration()
+        assert mw._is_bucket_calibrated(bucket) is False
+
+
+class TestCalibrationPersistence:
+    def test_save_config_round_trip(self, qapp, tmp_path):
+        cfg_path = tmp_path / "config.ini"
+        _write_minimal_config(cfg_path)
+        config = load_config(str(cfg_path))
+        mw = MainWindow(config)
+        mw._config_path = str(cfg_path)
+        bucket = config.buckets[0]
+        bucket.hook_lug_x_offset_ratio_left = -0.25
+        bucket.hook_lug_y_offset_ratio_left = 0.125
+        bucket.hook_lug_x_offset_ratio_right = 0.0
+        bucket.hook_lug_y_offset_ratio_right = 0.0
+        mw._save_config()
+        reloaded = load_config(str(cfg_path))
+        b = reloaded.buckets[0]
+        assert b.hook_lug_x_offset_ratio_left == pytest.approx(-0.25, abs=1e-6)
+        assert b.hook_lug_y_offset_ratio_left == pytest.approx(0.125, abs=1e-6)
+        assert b.hook_lug_x_offset_ratio_right == pytest.approx(0.0, abs=1e-6)
+        assert b.hook_lug_y_offset_ratio_right == pytest.approx(0.0, abs=1e-6)
+        assert reloaded.ai.offset_ratio_tolerance == 0.25
+        assert reloaded.ai.ready_debounce == 3
+
+    def test_save_config_removes_uncalibrated_keys(self, qapp, tmp_path):
+        cfg_path = tmp_path / "config.ini"
+        _write_minimal_config(cfg_path)
+        config = load_config(str(cfg_path))
+        mw = MainWindow(config)
+        mw._config_path = str(cfg_path)
+        mw._save_config()
+        reloaded = load_config(str(cfg_path))
+        b = reloaded.buckets[0]
+        assert b.hook_lug_x_offset_ratio_left is None
+        assert b.hook_lug_y_offset_ratio_left is None
+
+
+class TestCalibrationUI:
+    def test_calib_button_exists(self, qapp):
+        mw = MainWindow(Config())
+        assert mw.calib_btn is not None
+        assert mw.calib_btn.text() == "标定"
+
+    def test_badge_refresh(self, qapp):
+        bucket = BucketModel(id=1, description="t", code_x="X")
+        config = Config()
+        config.buckets = [bucket]
+        mw = MainWindow(config)
+        assert bucket.id in mw._calib_badge_labels
+        mw.refresh_bucket_icons()
+        assert mw._calib_badge_labels[bucket.id].styleSheet() == "background-color: #dcdfe6; border-radius: 2px;"
+        assert "未标定" in mw._calib_badge_labels[bucket.id].toolTip()
+        bucket.hook_lug_x_offset_ratio_left = 0.0
+        bucket.hook_lug_y_offset_ratio_left = 0.0
+        mw.refresh_bucket_icons()
+        assert mw._calib_badge_labels[bucket.id].styleSheet() == "background-color: #67c23a; border-radius: 2px;"
+        assert "已标定" in mw._calib_badge_labels[bucket.id].toolTip()
+
+
+def _bucket_with_cameras(cfg_bucket):
+    from hookvision.models import CameraModel, CameraPosition
+    cfg_bucket.cameras = [
+        CameraModel(position=CameraPosition.LEFT, rtsp_url="", topic="test/left"),
+        CameraModel(position=CameraPosition.RIGHT, rtsp_url="", topic="test/right"),
+    ]
+    return cfg_bucket
+
+
+class TestAutoCalibration:
+    def _make_mw(self, qapp, tmp_path):
+        cfg_path = tmp_path / "config.ini"
+        _write_minimal_config(cfg_path)
+        config = load_config(str(cfg_path))
+        bucket = _bucket_with_cameras(config.buckets[0])
+        mw = MainWindow(config)
+        _fresh(mw)
+        mw._config_path = str(cfg_path)
+        mw.active_bucket = bucket
+        return mw, bucket
+
+    def _ready_trigger(self, mw, side="left"):
+        for _ in range(3):
+            mw._update_auto_calibration(side, True)
+
+    def test_no_camera_side_not_learned(self, qapp, tmp_path):
+        # 该侧无摄像头 → 不自动标定
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        bucket.cameras = []
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        self._ready_trigger(mw)
+        assert bucket.hook_lug_x_offset_ratio_left is None
+
+    def test_no_active_bucket_not_learned(self, qapp, tmp_path):
+        # 无活动工位 → 不自动标定
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.active_bucket = None
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        self._ready_trigger(mw)
+        assert bucket.hook_lug_x_offset_ratio_left is None
+
+    def test_three_ready_within_window_writes(self, qapp, tmp_path):
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        self._ready_trigger(mw)
+        assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
+        assert bucket.hook_lug_y_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
+
+    def test_two_ready_not_enough(self, qapp, tmp_path):
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        for _ in range(2):
+            mw._update_auto_calibration("left", True)
+        assert bucket.hook_lug_x_offset_ratio_left is None
+
+    def test_interleaved_not_ready_tolerated(self, qapp, tmp_path):
+        # 非连续:1 秒窗口内 3 帧 ready 即可触发,中间的非 ready 帧不清零
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        mw._update_auto_calibration("left", True)
+        mw._update_auto_calibration("left", False)
+        mw._update_auto_calibration("left", True)
+        mw._update_auto_calibration("left", True)
+        assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
+
+    def test_frames_outside_window_not_counted(self, qapp, tmp_path):
+        # 超过窗口时长的 ready 帧互不累计
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.config.ai.auto_calib_duration_secs = 0.002
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        for _ in range(3):
+            mw._update_auto_calibration("left", True)
+            time.sleep(0.005)
+        assert bucket.hook_lug_x_offset_ratio_left is None
+
+    def test_calibrated_side_not_overwritten(self, qapp, tmp_path):
+        # 已标定侧 → 自动标定不应覆盖
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        bucket.hook_lug_x_offset_ratio_left = -0.25
+        bucket.hook_lug_y_offset_ratio_left = 0.125
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        self._ready_trigger(mw)
+        assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(-0.25, abs=1e-6)
+        assert bucket.hook_lug_y_offset_ratio_left == pytest.approx(0.125, abs=1e-6)
+
+    def test_both_sides_calibrated_automatically(self, qapp, tmp_path):
+        # 无需入队:左右未标定侧连续识别 ready 自动全部标定
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        for side in ("left", "right"):
+            mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+            mw.right_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+            self._ready_trigger(mw, side)
+        assert bucket.hook_lug_x_offset_ratio_left is not None
+        assert bucket.hook_lug_x_offset_ratio_right is not None
+        assert bucket.id not in getattr(mw, "_auto_calib_pending", set())
+
+    def test_reset_then_auto_recalibrates(self, qapp, tmp_path):
+        # 重置后再次 ready 自动重新标定
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        bucket.hook_lug_x_offset_ratio_left = -0.5
+        bucket.hook_lug_y_offset_ratio_left = -0.5
+        assert mw._is_bucket_calibrated(bucket) is True
+        mw._reset_calibration()
+        assert bucket.hook_lug_x_offset_ratio_left is None
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        self._ready_trigger(mw)
+        assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
+
+    def test_reset_calib_button_exists(self, qapp):
+        mw = MainWindow(Config())
+        assert mw.reset_calib_btn is not None
+        assert mw.reset_calib_btn.text() == "重置标定"
+
+    def test_custom_ready_frames_honored(self, qapp, tmp_path):
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.config.ai.auto_calib_ready_frames = 2
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        mw._update_auto_calibration("left", True)
+        assert bucket.hook_lug_x_offset_ratio_left is None
+        mw._update_auto_calibration("left", True)
+        assert bucket.hook_lug_x_offset_ratio_left == pytest.approx(0.0, abs=1e-6)
+
+    def test_custom_min_samples_not_written_too_early(self, qapp, tmp_path):
+        mw, bucket = self._make_mw(qapp, tmp_path)
+        mw.config.ai.auto_calib_min_samples = 5
+        mw.left_objects = [_obj("hook", 40, 40), _obj("lug", 40, 40)]
+        self._ready_trigger(mw)
+        assert bucket.hook_lug_x_offset_ratio_left is None

+ 70 - 0
tests/test_config_loader.py

@@ -42,3 +42,73 @@ class TestLoadConfig:
         import pytest
         import pytest
         with pytest.raises(NoSectionError):
         with pytest.raises(NoSectionError):
             load_config(str(path))
             load_config(str(path))
+
+
+def _write_ai_config(path, extra_ai_lines=""):
+    path.write_text(
+        f"""[App]
+name = 测试系统
+copyright = 测试
+screen = 0
+
+[Video]
+rtsp_timeout = 5
+
+[Hardware]
+module_type = RFID
+
+[RFID]
+ip = 192.168.1.1
+port = 60000
+stability_time = 5
+stability_count = 3
+
+[AI]
+enabled = true
+{extra_ai_lines}
+""", encoding="utf-8"
+    )
+
+
+class TestAutoCalibConfig:
+    def test_missing_keys_use_defaults(self, tmp_path):
+        path = tmp_path / "config.ini"
+        _write_ai_config(path)
+        config = load_config(str(path))
+        assert config.ai.auto_calib_ready_frames == 3
+        assert config.ai.auto_calib_duration_secs == 1.0
+        assert config.ai.auto_calib_min_samples == 2
+
+    def test_custom_values_loaded(self, tmp_path):
+        path = tmp_path / "config.ini"
+        _write_ai_config(
+            path,
+            "auto_calib_ready_frames = 10\n"
+            "auto_calib_duration_secs = 2.5\n"
+            "auto_calib_min_samples = 4\n",
+        )
+        config = load_config(str(path))
+        assert config.ai.auto_calib_ready_frames == 10
+        assert config.ai.auto_calib_duration_secs == 2.5
+        assert config.ai.auto_calib_min_samples == 4
+
+    def test_missing_keys_auto_initialized(self, tmp_path):
+        path = tmp_path / "config.ini"
+        _write_ai_config(path)
+        load_config(str(path))
+        text = path.read_text(encoding="utf-8")
+        assert "auto_calib_ready_frames = 3" in text
+        assert "auto_calib_duration_secs = 1.0" in text
+        assert "auto_calib_min_samples = 2" in text
+        reloaded = load_config(str(path))
+        assert reloaded.ai.auto_calib_ready_frames == 3
+        assert reloaded.ai.auto_calib_duration_secs == 1.0
+        assert reloaded.ai.auto_calib_min_samples == 2
+
+    def test_existing_custom_keys_not_overwritten(self, tmp_path):
+        path = tmp_path / "config.ini"
+        _write_ai_config(path, "auto_calib_ready_frames = 7\n")
+        load_config(str(path))
+        text = path.read_text(encoding="utf-8")
+        assert "auto_calib_ready_frames = 7" in text
+        assert "auto_calib_duration_secs = 1.0" in text

+ 1 - 1
tests/test_main_window.py

@@ -1,7 +1,7 @@
 import pytest
 import pytest
 from PySide6.QtWidgets import QMainWindow
 from PySide6.QtWidgets import QMainWindow
 from hookvision.models import HookState, Config
 from hookvision.models import HookState, Config
-from hookvision.main_window import MainWindow
+from hookvision.main_window_v2 import MainWindow
 from hookvision.config_loader import load_config
 from hookvision.config_loader import load_config
 
 
 
 

+ 7 - 11
tests/test_models.py

@@ -1,6 +1,6 @@
 from hookvision.models import (
 from hookvision.models import (
     ModuleType, CameraPosition, CameraState, SwitchMode, HookState,
     ModuleType, CameraPosition, CameraState, SwitchMode, HookState,
-    CameraModel, BucketModel, NVRModel, AIConfig, ModbusConfig,
+    CameraModel, BucketModel, AIConfig, ModbusConfig,
     SocketConfig, VideoConfig, Config
     SocketConfig, VideoConfig, Config
 )
 )
 
 
@@ -45,18 +45,15 @@ class TestModels:
         assert b.ready_x is False
         assert b.ready_x is False
         assert b.ready_y is True
         assert b.ready_y is True
 
 
-    def test_nvr_model_defaults(self):
-        nvr = NVRModel(id=0, name="test", ip="192.168.1.1")
-        assert nvr.port == 8000
-        assert nvr.username == "admin"
-        assert nvr.password == ""
-
     def test_ai_config_defaults(self):
     def test_ai_config_defaults(self):
         ai = AIConfig()
         ai = AIConfig()
         assert ai.enabled is False
         assert ai.enabled is False
-        assert ai.broker == "127.0.0.1"
-        assert ai.port == 1883
-        assert ai.topics == []
+        assert ai.tts_enabled is False
+        assert ai.offset_ratio_tolerance == 0.25
+        assert ai.ready_debounce == 3
+        assert ai.auto_calib_ready_frames == 3
+        assert ai.auto_calib_duration_secs == 1.0
+        assert ai.auto_calib_min_samples == 2
 
 
     def test_modbus_config(self):
     def test_modbus_config(self):
         m = ModbusConfig(ip="192.168.1.1")
         m = ModbusConfig(ip="192.168.1.1")
@@ -79,6 +76,5 @@ class TestModels:
         assert c.app_name == "钢包工位视频监测系统"
         assert c.app_name == "钢包工位视频监测系统"
         assert c.module_type == ModuleType.RFID
         assert c.module_type == ModuleType.RFID
         assert c.buckets == []
         assert c.buckets == []
-        assert c.nvrs == []
         assert c.rfid is None
         assert c.rfid is None
         assert c.modbus is None
         assert c.modbus is None

+ 65 - 0
tests/test_rfid_client.py

@@ -0,0 +1,65 @@
+import time
+from types import SimpleNamespace
+
+from hookvision.rfid_client import RFIDClient, _MIN_BACKOFF
+
+
+class _FakeSock:
+    def recv(self, size):
+        raise OSError("connection reset")
+
+    def close(self):
+        pass
+
+
+def _make_client(callbacks=None):
+    callbacks = callbacks or {}
+    config = SimpleNamespace(ip="127.0.0.1", port=1, stability_time=5, stability_count=3)
+    client = RFIDClient(config, on_tag_callback=callbacks.get("on_tag"), on_state_changed=callbacks.get("on_state"))
+    return client
+
+
+class TestSessionCloseKeepsTag:
+    def test_read_loop_exit_does_not_clear_tag(self):
+        events = []
+        client = _make_client({"on_tag": lambda tag: events.append(tag)})
+        client.running = True
+        client.sock = _FakeSock()
+        client._read_loop()
+        assert client.sock is None
+        assert client._backoff == _MIN_BACKOFF
+        assert client._backoff == 1
+        assert events == []
+
+    def test_check_idle_does_not_reconnect_within_backoff(self):
+        events = []
+        client = _make_client({"on_tag": lambda tag: events.append(tag)})
+        client._last_connect_attempt = time.time()
+        client.check_idle()
+        assert client.sock is None
+        assert events == []
+
+
+class TestIdleTimeout:
+    def test_idle_timeout_still_clears_tag_when_connected(self):
+        events = []
+        client = _make_client({"on_tag": lambda tag: events.append(tag)})
+        client._last_tag = "AABBCC"
+        client._last_time = time.time() - 10
+        client._last_connect_attempt = time.time()
+        # 模拟已连接状态
+        client.sock = _FakeSock()
+        client.check_idle()
+        assert client._last_tag is None
+        assert events == [None]
+
+    def test_idle_timeout_does_not_clear_tag_when_disconnected(self):
+        events = []
+        client = _make_client({"on_tag": lambda tag: events.append(tag)})
+        client._last_tag = "AABBCC"
+        client._last_time = time.time() - 10
+        client._last_connect_attempt = time.time()
+        # socket为None(断开状态)
+        client.check_idle()
+        assert client._last_tag == "AABBCC"
+        assert events == []

+ 100 - 20
tests/test_state_machine.py

@@ -1,16 +1,16 @@
 """Test hook state evaluation logic (evaluate_hook_state).
 """Test hook state evaluation logic (evaluate_hook_state).
 
 
-The state machine transitions:
+The state machine transitions (main_window_v2):
   IDLE -> HOOKING (hook-lug intersecting, debounce 3 frames)
   IDLE -> HOOKING (hook-lug intersecting, debounce 3 frames)
-  HOOKING -> READY (hook-lug intersecting + ready object, debounce 3)
-  HOOKING/READY -> IDLE (no intersection, immediate)
+  HOOKING -> READY (calibrated offset match via _side_match, debounce ready_debounce)
+  HOOKING/READY -> IDLE (no intersection; READY exits after ready_debounce frames)
   Any -> RELEASING (downward movement, debounce 3)
   Any -> RELEASING (downward movement, debounce 3)
 
 
 For RELEASING: both hook and lug centers must move down by > RELEASE_DELTA_THRESHOLD pixels.
 For RELEASING: both hook and lug centers must move down by > RELEASE_DELTA_THRESHOLD pixels.
 """
 """
 
 
 import pytest
 import pytest
-from hookvision.models import HookState
+from hookvision.models import HookState, Config, BucketModel
 
 
 
 
 def make_hook(x, y, w=20, h=20):
 def make_hook(x, y, w=20, h=20):
@@ -25,8 +25,17 @@ def make_ready(x, y, w=10, h=10):
     return {"x": x, "y": y, "w": w, "h": h, "label": "ready", "color": (0, 255, 255)}
     return {"x": x, "y": y, "w": w, "h": h, "label": "ready", "color": (0, 255, 255)}
 
 
 
 
+def calibrated_bucket(x_off=0.0, y_off=0.0):
+    b = BucketModel(id=1, description="test", code_x="X")
+    b.hook_lug_x_offset_ratio_left = x_off
+    b.hook_lug_y_offset_ratio_left = y_off
+    b.hook_lug_x_offset_ratio_right = x_off
+    b.hook_lug_y_offset_ratio_right = y_off
+    return b
+
+
 class MockMainWindow:
 class MockMainWindow:
-    """Minimal mock of MainWindow for testing evaluate_hook_state."""
+    """Minimal mock of MainWindow_v2 for testing evaluate_hook_state."""
 
 
     STATE_THRESHOLD = 3
     STATE_THRESHOLD = 3
     RELEASE_DELTA_THRESHOLD = 5
     RELEASE_DELTA_THRESHOLD = 5
@@ -41,10 +50,13 @@ class MockMainWindow:
         self.prev_right_hook = None
         self.prev_right_hook = None
         self.prev_left_lug = None
         self.prev_left_lug = None
         self.prev_right_lug = None
         self.prev_right_lug = None
+        self.active_bucket = None
+        self.config = Config()
+        self._calib_warned = set()
         self.last_state = None  # tracks update_ui_for_state calls
         self.last_state = None  # tracks update_ui_for_state calls
 
 
     def evaluate_hook_state(self):
     def evaluate_hook_state(self):
-        # This is the exact implementation from main_window.py
+        # This is the exact implementation from main_window_v2.py
         left_hooks = [o for o in self.left_objects if 'hook' in o['label'].lower()]
         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()]
         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_hooks = [o for o in self.right_objects if 'hook' in o['label'].lower()]
@@ -70,6 +82,19 @@ class MockMainWindow:
         left_pair = find_nearest_pair(left_hooks, left_lugs)
         left_pair = find_nearest_pair(left_hooks, left_lugs)
         right_pair = find_nearest_pair(right_hooks, right_lugs)
         right_pair = find_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
         is_releasing = False
 
 
         # 处理左侧配对
         # 处理左侧配对
@@ -104,17 +129,25 @@ class MockMainWindow:
         if is_releasing:
         if is_releasing:
             new_state = HookState.RELEASING
             new_state = HookState.RELEASING
         else:
         else:
-            any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
-            has_intersecting_pair = self._has_intersecting_pair(left_pair, right_pair)
-            if any_ready and has_intersecting_pair:
+            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
                 new_state = HookState.READY
-            elif has_intersecting_pair:
+            elif self._has_intersecting_pair(left_pair, right_pair):
                 new_state = HookState.HOOKING
                 new_state = HookState.HOOKING
             else:
             else:
                 new_state = HookState.IDLE
                 new_state = HookState.IDLE
 
 
-        # 防抖
-        threshold = 1 if new_state == HookState.IDLE else self.STATE_THRESHOLD
+        # 防抖:进出 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:
         if new_state == self.prev_state:
             self.state_counter += 1
             self.state_counter += 1
         else:
         else:
@@ -136,6 +169,19 @@ class MockMainWindow:
             right_pair[0]['paired'] = True
             right_pair[0]['paired'] = True
             right_pair[1]['paired'] = True
             right_pair[1]['paired'] = True
 
 
+    def _push_pair_traj(self, side, pair):
+        pass
+
+    def _update_suppress_window(self):
+        pass
+
+    def _clear_state_display(self):
+        self.last_state = None
+
+    @staticmethod
+    def _lug_touches_top(l):
+        return l['y'] <= 0
+
     def rects_intersect(self, a, b):
     def rects_intersect(self, a, b):
         ax1, ay1, ax2, ay2 = a['x'], a['y'], a['x']+a['w'], a['y']+a['h']
         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']
         bx1, by1, bx2, by2 = b['x'], b['y'], b['x']+b['w'], b['y']+b['h']
@@ -152,6 +198,27 @@ class MockMainWindow:
                 return True
                 return True
         return False
         return False
 
 
+    def _side_match(self, side, pair):
+        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:
+            return False
+        tol = self.config.ai.offset_ratio_tolerance
+        return abs(xr - cal_x) <= tol and abs(yr - cal_y) <= tol
+
 
 
 class TestStateMachine:
 class TestStateMachine:
     def test_initial_state_idle(self):
     def test_initial_state_idle(self):
@@ -200,12 +267,25 @@ class TestStateMachine:
         mw.evaluate_hook_state()
         mw.evaluate_hook_state()
         assert mw.hook_state == HookState.IDLE
         assert mw.hook_state == HookState.IDLE
 
 
+    def test_lug_at_top_skips_judgment(self):
+        mw = MockMainWindow()
+        mw.active_bucket = calibrated_bucket()
+        mw.left_objects = [make_hook(40, 40), make_lug(40, 40)]
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+        # 吊耳贴着画面上沿 → 不判定、状态重置为 IDLE、不显示状态框
+        mw.left_objects = [make_hook(40, 0), make_lug(40, 0)]
+        mw.evaluate_hook_state()
+        assert mw.last_state is None
+        assert mw.hook_state == HookState.IDLE
+
     def test_ready_state(self):
     def test_ready_state(self):
         mw = MockMainWindow()
         mw = MockMainWindow()
-        hook = make_hook(0, 0)
-        lug = make_lug(5, 5)
-        ready = make_ready(100, 100)
-        mw.left_objects = [hook, lug, ready]
+        mw.active_bucket = calibrated_bucket()
+        hook = make_hook(40, 40)
+        lug = make_lug(40, 40)  # 中心对齐,匹配标定偏移 0,0
+        mw.left_objects = [hook, lug]
 
 
         for _ in range(3):
         for _ in range(3):
             mw.evaluate_hook_state()
             mw.evaluate_hook_state()
@@ -283,15 +363,15 @@ class TestStateMachine:
 
 
     def test_ready_requires_intersecting_pair(self):
     def test_ready_requires_intersecting_pair(self):
         mw = MockMainWindow()
         mw = MockMainWindow()
-        # Non-intersecting hook/lug + ready should be IDLE, not READY
+        mw.active_bucket = calibrated_bucket()
+        # 即使标定匹配,hook/lug 不相交也不得 READY
         hook = make_hook(0, 0)
         hook = make_hook(0, 0)
         lug = make_lug(200, 200)  # far apart
         lug = make_lug(200, 200)  # far apart
-        ready = make_ready(100, 100)
-        mw.left_objects = [hook, lug, ready]
+        mw.left_objects = [hook, lug]
 
 
         for _ in range(3):
         for _ in range(3):
             mw.evaluate_hook_state()
             mw.evaluate_hook_state()
-        # No intersecting pair -> IDLE, even with ready present
+        # No intersecting pair -> IDLE
         assert mw.hook_state == HookState.IDLE
         assert mw.hook_state == HookState.IDLE
 
 
     def test_paired_flag_set(self):
     def test_paired_flag_set(self):

+ 372 - 0
tests/test_suppression.py

@@ -0,0 +1,372 @@
+import time
+import math
+
+import pytest
+
+from hookvision.models import HookState, Config, BucketModel
+from hookvision.main_window_v2 import MainWindow
+
+
+def _entry(t, hx, hy, lx, ly):
+    dist = math.hypot(hx - lx, hy - ly)
+    return {"t": t, "hook_cx": hx, "hook_cy": hy, "lug_cx": lx, "lug_cy": ly, "dist": dist}
+
+
+class TestSuppressWindow:
+    def _feed(self, mw, side, points, step=0.1):
+        t0 = time.time()
+        mw._pair_traj[side] = [_entry(t0 + i * step, *p) for i, p in enumerate(points)]
+        mw._update_suppress_window()
+
+    def test_releasing_suppresses(self, qapp):
+        mw = MainWindow(Config())
+        pts = [(50, 100 + i * 2, 60, 100 + i * 2) for i in range(6)]
+        self._feed(mw, "left", pts)
+        assert mw._trace_phase == "放包"
+        assert mw._is_voice_suppressed() is True
+
+    def test_separation_diagonal_suppresses(self, qapp):
+        mw = MainWindow(Config())
+        # 挂钩向左上远离吊耳(任意方向相离),距离由 0 增大到 ~29
+        pts = [(60 - i * 5, 100 - i * 3, 60, 100) for i in range(6)]
+        self._feed(mw, "left", pts)
+        assert mw._trace_phase == "分离"
+        assert mw._is_voice_suppressed() is True
+
+    def test_separation_rightward_suppresses(self, qapp):
+        mw = MainWindow(Config())
+        # 挂钩向右远离吊耳,距离由 0 增大到 25
+        pts = [(60 + i * 5, 100, 60, 100) for i in range(6)]
+        self._feed(mw, "right", pts)
+        assert mw._trace_phase == "分离"
+        assert mw._is_voice_suppressed() is True
+
+    def test_genuine_hooking_not_suppressed(self, qapp):
+        mw = MainWindow(Config())
+        # 真挂包:挂钩下移去咬合,吊耳静止,距离缩小
+        pts = [(50, 80 + i * 2, 60, 100) for i in range(6)]
+        self._feed(mw, "left", pts)
+        assert mw._trace_phase == ""
+        assert mw._is_voice_suppressed() is False
+
+    def test_lift_together_not_suppressed(self, qapp):
+        mw = MainWindow(Config())
+        # 挂包成功后吊起:挂钩+吊耳同步上移,距离保持贴合
+        pts = [(50, 120 - i * 2, 60, 120 - i * 2) for i in range(6)]
+        self._feed(mw, "left", pts)
+        assert mw._trace_phase == ""
+        assert mw._is_voice_suppressed() is False
+
+    def test_jitter_not_suppressed(self, qapp):
+        mw = MainWindow(Config())
+        pts = []
+        for i in range(6):
+            y = 100 + (i % 3)
+            pts.append((50, y, 60, y + 1))
+        self._feed(mw, "left", pts)
+        assert mw._trace_phase == ""
+        assert mw._is_voice_suppressed() is False
+
+    def test_no_pair_not_suppressed(self, qapp):
+        mw = MainWindow(Config())
+        mw._pair_traj = {"left": [], "right": []}
+        mw._update_suppress_window()
+        assert mw._trace_phase == ""
+        assert mw._is_voice_suppressed() is False
+
+    def test_far_hook_moving_not_suppressed(self, qapp):
+        mw = MainWindow(Config())
+        # 挂钩远离吊耳(从未贴合),即使距离增大也不应命中分离
+        pts = [(200 + i * 5, 100, 60, 100) for i in range(6)]
+        self._feed(mw, "left", pts)
+        assert mw._trace_phase == ""
+        assert mw._is_voice_suppressed() is False
+
+
+class TestVoiceGate:
+    def test_voice_suppressed_when_in_window(self, qapp):
+        mw = MainWindow(Config())
+        pts = [(50, 100 + i * 2, 60, 100 + i * 2) for i in range(6)]
+        t0 = time.time()
+        mw._pair_traj["left"] = [_entry(t0 + i * 0.1, *p) for i, p in enumerate(pts)]
+        mw._update_suppress_window()
+        mw.last_voice_state = None
+        mw.update_ui_for_state(HookState.READY)
+        assert mw.last_voice_state is None
+
+    def test_voice_plays_when_not_suppressed(self, qapp):
+        mw = MainWindow(Config())
+        mw._pair_traj = {"left": [], "right": []}
+        mw._voice_suppress_until = 0.0
+        mw.last_voice_state = None
+        mw.update_ui_for_state(HookState.READY)
+        assert mw.last_voice_state == HookState.READY
+
+
+class TestStaleOverlayClear:
+    def test_cleared_after_timeout(self, qapp):
+        mw = MainWindow(Config())
+        mw._last_mqtt_process = time.time() - 10
+        mw._overlay_cleared = False
+        mw._last_left_draw = [{"x": 0}]
+        mw._last_right_draw = [{"x": 0}]
+        mw._maybe_clear_stale_overlay()
+        assert mw._last_left_draw == []
+        assert mw._last_right_draw == []
+        assert mw._overlay_cleared is True
+        assert mw._trace_phase == ""
+        assert mw._voice_suppress_until == 0.0
+
+    def test_not_cleared_within_timeout(self, qapp):
+        mw = MainWindow(Config())
+        mw._last_mqtt_process = time.time()
+        mw._overlay_cleared = False
+        mw._last_left_draw = [{"x": 0}]
+        mw._maybe_clear_stale_overlay()
+        assert mw._last_left_draw == [{"x": 0}]
+        assert mw._overlay_cleared is False
+
+    def test_not_cleared_before_first_message(self, qapp):
+        mw = MainWindow(Config())
+        mw._last_mqtt_process = 0.0
+        mw._overlay_cleared = False
+        mw._maybe_clear_stale_overlay()
+        assert mw._last_mqtt_process == 0.0
+
+    def test_cleared_once_until_new_message(self, qapp):
+        mw = MainWindow(Config())
+        mw._last_mqtt_process = time.time() - 10
+        mw._overlay_cleared = False
+        mw._maybe_clear_stale_overlay()
+        assert mw._overlay_cleared is True
+        mw._maybe_clear_stale_overlay()
+        assert mw._last_left_draw == []
+
+    def test_disabled_ai_no_clear(self, qapp):
+        mw = MainWindow(Config())
+        mw.ai_enabled = False
+        mw._last_mqtt_process = time.time() - 10
+        mw._overlay_cleared = False
+        mw._last_left_draw = [{"x": 0}]
+        mw._maybe_clear_stale_overlay()
+        assert mw._last_left_draw == [{"x": 0}]
+
+
+def _obj(label, x, y, w=20, h=20):
+    return {"x": x, "y": y, "w": w, "h": h, "label": label}
+
+
+def _calibrated_bucket(x_off=0.0, y_off=0.0):
+    b = BucketModel(id=1, description="test", code_x="X")
+    b.hook_lug_x_offset_ratio_left = x_off
+    b.hook_lug_y_offset_ratio_left = y_off
+    b.hook_lug_x_offset_ratio_right = x_off
+    b.hook_lug_y_offset_ratio_right = y_off
+    return b
+
+
+def _matched_pair():
+    """标定偏移为 0:hook/lug 中心对齐且框相交 → 可以起吊"""
+    return _obj("hook", 40, 40, 20, 20), _obj("lug", 40, 40, 20, 20)
+
+
+def _misaligned_pair():
+    """中心偏开超出容差(xr/yr = -0.5)但框仍相交 → 挂包中"""
+    return _obj("hook", 30, 30, 20, 20), _obj("lug", 40, 40, 20, 20)
+
+
+def _fresh(mw):
+    mw.hook_state = HookState.IDLE
+    mw.prev_state = HookState.IDLE
+    mw.state_counter = 0
+    mw._pair_traj = {"left": [], "right": []}
+    mw._voice_suppress_until = 0.0
+    mw.prev_left_hook = None
+    mw.prev_left_lug = None
+    mw.prev_right_hook = None
+    mw.prev_right_lug = None
+
+
+class TestSideMatch:
+    def test_uncalibrated_side_false(self, qapp):
+        mw = MainWindow(Config())
+        h, l = _matched_pair()
+        assert mw._side_match("left", (h, l)) is False
+
+    def test_calibrated_match_true(self, qapp):
+        mw = MainWindow(Config())
+        mw.active_bucket = _calibrated_bucket()
+        h, l = _matched_pair()
+        assert mw._side_match("left", (h, l)) is True
+
+    def test_out_of_tolerance_false(self, qapp):
+        mw = MainWindow(Config())
+        mw.active_bucket = _calibrated_bucket()
+        h, l = _misaligned_pair()
+        assert mw._side_match("left", (h, l)) is False
+
+    def test_non_intersecting_false(self, qapp):
+        mw = MainWindow(Config())
+        mw.active_bucket = _calibrated_bucket()
+        h = _obj("hook", 0, 0, 20, 20)
+        l = _obj("lug", 100, 100, 20, 20)
+        assert mw._side_match("left", (h, l)) is False
+
+    def test_none_pair_false(self, qapp):
+        mw = MainWindow(Config())
+        mw.active_bucket = _calibrated_bucket()
+        assert mw._side_match("left", None) is False
+
+    def test_y_uses_bottom_edge_match_true(self, qapp):
+        # 底边对齐(yr=0)但中心偏开(中心 yr 会是 -0.5):只有底边语义下才判定可以起吊
+        mw = MainWindow(Config())
+        mw.active_bucket = _calibrated_bucket(x_off=-0.5, y_off=0.0)
+        h = _obj("hook", 30, 30, 20, 40)
+        l = _obj("lug", 40, 50, 20, 20)
+        assert mw._side_match("left", (h, l)) is True
+
+    def test_lug_touches_top_side_match_false(self, qapp):
+        # 吊耳贴着画面上沿(y=0):不再判定可以起吊
+        mw = MainWindow(Config())
+        mw.active_bucket = _calibrated_bucket()
+        h = _obj("hook", 40, 0, 20, 20)
+        l = _obj("lug", 40, 0, 20, 20)
+        assert mw._side_match("left", (h, l)) is False
+
+
+class TestCanLiftStateMachine:
+    def test_cold_start_stays_idle(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        for _ in range(5):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+    def test_uncalibrated_never_ready(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = BucketModel(id=1, description="test", code_x="X")
+        mw.left_objects = list(_matched_pair())
+        for _ in range(6):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+    def test_calibrated_match_ready(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = _calibrated_bucket()
+        mw.left_objects = list(_matched_pair())
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+
+    def test_misaligned_hooking_not_ready(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = _calibrated_bucket()
+        mw.left_objects = list(_misaligned_pair())
+        for _ in range(6):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+    def test_no_pair_idle(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = _calibrated_bucket()
+        for _ in range(5):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+    def test_lug_at_top_clears_display_and_skips_judgment(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = _calibrated_bucket()
+        mw.left_objects = list(_matched_pair())
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+        # 吊耳贴着画面上沿 → 不再判定,状态框清空(文字空、边框恢复默认灰),状态重置
+        mw.left_objects = [_obj("hook", 40, 0, 20, 20), _obj("lug", 40, 0, 20, 20)]
+        mw.evaluate_hook_state()
+        assert mw.hook_status_label.text() == ""
+        assert "#dcdfe6" in mw.left_label.styleSheet()
+        assert mw.hook_state == HookState.IDLE
+        # 吊耳回到画面内 → 恢复判定
+        mw.left_objects = list(_matched_pair())
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_status_label.text() == "挂钩状态: 挂包成功"
+
+    def test_non_intersecting_not_ready(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = _calibrated_bucket()
+        mw.left_objects = [_obj("hook", 0, 0, 20, 20), _obj("lug", 100, 100, 20, 20)]
+        for _ in range(5):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE
+
+    def test_releasing_still_detected(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        mw.active_bucket = _calibrated_bucket()
+        mw.left_objects = list(_matched_pair())
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+        # 放包:hook/lug 同步下移超过阈值
+        for step in range(1, 4):
+            mw.left_objects = [
+                _obj("hook", 40, 40 + step * 6, 20, 20),
+                _obj("lug", 40, 40 + step * 6, 20, 20),
+            ]
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.RELEASING
+
+
+class TestReadyStickyAntiFlicker:
+    """READY 进出用 ready_debounce 防抖:短时失配不闪红绿"""
+
+    def _engage(self, mw):
+        mw.active_bucket = _calibrated_bucket()
+        mw.left_objects = list(_matched_pair())
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+
+    def test_ready_holds_through_short_flicker(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        self._engage(mw)
+        # 短暂 2 帧失配(框仍相交)→ 仍保持 READY
+        for _ in range(2):
+            mw.left_objects = list(_misaligned_pair())
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+
+    def test_ready_holds_while_matched(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        self._engage(mw)
+        for _ in range(6):
+            mw.left_objects = list(_matched_pair())
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.READY
+
+    def test_ready_releases_to_hooking_after_persistent_misalign(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        self._engage(mw)
+        for _ in range(3):
+            mw.left_objects = list(_misaligned_pair())
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.HOOKING
+
+    def test_ready_releases_to_idle_when_detached(self, qapp):
+        mw = MainWindow(Config())
+        _fresh(mw)
+        self._engage(mw)
+        mw.left_objects = []
+        for _ in range(3):
+            mw.evaluate_hook_state()
+        assert mw.hook_state == HookState.IDLE

+ 148 - 0
读卡器SW5400_CN.pdf

@@ -0,0 +1,148 @@
+UHF 超高频工业读卡器                             SW5400
+
+                           UHF 超高频工业读卡器
+
+名称: UHF 超高频工业读卡器
+型号: SW5400
+尺寸: 90 x 94 x 40mm
+
+                                                                                                                                                     1/4
+UHF 超高频工业读卡器                                                     SW5400
+
+简介:
+
+    SW5400 是一款高性能的 UHF 频段 ISO18000-6C(EPC C1G2)协议电子标签读写器,自主知
+识产权设计,结合专有的高效电子标签碰撞处理算法,在保持高识读率的同时,实现对电子标签的快
+速读写处理,主要用于工业 RFID 系统。
+
+应用场景:
+
+♦工业产品流程控制
+♦烟草管理
+♦考勤管理
+♦AGV 智能车
+♦环卫地磅管理
+
+产品特点:
+
+1. 自主知识产权设计;
+2. 充分支持符合 UHF EPC Gen2(ISO18000-6C)协议电子标签;
+3. 工作频率 FCC 902~928MHz、CE 865~868MHz(可以用软件按不同国家或地区要求调整);
+4. 以广谱跳频(FHSS)或定频发射方式工作;
+5. 输出功率: 最大 26dBm(可调);
+6. 内置 2dBi 圆极化陶瓷天线,读卡距离范围 0~3.5M*;
+7. 支持主动模式,命令模式,触发模式,密码工作模式;
+8. DC12V 供电,最大支持 24V 供电;
+9. 通信接口支持 RS232,RS485,触发,RJ45;
+10.一路继电器(定制)可以控制喇叭,电机,灯等大功率设备;
+11. 支持 ModBus 协议(仅限于 RS232、RS485、RJ45 接口);
+12.支持白名单读卡和时间段读卡*.
+
+* 有效距离与协议格式、外接天线、电子标签及工作环境有关。
+
+产品型号解释:
+
+              Model                 Interface
+
+              SW5400   标准版(RS232, RS485, 触发)
+
+              SW5400T  标准版 及 RJ45
+
+              SW5400R  标准版 及 Relay
+
+              SW5400W  标准版 及 WIFI
+
+              SW5400U  标准版 及 USB
+
+              -T: RJ45 -T: RJ45 -W:WIFI –R: Relay –U:USB –G: 4G
+
+电特性:
+极限参数
+
+              项目       符号           数值         单位
+
+              电源电压     VCC          24         V
+              工作温度     TOPR
+              储存温度     TSTR         -20~+60    ℃
+
+                                    -40~+85    ℃
+
+                                                                 2/4
+UHF 超高频工业读卡器                                       SW5400
+
+规格
+除特别说明,所示规格取自 TA=25℃及 VCC=+12V 工作条件下
+
+              项目    符号    最小 典型      最大   单位
+
+              电源电压  VCC   8    12    24   V
+              工作电流    IC
+              工作频率             400   600  mA
+                    FREQ
+                          865        928  MHz
+
+产品参数          SW5400
+              UHF 超高频工业读卡器
+       类型
+       型号     90 x 94 x 40mm
+       名称     1.0Kg
+              1.5Kg
+       物理参数   23*16*13cm
+       外形尺寸   48*32.5*28.5cm,8 台/箱
+       重量     ABS 面罩,金属后盖
+       包装重量   黑色
+       包装尺寸
+       外箱尺寸   -20~+60℃
+       外壳材料   -40~+85℃
+       设备颜色   5%~95% RH, Non-condensing
+       环境参数   IP65
+       工作温度
+       储存温度   902~928MHz,860~868MHz(软件可调)
+       工作湿度   2dBi 圆极化陶瓷天线
+       防水等级   0 - 26dBm
+       性能参数   0-350cm *有效距离与协议格式、外接天线、电子标签及工作环境有关
+       工作频率   0-150cm *有效距离与协议格式、外接天线、电子标签及工作环境有关
+       天线规格   200 次/1 秒
+       输出功率   ISO18000-6C(EPC Gen2)
+       读卡距离*  蜂鸣器
+       写卡距离*  DC +9 - 24V, 5W
+       读卡速度   主动模式, 命令模式, 触发模式, 密码模式
+       读写卡类型  RS232, RS485, WG26, WG34, 触发,
+       读卡提示   继电器(定制), RJ45 网口(定制),
+       工作电压   WIFI(定制),USB(定制) ,
+       工作模式
+
+       通信接口
+
+                                                   3/4
+UHF 超高频工业读卡器                               SW5400
+
+其他读卡功能                 支持白名单读卡和时间段读卡*
+网络
+SDK 开发包                RJ45 支持 TCP 客户端/服务器端, HTTP,MQTT,Modbus, DNS, DHCP.
+SDK 开发包                WIFI 支持 TCP 客户端,HTTP,MQTT.
+                       LLRP(定制)
+
+                       支持 X86 & X64, C++, C#, VB, Delphi, Linux, ARM, Python, NodeJS,
+                       QT, JAVA, Android, Modbus 等语言.
+
+接口                                  描述
+
+                  颜色   电源正极 (DC 9V – 24V)
+                    红           GND 地线
+                    黑          RS232 TXD
+                    白          RS232 RXD
+                    紫             GND 地
+                    棕
+                    黄          韦根 DATA0
+                    绿          韦根 DATA1
+                    灰
+                                GND 地线
+                  品红            RS485 A+
+                    橙            RS485 B-
+                       触发输入+(TTL level)
+                  淡绿   触发输入-(TTL level)
+                    蓝
+
+                                                                                       4/4
+