Procházet zdrojové kódy

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

lsfoo před 5 dny
rodič
revize
e8c7a5a255

+ 4 - 0
.gitignore

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

+ 99 - 15
README.md

@@ -14,32 +14,116 @@
 
 ## 安装与运行
 
+### 方式一:源码运行(开发环境)
+
 1. 安装 Python 3.8+
 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/...`)
 - 确保摄像头或 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ární
assets/手动标定原理示意图.png


+ 1 - 1
build_exe.bat

@@ -8,7 +8,7 @@ if %errorlevel% neq 0 exit /b %errorlevel%
 
 REM Step 2: Build the executable
 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%
 
 echo === Build complete! Run the following to install shortcuts: ===

+ 9 - 3
config.dev.ini

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

+ 41 - 17
config.ini

@@ -4,11 +4,10 @@ copyright = 恒莱达
 screen = 0
 
 [Video]
-rtsp_timeout =  10
+rtsp_timeout = 10
 
 [Hardware]
 module_type = RFID
-# module_type = HD3200
 
 [RFID]
 ip = 192.168.100.113
@@ -16,6 +15,28 @@ port = 60000
 stability_time = 5
 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]
 ip = 192.168.1.200
 port = 502
@@ -26,34 +47,36 @@ stability_count = 3
 enabled = true
 broker = 192.168.1.248
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 module_count = 2
+auto_calib_ready_frames = 3
+auto_calib_duration_secs = 1.0
+auto_calib_min_samples = 2
 
 [AI:0]
 broker = 192.168.100.144
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 [AI:1]
 broker = 192.168.100.101
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 [AI:2]
 broker = 192.168.100.102
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 [AI:3]
 broker = 192.168.100.100
 port = 18830
-topics =
-    ds/channel/#
-
+topics = 
+	ds/channel/#
 
 [NVR]
 count = 1
@@ -83,7 +106,7 @@ right_topic = ds/channel/1
 id = 2
 description = 2号工位
 code_x = 00000002
-code_y =
+code_y = 
 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
@@ -94,7 +117,7 @@ right_topic = ds/channel/2
 id = 3
 description = 3号工位
 code_x = 00000003
-code_y =
+code_y = 
 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
@@ -105,7 +128,7 @@ right_topic = ds/channel/1
 id = 4
 description = 4号工位
 code_x = 00000004
-code_y =
+code_y = 
 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
@@ -116,9 +139,10 @@ right_topic = ds/channel/1
 id = 5
 description = 5号工位
 code_x = 00000005
-code_y =
+code_y = 
 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_topic = ds/channel/0
 right_topic = ds/channel/1
+

+ 36 - 25
config.prod.ini

@@ -4,11 +4,11 @@ copyright = 恒莱达
 screen = 0
 
 [Video]
-rtsp_timeout =  10
+rtsp_timeout = 10
+ratio_16_9 = false
 
 [Hardware]
 module_type = RFID
-# module_type = HD3200
 
 [RFID]
 ip = 192.168.100.113
@@ -25,31 +25,35 @@ stability_count = 3
 [AI]
 enabled = true
 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]
 broker = 192.168.100.144
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 [AI:1]
 broker = 192.168.100.101
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 [AI:2]
 broker = 192.168.100.102
 port = 18830
-topics =
-    ds/channel/#
+topics = 
+	ds/channel/#
 
 [AI:3]
 broker = 192.168.100.100
 port = 18830
-topics =
-    ds/channel/#
-
+topics = 
+	ds/channel/#
 
 [Bucket:0]
 id = 1
@@ -57,8 +61,8 @@ description = 1号工位
 code_x = 00000001
 code_y = 
 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
 right_topic = ds/channel/1
 
@@ -66,32 +70,38 @@ right_topic = ds/channel/1
 id = 2
 description = 2号工位
 code_x = 00000002
-code_y =
+code_y = 
 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
 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]
 id = 3
 description = 3号工位
 code_x = 00000003
-code_y =
+code_y = 
 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
 right_topic = ds/channel/1
+hook_lug_x_offset_ratio_right = -0.049103
+hook_lug_y_offset_ratio_right = 0.435473
 
 [Bucket:3]
 id = 4
 description = 4号工位
 code_x = 00000004
-code_y =
+code_y = 
 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
 right_topic = ds/channel/1
 
@@ -99,9 +109,10 @@ right_topic = ds/channel/1
 id = 5
 description = 5号工位
 code_x = 00000005
-code_y =
+code_y = 
 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
 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"):
         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
     if config.module_type == ModuleType.RFID:
@@ -39,6 +40,12 @@ def load_config(config_path: str) -> Config:
     # AI
     config.ai.enabled = cfg.getboolean("AI", "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 段)
     for section in sorted(
@@ -65,7 +72,11 @@ def load_config(config_path: str) -> Config:
             description=cfg.get(section, "description"),
             code_x=cfg.get(section, "code_x"),
             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")
         right_rtsp = cfg.get(section, "right_rtsp")
@@ -80,4 +91,42 @@ def load_config(config_path: str) -> Config:
         bucket.cameras = cameras
         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
     # 放包判断位移阈值(像素)
     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"
 
     # 跨线程信号
@@ -56,6 +73,7 @@ class MainWindow(QMainWindow):
         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
 
@@ -73,9 +91,25 @@ class MainWindow(QMainWindow):
         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 = ""
+        # 上升 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_hooking_voice_time = 0.0
+        self._ready_traces: dict = {}  # track_id → [(cx, cy, time), ...]
         self.voice_enabled = True
         self._voice_volume = 100
         self.ai_enabled = True
@@ -353,6 +387,11 @@ class MainWindow(QMainWindow):
         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)
         main_layout.addWidget(status_widget)
 
         self.update_video_size()
@@ -445,11 +484,19 @@ class MainWindow(QMainWindow):
             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._ascend_frames = 0
+            self._ascend_latch_until = 0.0
+            self._ready_hold_until = 0.0
             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:
             if self.left_player and self._last_left_draw:
                 self.left_player.set_ai_objects(self._last_left_draw)
@@ -595,7 +642,7 @@ class MainWindow(QMainWindow):
             return
         available_width = container.width() - 15
         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():
             target_height = container.height()
         self.left_label.setFixedSize(single_width, target_height)
@@ -629,6 +676,13 @@ class MainWindow(QMainWindow):
         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("加载中...")
@@ -775,13 +829,15 @@ class MainWindow(QMainWindow):
     # ---------- 硬件初始化 ----------
     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
+                hex_output=True,
+                valid_codes=valid_codes
             )
             if self.rfid_client.connect():
                 self.module_status.setText("通讯状态: RFID正常")
@@ -865,7 +921,16 @@ class MainWindow(QMainWindow):
         self.prev_right_lug = None
         self.last_voice_state = None
         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._last_left_draw = []
+        self._last_right_draw = []
 
         # 先切 AI 模块 + 订阅 MQTT topic(非阻塞),再启 RTSP
         if bucket and self.config.ai.enabled and self.mqtt_client:
@@ -922,6 +987,8 @@ class MainWindow(QMainWindow):
             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:
@@ -1014,6 +1081,7 @@ class MainWindow(QMainWindow):
                     else:
                         color = (128, 128, 128)
                     objects.append({
+                        "track_id": parts[0],
                         "x": x,
                         "y": y,
                         "w": w,
@@ -1040,6 +1108,7 @@ class MainWindow(QMainWindow):
         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()
@@ -1055,6 +1124,19 @@ class MainWindow(QMainWindow):
 
                 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:
                     self.left_objects = all_objects
                 else:
@@ -1062,10 +1144,19 @@ class MainWindow(QMainWindow):
 
                 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:
                     self._last_left_draw = draw_objects
@@ -1104,6 +1195,10 @@ class MainWindow(QMainWindow):
         left_pair = find_nearest_pair(left_hooks, left_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
 
         if left_pair:
@@ -1137,17 +1232,40 @@ class MainWindow(QMainWindow):
         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:
+            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
+                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:
                 new_state = HookState.HOOKING
             else:
                 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:
             self.state_counter += 1
         else:
@@ -1185,6 +1303,164 @@ class MainWindow(QMainWindow):
                 return True
         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 更新与语音 ----------
     def update_ui_for_state(self, state):
         if state == HookState.IDLE:
@@ -1217,6 +1493,8 @@ class MainWindow(QMainWindow):
 
         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("挂包中")
@@ -1230,6 +1508,7 @@ class MainWindow(QMainWindow):
     def check_idle(self):
         if self.rfid_client:
             self.rfid_client.check_idle()
+        self._maybe_clear_stale_overlay()
         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:
@@ -1353,9 +1632,12 @@ class MainWindow(QMainWindow):
             cfg.add_section("AI")
         val = "true" if self.config.ai.tts_enabled else "false"
         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:
             cfg.write(f)
-        logger.info(f"TTS 设置已保存: {self._config_path} → tts_enabled={val}")
+        logger.info(f"配置已保存: {self._config_path}")
 
     @staticmethod
     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
     ready_x: bool = False
     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
 class AIModuleModel:
@@ -53,6 +58,14 @@ class AIModuleModel:
 class AIConfig:
     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
 class ModbusConfig:
@@ -68,9 +81,30 @@ class SocketConfig:
     stability_time: int = 20
     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
 class VideoConfig:
     rtsp_timeout: float = 5.0   # 默认 5 秒
+    ratio_16_9: bool = True     # 画面比例 16:9 / 4:3
 
 @dataclass
 class Config:
@@ -84,3 +118,4 @@ class Config:
     ai_modules: List[AIModuleModel] = field(default_factory=list)
     buckets: List[BucketModel] = field(default_factory=list)
     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 subprocess
 import threading
 import time
 import logging
@@ -6,28 +8,77 @@ from typing import Callable, Optional
 
 logger = logging.getLogger(__name__)
 
-_MIN_BACKOFF = 5
+_MIN_BACKOFF = 1
 _MAX_BACKOFF = 60
 
 class RFIDClient:
     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.on_tag_callback = on_tag_callback
         self.tag_offset = tag_offset
         self.tag_length = tag_length
         self.hex_output = hex_output
+        self.valid_codes = valid_codes  # 允许的卡号集合,None 表示不过滤
         self.sock: Optional[socket.socket] = None
         self.running = False
+        self._online: Optional[bool] = None
         self._last_tag: Optional[str] = None
         self._stable_count = 0
         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.thread = None
         self.buffer = b""
         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:
         """尝试建立连接,若已连接则直接返回 True,否则尝试重连(有间隔限制)"""
@@ -52,20 +103,19 @@ class RFIDClient:
             self._last_receive_time = time.time()
             self._backoff = _MIN_BACKOFF
             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
         except Exception as e:
             self._backoff = min(self._backoff * 2, _MAX_BACKOFF)
             logger.error(f"RFID连接失败,下次{self._backoff}s后重试: {e}")
             self.sock = None
-            if self.on_state_changed:
-               self.on_state_changed(False)
+            self._notify_state(False)
             return False
 
     def disconnect(self):
         """主动断开连接"""
         self.running = False
+        self._ping_running = False
         sock = self.sock
         self.sock = None
         if sock:
@@ -74,10 +124,13 @@ class RFIDClient:
             except OSError:
                 pass
         logger.info("RFID已断开")
-        if self.on_state_changed:
-            self.on_state_changed(False)
+        self._notify_state(False)
         self.on_tag_callback(None)   # 通知上层
 
+    def is_connected(self) -> bool:
+        """检查连接是否正常"""
+        return self.sock is not None and self.running
+
     def _read_loop(self):
         while self.running:
             sock = self.sock
@@ -108,11 +161,22 @@ class RFIDClient:
             except Exception as e:
                 logger.error(f"RFID读取错误: {e}")
                 break
-        # 退出循环(连接断开)
+        # 退出循环(读卡器主动断开或网络异常),静默清理并快速重连。
+        # 该读卡器会周期性主动断开连接,属正常行为,在线状态由周期 ping 判定。
         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):
         hex_str = packet.hex()
@@ -139,6 +203,11 @@ class RFIDClient:
             logger.debug("无效卡号(空或全0),忽略")
             return
 
+        # 过滤不在配置中的卡号(读卡器同时读到多张卡时,只保留配置的工位卡)
+        if self.valid_codes and tag not in self.valid_codes:
+            logger.debug(f"卡号 {tag} 不在配置列表中,过滤")
+            return
+
         logger.debug(f"提取卡号: {tag}")
         self._process_tag(tag)
 
@@ -162,8 +231,8 @@ class RFIDClient:
         1. 检查空闲超时(卡离开)
         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("空闲超时,清空状态")
             self._last_tag = None
             self._stable_count = 0
@@ -173,8 +242,19 @@ class RFIDClient:
         if self.sock is None:
             # 如果 socket 已断开,尝试重连
             if time.time() - self._last_connect_attempt > self._backoff:
+                logger.debug("RFID socket为None,尝试重连")
                 self.connect()
         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 sys
+import os
+import socket
+import struct
 import numpy as np
 import time
 import cv2
+import shutil
+import subprocess
 import threading
 import logging
+import platform
+from urllib.parse import urlparse, urlunparse, quote
 from PySide6.QtCore import QObject, Signal, QMutex
 from PySide6.QtGui import QImage, QPixmap
 from typing import List, Dict, Any, Optional
 
 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):
     frame_ready = Signal(QPixmap)
     first_frame_received = Signal()
@@ -27,6 +179,8 @@ class RTSPPlayer(QObject):
         self.ai_objects: List[Dict[str, Any]] = []
         self.target_fps = 25
         self.mutex = QMutex()
+        self._last_frame_time: float = 0.0
+        self._timeout_emitted = False
 
     def set_target_size(self, width: int, height: int):
         self.mutex.lock()
@@ -39,6 +193,24 @@ class RTSPPlayer(QObject):
         self.ai_objects = list(objects)
         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):
         self.running = True
         options = {
@@ -55,51 +227,96 @@ class RTSPPlayer(QObject):
         start_time = time.time()
         first_packet = True
         container = None
+        ffsub = None
+        use_ffmpeg_subprocess = False
 
+        # 先尝试 av 库
         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
-                    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()
+                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:
             logger.error(f"RTSP错误: {e}")
-            if first_packet:
+            if not self._timeout_emitted:
+                self._timeout_emitted = True
                 self.timeout.emit()
         finally:
             if container:
                 container.close()
+            if ffsub:
+                ffsub.close()
             self.running = False
 
     def start(self):
@@ -139,18 +356,54 @@ class RTSPPlayer(QObject):
             x_off = y_off = 0
 
         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)
             y = int(obj.get('y', 0) * sy + y_off)
             w = int(obj.get('w', 0) * sx)
             h = int(obj.get('h', 0) * sy)
             label = obj.get('label', '')
             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)
-            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
         bytes_per_line = ch * w

+ 14 - 16
main.py

@@ -5,19 +5,18 @@ import json
 from PySide6.QtWidgets import QApplication
 from qt_material import apply_stylesheet
 from hookvision.config_loader import load_config
-from hookvision.main_window import MainWindow
+from hookvision.main_window_v2 import MainWindow
 import logging
 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',
                     handlers=[logging.FileHandler(log_path, encoding='utf-8'), logging.StreamHandler()])
 
 def _autostart_diag():
-    import winreg
     log = []
     log.append(f"sys.executable = {sys.executable}")
     log.append(f"sys.platform = {sys.platform}")
     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")
     log.append(f"config_path = {config_path}")
     try:
@@ -30,17 +29,9 @@ def _autostart_diag():
         os.remove(config_path)
     except Exception as 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)
     print(msg)
     with open("autostart_diag.txt", "w", encoding="utf-8") as f:
@@ -61,8 +52,15 @@ def main():
     if "--dev" in sys.argv:
         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:
         config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), config_name)
     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
 pymodbus>=3.5.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
         with pytest.raises(NoSectionError):
             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
 from PySide6.QtWidgets import QMainWindow
 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
 
 

+ 7 - 11
tests/test_models.py

@@ -1,6 +1,6 @@
 from hookvision.models import (
     ModuleType, CameraPosition, CameraState, SwitchMode, HookState,
-    CameraModel, BucketModel, NVRModel, AIConfig, ModbusConfig,
+    CameraModel, BucketModel, AIConfig, ModbusConfig,
     SocketConfig, VideoConfig, Config
 )
 
@@ -45,18 +45,15 @@ class TestModels:
         assert b.ready_x is False
         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):
         ai = AIConfig()
         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):
         m = ModbusConfig(ip="192.168.1.1")
@@ -79,6 +76,5 @@ class TestModels:
         assert c.app_name == "钢包工位视频监测系统"
         assert c.module_type == ModuleType.RFID
         assert c.buckets == []
-        assert c.nvrs == []
         assert c.rfid 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).
 
-The state machine transitions:
+The state machine transitions (main_window_v2):
   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)
 
 For RELEASING: both hook and lug centers must move down by > RELEASE_DELTA_THRESHOLD pixels.
 """
 
 import pytest
-from hookvision.models import HookState
+from hookvision.models import HookState, Config, BucketModel
 
 
 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)}
 
 
+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:
-    """Minimal mock of MainWindow for testing evaluate_hook_state."""
+    """Minimal mock of MainWindow_v2 for testing evaluate_hook_state."""
 
     STATE_THRESHOLD = 3
     RELEASE_DELTA_THRESHOLD = 5
@@ -41,10 +50,13 @@ class MockMainWindow:
         self.prev_right_hook = None
         self.prev_left_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
 
     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_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()]
@@ -70,6 +82,19 @@ class MockMainWindow:
         left_pair = find_nearest_pair(left_hooks, left_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
 
         # 处理左侧配对
@@ -104,17 +129,25 @@ class MockMainWindow:
         if is_releasing:
             new_state = HookState.RELEASING
         else:
-            any_ready = any('ready' in o['label'].lower() for o in self.left_objects + self.right_objects)
-            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
-            elif has_intersecting_pair:
+            elif self._has_intersecting_pair(left_pair, right_pair):
                 new_state = HookState.HOOKING
             else:
                 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:
             self.state_counter += 1
         else:
@@ -136,6 +169,19 @@ class MockMainWindow:
             right_pair[0]['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):
         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']
@@ -152,6 +198,27 @@ class MockMainWindow:
                 return True
         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:
     def test_initial_state_idle(self):
@@ -200,12 +267,25 @@ class TestStateMachine:
         mw.evaluate_hook_state()
         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):
         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):
             mw.evaluate_hook_state()
@@ -283,15 +363,15 @@ class TestStateMachine:
 
     def test_ready_requires_intersecting_pair(self):
         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)
         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):
             mw.evaluate_hook_state()
-        # No intersecting pair -> IDLE, even with ready present
+        # No intersecting pair -> IDLE
         assert mw.hook_state == HookState.IDLE
 
     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
+