Procházet zdrojové kódy

refactor: 重构检测后端,解耦推理与业务逻辑

主要变更:
1. 新增 inference_backend.py 统一封装 RKNN/ONNX 检测接口
2. 替换 safety_detector 导入为 inference_backend
3. 删除 LLM 相关配置与代码,简化系统配置
4. 更新测试用例适配新的导入路径
5. 新增本地测试、RKNN诊断等工具脚本
6. 补充设备文档与摄像头配置示例
7. 调整检测阈值与模型路径适配RK3588环境
wenhongquan před 18 hodinami
rodič
revize
45a43a3950
55 změnil soubory, kde provedl 1821 přidání a 3267 odebrání
  1. 39 0
      .cursor/rules/codegraph.mdc
  2. 81 51
      AGENTS.md
  3. 62 0
      devices.md
  4. 128 84
      dual_camera_system/README.md
  5. binární
      dual_camera_system/__pycache__/camera_group.cpython-313.pyc
  6. binární
      dual_camera_system/__pycache__/coordinator.cpython-310.pyc
  7. binární
      dual_camera_system/__pycache__/coordinator.cpython-313.pyc
  8. binární
      dual_camera_system/__pycache__/event_pusher.cpython-310.pyc
  9. binární
      dual_camera_system/__pycache__/inference_backend.cpython-310.pyc
  10. binární
      dual_camera_system/__pycache__/main.cpython-310.pyc
  11. binární
      dual_camera_system/__pycache__/paired_image_saver.cpython-310.pyc
  12. binární
      dual_camera_system/__pycache__/panorama_camera.cpython-310.pyc
  13. binární
      dual_camera_system/__pycache__/polling_tracker.cpython-310.pyc
  14. binární
      dual_camera_system/__pycache__/ptz_camera.cpython-310.pyc
  15. binární
      dual_camera_system/__pycache__/tracker.cpython-310.pyc
  16. 8 6
      dual_camera_system/camera_group.py
  17. 79 0
      dual_camera_system/capture_and_detect.py
  18. 2 7
      dual_camera_system/config.py
  19. 2 7
      dual_camera_system/config/__init__.py
  20. binární
      dual_camera_system/config/__pycache__/__init__.cpython-310.pyc
  21. binární
      dual_camera_system/config/__pycache__/camera.cpython-310.pyc
  22. binární
      dual_camera_system/config/__pycache__/detection.cpython-310.pyc
  23. binární
      dual_camera_system/config/__pycache__/system.cpython-310.pyc
  24. binární
      dual_camera_system/config/__pycache__/tracking.cpython-310.pyc
  25. 62 37
      dual_camera_system/config/camera.py
  26. 38 52
      dual_camera_system/config/detection.py
  27. 0 33
      dual_camera_system/config/llm.py
  28. 2 9
      dual_camera_system/config/system.py
  29. 97 10
      dual_camera_system/coordinator.py
  30. 62 0
      dual_camera_system/devices.md
  31. 129 0
      dual_camera_system/diag_rknn.py
  32. 55 342
      dual_camera_system/event_pusher.py
  33. 212 0
      dual_camera_system/inference_backend.py
  34. 0 350
      dual_camera_system/llm_service.py
  35. 33 896
      dual_camera_system/main.py
  36. 385 97
      dual_camera_system/panorama_camera.py
  37. 14 2
      dual_camera_system/ptz_camera.py
  38. 0 463
      dual_camera_system/safety_coordinator.py
  39. 0 816
      dual_camera_system/safety_detector.py
  40. binární
      dual_camera_system/scripts/__pycache__/local_test.cpython-310.pyc
  41. 147 0
      dual_camera_system/scripts/local_test.py
  42. binární
      dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310-pytest-9.0.2.pyc
  43. binární
      dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310.pyc
  44. binární
      dual_camera_system/tests/__pycache__/test_integration_polling.cpython-310-pytest-9.0.2.pyc
  45. binární
      dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310-pytest-9.0.2.pyc
  46. binární
      dual_camera_system/tests/__pycache__/test_tracker.cpython-310-pytest-9.0.2.pyc
  47. 2 2
      dual_camera_system/tests/test_tracker.py
  48. 3 3
      dual_camera_system/tracker.py
  49. binární
      testrk3588/1781324210075.png
  50. 144 0
      testrk3588/test_yolo26n.py
  51. binární
      testrk3588/yolo26n_end2end.rknn
  52. 7 0
      tmp_fix_threshold.py
  53. 7 0
      tmp_set_thr.py
  54. 7 0
      tmp_set_thr35.py
  55. 14 0
      tmp_switch_model.py

+ 39 - 0
.cursor/rules/codegraph.mdc

@@ -0,0 +1,39 @@
+---
+description: CodeGraph MCP usage guide — when to use which tool
+alwaysApply: true
+---
+<!-- CODEGRAPH_START -->
+## CodeGraph
+
+This project has a CodeGraph MCP server (`codegraph_*` tools) configured. CodeGraph is a tree-sitter-parsed knowledge graph of every symbol, edge, and file. Reads are sub-millisecond and return structural information grep cannot.
+
+### When to prefer codegraph over native search
+
+Use codegraph for **structural** questions — what calls what, what would break, where is X defined, what is X's signature. Use native grep/read only for **literal text** queries (string contents, comments, log messages) or after you already have a specific file open.
+
+| Question | Tool |
+|---|---|
+| "Where is X defined?" / "Find symbol named X" | `codegraph_search` |
+| "What calls function Y?" | `codegraph_callers` |
+| "What does Y call?" | `codegraph_callees` |
+| "How does X reach/become Y? / trace the flow from X to Y" | `codegraph_trace` (one call = the whole path, incl. callback/React/JSX dynamic hops) |
+| "What would break if I changed Z?" | `codegraph_impact` |
+| "Show me Y's signature / source / docstring" | `codegraph_node` |
+| "Give me focused context for a task/area" | `codegraph_context` |
+| "See several related symbols' source at once" | `codegraph_explore` |
+| "What files exist under path/" | `codegraph_files` |
+| "Is the index healthy?" | `codegraph_status` |
+
+### Rules of thumb
+
+- **Answer directly — don't delegate exploration.** For "how does X work" / architecture questions, answer with 2-3 codegraph calls: `codegraph_context` first, then ONE `codegraph_explore` for the source of the symbols it surfaces. For a specific **flow** ("how does X reach Y") start with `codegraph_trace` from→to — one call returns the whole path with dynamic hops bridged — then ONE `codegraph_explore` for the bodies; don't rebuild the path with `codegraph_search` + `codegraph_callers`. Codegraph IS the pre-built index, so spawning a separate file-reading sub-task/agent — or running a grep + read loop — repeats work codegraph already did and costs more for the same answer.
+- **Trust codegraph results.** They come from a full AST parse. Do NOT re-verify them with grep — that's slower, less accurate, and wastes context.
+- **Don't grep first** when looking up a symbol by name. `codegraph_search` is faster and returns kind + location + signature in one call.
+- **Don't chain `codegraph_search` + `codegraph_node`** when you just want context — `codegraph_context` is one call.
+- **Don't loop `codegraph_node` over many symbols** — one `codegraph_explore` call returns several symbols' source grouped in a single capped call, while each separate node/Read call re-reads the whole context and costs far more.
+- **Index lag**: the file watcher debounces ~500ms behind writes; don't re-query immediately after editing a file in the same turn.
+
+### If `.codegraph/` doesn't exist
+
+The MCP server returns "not initialized." Ask the user: *"I notice this project doesn't have CodeGraph initialized. Want me to run `codegraph init -i` to build the index?"*
+<!-- CODEGRAPH_END -->

+ 81 - 51
AGENTS.md

@@ -9,12 +9,13 @@
 **施工现场安全行为智能识别系统 v2.0.0** - 基于 Python 的双摄像头联动系统。
 
 核心功能:
-- 全景摄像头实时监控,YOLO11 检测人体/安全帽/反光衣
+- 多组全景摄像头实时监控,YOLO11 检测人体
 - 球机 PTZ 联动跟踪,变焦定位目标
-- OCR 编号识别(llama-server API)
-- 安全违规检测(未戴安全帽、未穿反光衣)
+- 配对图片保存与上传
 - 事件推送至业务平台 + 语音播报
 
+> 注:OCR 编号识别、LLM 判断、安全帽/反光衣检测已在本版本中移除。
+
 ---
 
 ## 目录结构
@@ -24,24 +25,24 @@ dual_camera_system/
 ├── config/                      # 模块化配置(已重构)
 │   ├── __init__.py              # 配置汇总导出
 │   ├── camera.py                # 摄像头 + 日志配置
-│   ├── detection.py             # 人体/安全检测配置
+│   ├── detection.py             # 人体检测配置
 │   ├── ptz.py                   # PTZ 控制参数
-│   ├── ocr.py                   # OCR 配置
 │   ├── coordinator.py            # 联动 + 校准配置
+│   ├── tracking.py              # 跟踪 + 轮询抓拍配置(库代码)
 │   ├── event.py                 # 事件推送配置
 │   ├── voice.py                 # 语音播报配置
-│   ├── llm.py                   # 大模型配置
 │   └── system.py                # 系统开关 + 工作模式
-├── main.py                      # OCR 模式入口(编号识别)
-├── safety_main.py               # 安全模式入口(安全检测)
+├── main.py                      # 主入口(仅多组模式)
+├── multi_group_system.py        # 多组系统管理器
+├── camera_group.py              # 单组摄像头封装
 ├── dahua_sdk.py                 # 大华 SDK ctypes 封装
 ├── panorama_camera.py           # 全景摄像头 + 人体检测
 ├── ptz_camera.py                # 球机 PTZ 控制
 ├── calibration.py               # 视觉校准(运动检测 + 特征匹配)
-├── ocr_recognizer.py            # OCR 编号识别
-├── safety_detector.py           # 安全检测(安全帽/反光衣
-├── safety_coordinator.py        # 安全联动控制器
-├── llm_service.py               # 大模型服务封装
+├── coordinator.py               # 联动控制器(SequentialCoordinator)
+├── tracker.py                   # Ultralytics 跟踪器封装(库代码
+├── polling_tracker.py           # 多目标轮询跟踪协调器(库代码)
+├── inference_backend.py         # RKNN/ONNX 通用推理后端
 ├── event_pusher.py              # 事件推送至业务平台
 ├── voice_announcer.py           # TTS 语音播报
 └── README.md                    # 项目说明
@@ -56,24 +57,19 @@ dh/                              # 大华 SDK(仅参考)
 
 ## 运行命令
 
-### OCR 模式(编号识别)
-```bash
-python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
-python main.py --interactive      # 交互模式
-python main.py --demo            # 演示模式
-python main.py --skip-calibration # 跳过校准
-```
-
-### 安全模式(安全检测)
+### 多组模式(当前唯一入口)
 ```bash
-python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
+python main.py
+python main.py --skip-calibration                     # 跳过校准
+python main.py --demo                                 # 演示模式
+python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101  # 覆盖第一组 IP
 ```
 
 ### 通用参数
 ```bash
 --model-size {n,s,m,l,x}    # YOLO11 模型尺寸
 --no-gpu                     # 禁用 GPU
---ocr-host localhost --ocr-port 8111  # OCR API 地址
+--model /path/to/model.pt    # 显式指定检测模型
 ```
 
 ---
@@ -83,9 +79,6 @@ python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
 | 组件 | 技术 |
 |------|------|
 | 人体检测 | YOLO11 (ultralytics) |
-| 安全检测 | YOLO11 安全专用模型 |
-| OCR 识别 | llama-server API (Qwen2.5-VL-7B-Instruct) |
-| 安全判断 | 规则 + LLM 混合模式 |
 | 摄像头 SDK | 大华 NetSDK (ctypes) |
 | 图像处理 | OpenCV |
 | 特征匹配 | SIFT / ORB |
@@ -98,28 +91,22 @@ python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
 ## 配置说明
 
 ### config/system.py - 功能开关
-- `mode`: `'safety'` 或 `'ocr'`
 - `enable_panorama_camera`, `enable_ptz_camera`
-- `enable_detection`, `enable_safety_detection`
+- `enable_detection`: 启用人人体检测
 - `enable_calibration`, `enable_ptz_tracking`
-- `enable_ocr`, `enable_llm`
 - `enable_event_push`, `enable_voice_announce`
-- `safety_strategy`: `'llm'` / `'rule'` / `'hybrid'`
 
 ### config/camera.py - 摄像头配置
-- `PANORAMA_CAMERA`: 全景摄像头 IP/端口/凭证
+- `PANORAMA_CAMERA`: 全景摄像头 IP/端口/凭证/分辨率/品牌
+  - `brand`: 摄像头品牌,`'dahua'` 使用 SDK 登录,`'hikvision'` 仅使用 RTSP 取流
+  - `use_sdk`: `False` 时跳过 SDK 登录,直接 RTSP 取流
+  - `resolution`: 期望分辨率,支持 `(width, height)` 或字符串 `"3840x1080"`、`"2560*1440"`、`"1920x1080"`
 - `PTZ_CAMERA`: 球机 IP/端口/凭证
+- `CAMERA_GROUPS`: 多组摄像头配置,每组 `panorama` 可独立配置 `resolution`/`brand`
 - `SDK_PATH`: `/home/wen/dsh/dh/Bin`(Linux 路径)
 
 ### config/detection.py - 检测配置
-- `DETECTION_CONFIG`: 人体检测(目标类别、置信度、检测间隔)
-- `SAFETY_DETECTION_CONFIG`: 安全检测模型路径 `/home/wen/dsh/yolo/yolo11m_safety.pt`
-  - 类别映射: `0=安全帽`, `3=人`, `4=反光衣`
-  - `alert_cooldown`: 3.0 秒,同一目标告警冷却
-
-### config/llm.py - 大模型配置
-- `LLM_CONFIG`: Qwen2.5-VL-7B-Instruct,localhost:8111
-- `LLM_SAFETY_CONFIG`: `use_llm_for_safety`, `verify_with_llm`
+- `DETECTION_CONFIG`: 人体检测(目标类别、置信度、检测间隔、模型路径)
 
 ### config/event.py - 事件推送
 - `EVENT_PUSHER_CONFIG`: 推送到 `jtjai.device.wenhq.top:8583`
@@ -130,6 +117,17 @@ python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
 - `TTS_CONFIG`: Edge-TTS, zh-CN-XiaoxiaoNeural
 - `VOICE_ANNOUNCER_CONFIG`: 违规播报重复 3 次
 
+### config/tracking.py - 跟踪 + 轮询抓拍配置
+- `TRACKING_CONFIG.model_path`: 默认跟踪模型路径,按平台自动选择
+  - Linux aarch64 → `/home/admin/dsh/yolo/yolo11.rknn`(RK3588)
+  - Linux x86_64 → `/home/wen/dsh/yolo/yolo11n.pt`
+  - Darwin/macOS → `/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/sb/model/yolo11n.pt`
+- `TRACKING_CONFIG.model_type`: `'auto'` | `'yolo'` | `'rknn'` | `'onnx'`
+- `TRACKING_CONFIG.tracker_type`: `'bytetrack'` | `'botsort'`
+- `TRACKING_CONFIG.max_tracking_targets`: 最大同时跟踪目标数
+- `TRACKING_CONFIG.tracking_timeout`: 目标丢失后保留 ID 的超时时间
+- 轮询抓拍参数:`ptz_stabilize_time`、`ptz_command_cooldown`、`capture_dir`、`enable_upload`
+
 ### config/coordinator.py - 校准配置
 - `CALIBRATION_CONFIG.interval`: 24 小时(不是 5 分钟)
 
@@ -152,22 +150,17 @@ python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
 3. **SDK 类型映射**: 大华 SDK 在 Linux 上 `DWORD=unsigned int(4B)`, `LONG=int(4B)`, `LLONG=long(8B)`,ctypes 绑定必须严格匹配,否则结构体对齐错误导致登录失败
 4. **初始化顺序**: `main.py` 中先加载 YOLO/PyTorch,再初始化大华 SDK。大华 SDK 的 `CLIENT_Init` 会修改进程内存映射,如果先于 PyTorch 加载会导致 segfault
 5. **校准间隔**: 实际是 24 小时,不是 README 中的 5 分钟
-6. **模型路径**: 安全检测模型在 `/home/wen/dsh/yolo/yolo11m_safety.pt`
+6. **模型路径**: 人体检测模型在 `config/detection.py` 中配置,默认 `/home/wen/dsh/yolo/yolo11n.pt`;macOS 本地测试建议将 `config/tracking.py` 中模型路径指向可用 `.pt` 文件
 7. **YOLO11 自动下载**: 首次运行自动下载预训练权重
-8. **OCR 服务**: 需先启动 llama-server(默认 localhost:8111)
-9. **工作模式**: `main.py` 是 OCR 模式,`safety_main.py` 是安全检测模式
-10. **摄像头端口**: SDK 登录用 37777,RTSP 流用 554,config 中 `port=37777`, `rtsp_port=554`
+8. **工作模式**: `main.py` 当前仅支持 **多组摄像头联动模式**(`multi_group_system.py`),所有摄像头组在 `config/camera.py` 的 `CAMERA_GROUPS` 中配置
+9. **摄像头端口**: SDK 登录用 37777,RTSP 流用 554,config 中 `port=37777`, `rtsp_port=554`
+10. **全景分辨率**: 在 `config/camera.py` 中配置 `resolution`,支持 `3840x1080`、`2560x1440`、`1920x1080` 等;系统仅在 RTSP 不可用时按配置分辨率生成模拟帧,实际流帧不再拉伸缩放;模型推理时通过 letterbox(灰度填充)保持宽高比,避免丢精度
+11. **混合品牌**: 枪机支持 Hikvision RTSP-only 模式(配置 `brand='hikvision'` 或 `use_sdk=False`),球机仍使用 Dahua SDK 控制 PTZ
+12. **本地测试**: 在无法加载 Dahua SDK 的环境(如 macOS),可运行 `python scripts/local_test.py` 验证 RTSP、检测与 PTZ 角度计算
+13. **库代码**: `tracker.py`、`polling_tracker.py` 为保留的 Ultralytics 跟踪库代码,当前 `main.py` 未使用,但测试用例仍覆盖
 
 ---
 
-## 交互命令(OCR 模式 main.py)
-
-- `s` - 开始/停止联动
-- `r` - 获取识别结果
-- `t` - 手动跟踪(输入坐标)
-- `c` - 抓拍快照
-- `q` - 退出
-
 ---
 
 ## 校准机制
@@ -192,6 +185,43 @@ python safety_main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
 
 ---
 
+## 多组摄像头联动模式
+
+**核心组件**:
+- `multi_group_system.py`:`MultiGroupSystem` 管理多组摄像头的并行运行
+- `camera_group.py`:`CameraGroup` 封装单组(全景 + 球机)的初始化、校准、联动与抓拍
+- `coordinator.py`:`SequentialCoordinator` 负责单组内的人体检测 → PTZ 定位 → 配对图片保存
+
+**工作流程**:
+```
+config/camera.py 中 CAMERA_GROUPS
+              │
+              ▼
+      MultiGroupSystem
+              │
+              ▼
+    为每组创建 CameraGroup
+              │
+              ▼
+    共享 SDK + 共享 ObjectDetector
+              │
+              ▼
+      每组独立 SequentialCoordinator
+              │
+              ▼
+    人体检测 ──> PTZ 定位 ──> 抓拍 ──> 配对图片保存/上传
+```
+
+**关键行为**:
+1. `MultiGroupSystem` 初始化一次大华 SDK 和一个人体检测器(YOLO11)
+2. 为 `CAMERA_GROUPS` 中每个启用的组创建 `CameraGroup`
+3. 每组独立完成:视频流连接 → 自动校准 → 启动 `SequentialCoordinator`
+4. `SequentialCoordinator` 检测到人体后控制球机 PTZ 定位并保存配对图片
+5. 配对图片保存器 `PairedImageSaver` 负责本地存储与可选 OSS/第三方平台上传
+6. 系统运行期间按配置执行每日定时校准
+
+---
+
 ## SDK 参考
 
 头文件:`dh/Include/Common/dhnetsdk.h`

+ 62 - 0
devices.md

@@ -0,0 +1,62 @@
+# DSH项目 — 设备与开发手册
+## 设备信息
+### 服务器/计算设备
+| 环境 | 设备 | IP | 用户/密码 | 说明 |
+|------|------|----|-----------|------|
+| 测试环境 | RK3588 (Radxa Rock 5B) | 192.168.8.3 | admin / admin | NPU 6 TOPS, Conda 环境: `rknn` |
+| 正式环境 | RK3588 (Radxa Rock 5B) | 10.126.126.1 | admin / admin | NPU 6 TOPS, Conda 环境: `rknn` |
+
+### 摄像机设备
+#### 测试环境
+| 设备类型 | 品牌 | IP | 用户/密码 | 分辨率 | 特殊参数 | 说明 |
+|----------|------|----|-----------|--------|----------|------|
+| 枪机 | 海康威视 | 192.168.8.2 | admin / QAZwsx12 | 2560*1440 | - | 宽视野人员检测 |
+| 球机 | 大华 | 192.168.8.5 | admin / Aa1234567 | 3840*2160 | pan_flip:True <br> ceiling_mount:True | PTZ 云台控制抓拍 |
+
+#### 正式环境
+| 设备类型 | 品牌 | IP | 用户/密码 | 分辨率 | 特殊参数 | 说明 |
+|----------|------|----|-----------|--------|----------|------|
+| 枪机 | 大华 | 192.168.20.196 | admin / Aa1234567 | 3840*1080 | - | 宽视野人员检测 |
+| 球机 | 大华 | 192.168.20.197 | admin / Aa1234567 | 3840*2160 | pan_flip:False <br> ceiling_mount:False | PTZ 云台控制抓拍 |
+
+## 接入地址与操作指令
+### RTSP 流地址
+#### 测试环境
+```
+# 海康威视枪机 主码流(人员检测)
+rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101
+
+# 大华球机 子码流(PTZ 抓拍预览)
+rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1
+```
+
+#### 正式环境
+```
+# 大华枪机 主码流(人员检测)
+rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0
+
+# 大华球机 子码流(PTZ 抓拍预览)
+rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=1
+```
+
+### SSH 登录指令
+#### 测试环境 RK3588
+```bash
+ssh admin@192.168.8.3
+/home/admin/miniconda3/condabin/conda activate rknn
+```
+
+#### 正式环境 RK3588
+```bash
+ssh admin@10.126.126.1
+/home/admin/miniconda3/condabin/conda activate rknn
+```
+
+---
+
+### 补充说明
+1. 正式环境大华枪机RTSP地址按大华通用格式补充,若实际地址有差异请按现场配置调整;
+2. 特殊参数说明:
+   - `pan_flip`:云台水平翻转开关,用于适配不同安装朝向的画面矫正;
+   - `ceiling_mount`:吸顶安装标识,True表示球机为倒挂安装模式,False为正装模式;
+3. 所有设备默认端口:SSH(22)、RTSP(554),若网络环境变更需同步更新端口配置。

+ 128 - 84
dual_camera_system/README.md

@@ -2,32 +2,62 @@
 
 ## 系统简介
 
-本系统实现全景摄像头和可变焦球机的联动抓拍功能:
-- 全景摄像头实时监控,检测画面中的人体
-- 检测到人体后,球机自动变焦定位到目标
-- 对人体进行分割,OCR识别衣服上的编号
+本系统实现多组全景摄像头和可变焦球机的联动抓拍功能:
+- 多组全景摄像头实时监控,检测画面中的人体
+- 检测到人体后,对应球机自动变焦定位到目标
+- 保存全景+球机配对图片,支持上传至业务平台
+
+> 注意:本版本已移除 OCR 编号识别、LLM 判断、安全帽/反光衣检测,仅保留人体检测与联动抓拍。
 
 ## 系统架构
 
 ```
-全景摄像头                    球机                    识别模块
-    │                          │                        │
-视频流 ──> 人体检测 ──> 位置计算 ──> PTZ控制 ──> 变焦放大 ──> OCR识别
-                                                         │
-                                                     编号结果
+多组摄像头配置 (config/camera.py)
+              │
+              ▼
+      MultiGroupSystem
+              │
+              ▼
+    ┌─────────┴─────────┐
+    │                   │
+CameraGroup 1      CameraGroup 2  ...
+    │                   │
+    ▼                   ▼
+全景+球机          全景+球机
+    │                   │
+    ▼                   ▼
+SequentialCoordinator  ...
+    │
+    ▼
+人体检测 -> PTZ定位 -> 抓拍 -> 配对保存/上传
 ```
 
 ## 目录结构
 
 ```
 dual_camera_system/
-├── config.py          # 配置文件
+├── config/            # 模块化配置
+│   ├── __init__.py    # 配置导出
+│   ├── camera.py      # 摄像头 + 日志配置
+│   ├── detection.py   # 人体检测配置
+│   ├── ptz.py         # PTZ 控制参数
+│   ├── coordinator.py # 联动 + 校准配置
+│   ├── tracking.py    # 跟踪配置(库代码)
+│   ├── event.py       # 事件推送配置
+│   ├── system.py      # 系统开关
+│   └── ...            # 其他配置
+├── main.py            # 主程序(仅多组模式)
+├── multi_group_system.py  # 多组系统管理器
+├── camera_group.py    # 单组摄像头封装
 ├── dahua_sdk.py       # 大华SDK Python封装
 ├── panorama_camera.py # 全景摄像头模块(视频流、人体检测)
 ├── ptz_camera.py      # 球机控制模块(PTZ控制、精确定位)
-├── ocr_recognizer.py  # OCR识别模块(人体分割、编号识别)
-├── coordinator.py     # 联动控制器
-├── main.py            # 主程序
+├── coordinator.py     # 联动控制器(SequentialCoordinator)
+├── tracker.py         # Ultralytics 跟踪器封装(库代码)
+├── polling_tracker.py # 多目标轮询跟踪协调器(库代码)
+├── inference_backend.py  # RKNN/ONNX 通用推理后端
+├── event_pusher.py    # 事件推送至业务平台
+├── voice_announcer.py # TTS 语音播报
 └── README.md          # 说明文档
 ```
 
@@ -39,70 +69,88 @@ pip install opencv-python opencv-contrib-python
 
 # YOLO11 (用于人体检测) - Ultralytics最新版本
 pip install ultralytics
-
-# llama-server (用于OCR识别)
-# 需要单独部署llama-server服务
-# 启动命令: llama-server -m PaddleOCR-VL-1.5-GGUF.gguf --port 8111
 ```
 
-**注意**: 
-- YOLO11是Ultralytics的最新模型,首次运行时会自动下载预训练权重(yolo11n.pt等)
-- OCR使用llama-server API,需要先部署视觉语言模型服务
+**注意**: YOLO11是Ultralytics的最新模型,首次运行时会自动下载预训练权重(yolo11n.pt等)。
 
 ## 使用方法
 
 ### 1. 修改配置
 
-编辑 `config.py` 设置摄像头参数:
+编辑 `config/camera.py` 配置摄像头组。系统默认使用示例环境:
 
-```python
-# 全景摄像头配置
-PANORAMA_CAMERA = {
-    'ip': '192.168.1.100',
-    'port': 37777,
-    'username': 'admin',
-    'password': 'admin123',
-    'channel': 0,
-}
+- 枪机:Hikvision `192.168.8.2`,RTSP-only(不经过 Dahua SDK 登录)
+- 球机:Dahua `192.168.8.5`,吸顶安装,pan 方向翻转
 
-# 球机配置
-PTZ_CAMERA = {
-    'ip': '192.168.1.101',
-    'port': 37777,
-    'username': 'admin',
-    'password': 'admin123',
-    'channel': 0,
-}
+```python
+CAMERA_GROUPS = [
+    {
+        'group_id': 'group_1',
+        'name': '主入口',
+        'enabled': True,
+        'panorama': {
+            'ip': '192.168.8.2',
+            'port': 37777,  # Hikvision 不使用 SDK 登录,此字段可忽略
+            'username': 'admin',
+            'password': 'QAZwsx12',
+            'channel': 1,
+            'brand': 'hikvision',  # 或 use_sdk: False
+            # 全景分辨率,支持 (width, height) 或 "3840x1080" / "2560*1440" / "1920x1080"
+            'resolution': (2560, 1440),
+            'rtsp_url': 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101',
+        },
+        'ptz': {
+            'ip': '192.168.8.5',
+            'port': 37777,
+            'username': 'admin',
+            'password': 'Aa1234567',
+            'channel': 0,
+            'mount_type': 'ceiling',  # 吸顶安装
+            'pan_flip': True,         # 球机与枪机朝向相反
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
+        },
+    }
+]
 ```
 
+编辑 `config/detection.py` 调整人体检测模型与阈值。
+
 ### 2. 运行系统
 
 ```bash
-# 启动llama-server (OCR服务)
-llama-server -m PaddleOCR-VL-1.5-GGUF.gguf --port 8111
+# 启动多组联动系统
+python main.py
 
-# 启动双摄像头系统
-python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101
+# 跳过自动校准
+python main.py --skip-calibration
 
-# 指定YOLO11模型大小和OCR配置
-python main.py --panorama-ip 192.168.1.100 --ptz-ip 192.168.1.101 \
-    --model-size s \
-    --ocr-host localhost --ocr-port 8111
+# 覆盖第一组摄像头 IP
+python main.py --panorama-ip 192.168.8.2 --ptz-ip 192.168.8.5
 
-# 交互模式
-python main.py --interactive
+# 指定模型
+python main.py --model /home/wen/dsh/yolo/yolo11n.pt --model-size n
 
 # 演示模式 (不连接实际摄像头)
 python main.py --demo
 ```
 
-### 3. 交互命令
+### 本地冒烟测试(无需 Dahua SDK)
+
+在 macOS 等无法加载 `libdhnetsdk.so` 的环境,或只想快速验证枪机 RTSP / 检测 / PTZ 角度计算时:
+
+```bash
+cd dual_camera_system
+python scripts/local_test.py --frames 10
+
+# 显示检测窗口(按 q 退出)
+python scripts/local_test.py --frames 10 --show
+```
 
-- `s` - 开始/停止联动
-- `r` - 获取识别结果
-- `t` - 手动跟踪 (输入坐标)
-- `c` - 抓拍快照
-- `q` - 退出
+该脚本会:
+1. 打开 Hikvision 枪机 RTSP 流
+2. 加载 YOLO 人体检测模型
+3. 对若干帧进行检测,并打印每个人的坐标
+4. 根据球机 `mount_type` / `pan_flip` 等配置计算应转动的 PTZ 角度(不实际转动)
 
 ## 关键功能说明
 
@@ -115,29 +163,9 @@ python main.py --demo
 3. **融合定位**: 综合两种方法结果加权融合
 4. **降级方案**: 视觉检测失败时使用角度估算
 
-**校准流程:**
-```
-移动前全景帧 ────┐
-                 │──> 运动检测 ──> 运动区域中心
-移动后全景帧 ────┘
-                 
-球机抓拍 ────────┐
-                 │──> 特征匹配 ──> 匹配点中心
-全景画面 ─────────┘
-
-运动区域 + 匹配点 ──> 加权融合 ──> 最终位置
-```
-
 **定时校准:**
-- 系统运行时每5分钟自动校准一次
+- 系统运行时每日指定时间自动校准一次(默认 08:00,可在 `config/coordinator.py` 中配置)
 - 确保坐标映射的准确性
-- 校准间隔可在 `config.py` 中配置
-
-**校准失败可能原因:**
-1. 全景摄像头或球机连接失败
-2. 球机PTZ控制异常
-3. 摄像头视野范围配置错误
-4. 场景缺少足够特征点
 
 **跳过校准:**
 ```bash
@@ -159,26 +187,42 @@ python main.py --force-calibration
 - `l` (large) - 高精度
 - `x` (extra-large) - 最高精度,速度最慢
 
+### 多组联动
+- `MultiGroupSystem` 共享一个大华 SDK 实例和一个 YOLO 检测器
+- 每组摄像头独立运行 `SequentialCoordinator`
+- 检测到人体后控制球机 PTZ 定位并保存配对图片
+
 ### PTZ控制
 支持大华球机的 PTZ 控制:
 - 三维精确定位 (DH_EXTPTZ_EXACTGOTO)
 - 预置点设置/调用
 - 平滑移动跟踪
 
-### 编号识别
-- 使用 llama-server API 进行OCR识别
-- 支持视觉语言模型 (如 PaddleOCR-VL)
-- 图像通过base64编码发送给API
-- 可配置API地址、端口和模型
+### 配对图片保存
+- 全景图与球机抓拍图按时间窗口归入同一目录
+- 支持 OSS 上传与第三方平台推送
+- 配置位于 `config/device.py`
 
-**启动llama-server示例:**
-```bash
-llama-server -m PaddleOCR-VL-1.5-GGUF.gguf --port 8111
+### 全景分辨率配置
+每组全景摄像头可独立配置期望分辨率:
+
+```python
+'panorama': {
+    ...
+    'resolution': (3840, 1080),  # 或 "3840x1080" / "3840*1080"
+}
 ```
 
+支持格式:
+- 元组/列表:`(3840, 1080)`、`[2560, 1440]`
+- 字符串:`"3840x1080"`、`"2560*1440"`、`"1920x1080"`
+
+系统不会拉伸 RTSP 流帧;模型推理时通过 letterbox(灰度填充)将画面按比例缩放并补到模型输入尺寸,避免丢精度。配置的分辨率用于生成模拟帧与分辨率校验。
+
 ## 注意事项
 
 1. 确保大华 SDK 库文件路径正确
 2. 球机需要支持 PTZ 控制功能
 3. 坐标映射需要根据实际场景校准
-4. OCR 识别效果取决于图像质量和编号清晰度
+4. 所有摄像头组在 `config/camera.py` 中配置,`main.py` 不再支持单组独立运行
+5. `tracker.py` 和 `polling_tracker.py` 为保留的 Ultralytics 跟踪库代码,当前 `main.py` 未使用

binární
dual_camera_system/__pycache__/camera_group.cpython-313.pyc


binární
dual_camera_system/__pycache__/coordinator.cpython-310.pyc


binární
dual_camera_system/__pycache__/coordinator.cpython-313.pyc


binární
dual_camera_system/__pycache__/event_pusher.cpython-310.pyc


binární
dual_camera_system/__pycache__/inference_backend.cpython-310.pyc


binární
dual_camera_system/__pycache__/main.cpython-310.pyc


binární
dual_camera_system/__pycache__/paired_image_saver.cpython-310.pyc


binární
dual_camera_system/__pycache__/panorama_camera.cpython-310.pyc


binární
dual_camera_system/__pycache__/polling_tracker.cpython-310.pyc


binární
dual_camera_system/__pycache__/ptz_camera.cpython-310.pyc


binární
dual_camera_system/__pycache__/tracker.cpython-310.pyc


+ 8 - 6
dual_camera_system/camera_group.py

@@ -228,10 +228,9 @@ class CameraGroup:
             ptz_camera=self.ptz_camera,
             get_frame_func=self.panorama_camera.get_frame,
             detect_marker_func=None,
-            ptz_capture_func=self._capture_ptz_frame,
-            calibration_file=self.calibration_file
+            ptz_capture_func=self._capture_ptz_frame
         )
-        
+
         # 配置校准参数
         from config import CALIBRATION_CONFIG
         overlap_cfg = CALIBRATION_CONFIG.get('overlap_discovery', {})
@@ -240,9 +239,12 @@ class CameraGroup:
         self.calibrator.overlap_pan_step = overlap_cfg.get('pan_step', 20)
         self.calibrator.overlap_tilt_step = overlap_cfg.get('tilt_step', 15)
         self.calibrator.stabilize_time = overlap_cfg.get('stabilize_time', 2.0)
-        
-        # 创建校准管理器
-        self.calibration_manager = CalibrationManager(self.calibrator)
+
+        # 创建校准管理器(传入校准文件路径)
+        self.calibration_manager = CalibrationManager(
+            self.calibrator,
+            calibration_file=self.calibration_file
+        )
         
         # 执行校准
         result = self.calibration_manager.auto_calibrate(

+ 79 - 0
dual_camera_system/capture_and_detect.py

@@ -0,0 +1,79 @@
+"""
+抓取一枪机实时帧,使用当前配置的人体检测模型检测并保存标记图。
+用于在 RK3588 上快速验证模型输出是否正常。
+"""
+
+import os
+import sys
+import cv2
+import time
+import numpy as np
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent))
+
+from panorama_camera import ObjectDetector
+from config import DETECTION_CONFIG
+
+RTSP_URL = 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101'
+OUT_DIR = Path('/home/admin/dsh/detection_check')
+OUT_DIR.mkdir(parents=True, exist_ok=True)
+
+
+def main():
+    print(f"加载模型: {DETECTION_CONFIG['model_path']}")
+    detector = ObjectDetector(
+        model_path=DETECTION_CONFIG['model_path'],
+        use_gpu=DETECTION_CONFIG.get('use_gpu', True),
+        model_type=DETECTION_CONFIG.get('model_type', 'auto')
+    )
+
+    print(f"打开 RTSP: {RTSP_URL}")
+    cap = cv2.VideoCapture(RTSP_URL, cv2.CAP_FFMPEG)
+    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+    if not cap.isOpened():
+        print("无法打开 RTSP 流")
+        return
+
+    # 读取几帧清缓存
+    for _ in range(10):
+        cap.read()
+
+    ret, frame = cap.read()
+    cap.release()
+    if not ret or frame is None:
+        print("未获取到帧")
+        return
+
+    h, w = frame.shape[:2]
+    print(f"帧尺寸: {w}x{h}")
+
+    # 检测
+    dets = detector.detect(frame)
+    persons = [d for d in dets if d.class_name == 'person']
+    print(f"检测到 {len(persons)} 个人 (原始目标 {len(dets)})")
+    for i, p in enumerate(persons):
+        print(f"  {i}: conf={p.confidence:.3f} bbox={p.bbox} center={p.center}")
+
+    # 保存原图
+    orig_path = OUT_DIR / 'capture_original.jpg'
+    cv2.imwrite(str(orig_path), frame)
+
+    # 绘制标记图
+    marked = frame.copy()
+    for p in persons:
+        x, y, bw, bh = p.bbox
+        cv2.rectangle(marked, (x, y), (x + bw, y + bh), (0, 255, 0), 2)
+        label = f"person {p.confidence:.2f}"
+        cv2.putText(marked, label, (x, max(y - 5, 20)),
+                    cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
+
+    marked_path = OUT_DIR / 'capture_marked.jpg'
+    cv2.imwrite(str(marked_path), marked)
+    print(f"已保存: {orig_path}, {marked_path}")
+
+    detector.release()
+
+
+if __name__ == '__main__':
+    main()

+ 2 - 7
dual_camera_system/config.py

@@ -11,14 +11,11 @@ from config.camera import (
     LOG_CONFIG,
     PANORAMA_CAMERA, PTZ_CAMERA, SDK_PATH,
 )
-from config.detection import (
-    DETECTION_CONFIG, SAFETY_DETECTION_CONFIG,
-)
+from config.detection import DETECTION_CONFIG
 from config.ptz import PTZ_CONFIG
 from config.coordinator import COORDINATOR_CONFIG, CALIBRATION_CONFIG
 from config.tracking import TRACKING_CONFIG
 from config.event import EVENT_PUSHER_CONFIG, EVENT_LISTENER_CONFIG
-from config.llm import LLM_CONFIG, LLM_SAFETY_CONFIG
 from config.system import SYSTEM_CONFIG
 
 
@@ -28,15 +25,13 @@ __all__ = [
     # 摄像头
     'PANORAMA_CAMERA', 'PTZ_CAMERA', 'SDK_PATH',
     # 检测
-    'DETECTION_CONFIG', 'SAFETY_DETECTION_CONFIG',
+    'DETECTION_CONFIG',
     # PTZ
     'PTZ_CONFIG',
     # 联动、校准与跟踪
     'COORDINATOR_CONFIG', 'CALIBRATION_CONFIG', 'TRACKING_CONFIG',
     # 事件
     'EVENT_PUSHER_CONFIG', 'EVENT_LISTENER_CONFIG',
-    # LLM
-    'LLM_CONFIG', 'LLM_SAFETY_CONFIG',
     # 系统
     'SYSTEM_CONFIG',
 ]

+ 2 - 7
dual_camera_system/config/__init__.py

@@ -7,14 +7,11 @@ from .camera import (
     PANORAMA_CAMERA, PTZ_CAMERA, SDK_PATH,
     LOG_CONFIG, CAMERA_GROUPS, get_enabled_groups
 )
-from .detection import (
-    DETECTION_CONFIG, SAFETY_DETECTION_CONFIG
-)
+from .detection import DETECTION_CONFIG
 from .tracking import TRACKING_CONFIG
 from .ptz import PTZ_CONFIG
 from .coordinator import COORDINATOR_CONFIG, CALIBRATION_CONFIG
 from .event import EVENT_PUSHER_CONFIG, EVENT_LISTENER_CONFIG
-from .llm import LLM_CONFIG, LLM_SAFETY_CONFIG
 from .system import SYSTEM_CONFIG
 from .oss import S3_COMPATIBLE_CONFIG
 from .device import (
@@ -29,7 +26,7 @@ __all__ = [
     # 摄像头
     'PANORAMA_CAMERA', 'PTZ_CAMERA', 'SDK_PATH', 'CAMERA_GROUPS', 'get_enabled_groups',
     # 检测
-    'DETECTION_CONFIG', 'SAFETY_DETECTION_CONFIG',
+    'DETECTION_CONFIG',
     # 跟踪
     'TRACKING_CONFIG',
     # PTZ
@@ -38,8 +35,6 @@ __all__ = [
     'COORDINATOR_CONFIG', 'CALIBRATION_CONFIG',
     # 事件
     'EVENT_PUSHER_CONFIG', 'EVENT_LISTENER_CONFIG',
-    # LLM
-    'LLM_CONFIG', 'LLM_SAFETY_CONFIG',
     # 系统
     'SYSTEM_CONFIG',
     # OSS

binární
dual_camera_system/config/__pycache__/__init__.cpython-310.pyc


binární
dual_camera_system/config/__pycache__/camera.cpython-310.pyc


binární
dual_camera_system/config/__pycache__/detection.cpython-310.pyc


binární
dual_camera_system/config/__pycache__/system.cpython-310.pyc


binární
dual_camera_system/config/__pycache__/tracking.cpython-310.pyc


+ 62 - 37
dual_camera_system/config/camera.py

@@ -17,23 +17,29 @@ LOG_CONFIG = {
 # 单组摄像头配置(保持向后兼容)
 # ============================================================
 PANORAMA_CAMERA = {
-    'ip': '192.168.20.196',
+    'ip': '192.168.8.2',
     'port': 37777,
     'rtsp_port': 554,
     'username': 'admin',
-    'password': 'Aa1234567',
+    'password': 'QAZwsx12',
     'channel': 1,
-    'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0',
+    # 品牌:dahua 使用 SDK 登录;hikvision 仅使用 RTSP 取流
+    'brand': 'hikvision',
+    'use_sdk': False,
+    # 全景摄像头期望分辨率,支持 (width, height) 或字符串如 "1920x1080"
+    # 常见值:3840x1080、2560x1440、1920x1080
+    'resolution': (2560, 1440),
+    'rtsp_url': 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101',
 }
 
 PTZ_CAMERA = {
-    'ip': '192.168.20.197',
+    'ip': '192.168.8.5',
     'port': 37777,
     'rtsp_port': 554,
     'username': 'admin',
     'password': 'Aa1234567',
     'channel': 0,  # PTZ 控制通道号 (SDK 从 0 开始)
-    'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=0',
+    'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
 }
 
 # ============================================================
@@ -43,55 +49,41 @@ PTZ_CAMERA = {
 CAMERA_GROUPS = [
     {
         'group_id': 'group_1',
-        'name': '第一组',
+        'name': '现场主组',
         'enabled': True,
         'panorama': {
-            'ip': '192.168.20.196',
+            'ip': '192.168.8.2',
             'port': 37777,
             'rtsp_port': 554,
             'username': 'admin',
-            'password': 'Aa1234567',
+            'password': 'QAZwsx12',
             'channel': 1,
+            # 品牌:dahua 使用 SDK 登录;hikvision 仅使用 RTSP 取流
+            'brand': 'hikvision',
+            'use_sdk': False,
+            # 全景摄像头期望分辨率,支持 (width, height) 或字符串如 "2560x1440"
+            # 常见值:3840x1080、2560x1440、1920x1080
+            'resolution': (2560, 1440),
+            'rtsp_url': 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101',
         },
         'ptz': {
-            'ip': '192.168.20.197',
+            'ip': '192.168.8.5',
             'port': 37777,
             'rtsp_port': 554,
             'username': 'admin',
             'password': 'Aa1234567',
             'channel': 0,
-            'pan_flip': False,     # pan方向不翻转
-            'ceiling_mount': False, # 吊装朝下未翻转
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
+            # 球机安装方向:ceiling=吸顶/吊装(镜头朝下), wall=壁装/立杆(镜头水平)
+            'mount_type': 'ceiling',
+            # pan_flip: 球机与全景朝向相反时设为 True
+            'pan_flip': True,
+            # tilt_flip: 俯仰方向相反时设为 True(ceiling 会自动翻转 tilt,通常无需再设)
+            'tilt_flip': False,
         },
         'calibration_file': '/home/admin/dsh/calibration_group1.json',
         'paired_image_dir': '/home/admin/dsh/paired_images_group1',
     },
-    # 第二组配置(示例,按需启用)
-    # {
-    #     'group_id': 'group_2',
-    #     'name': '第二组',
-    #     'enabled': False,  # 设为 True 启用
-    #     'panorama': {
-    #         'ip': '192.168.20.198',
-    #         'port': 37777,
-    #         'rtsp_port': 554,
-    #         'username': 'admin',
-    #         'password': 'Aa1234567',
-    #         'channel': 1,
-    #     },
-    #     'ptz': {
-    #         'ip': '192.168.20.199',
-    #         'port': 37777,
-    #         'rtsp_port': 554,
-    #         'username': 'admin',
-    #         'password': 'Aa1234567',
-    #         'channel': 0,
-    #         'pan_flip': True,
-    #         'ceiling_mount': True,
-    #     },
-    #     'calibration_file': '/home/admin/dsh/calibration_group2.json',
-    #     'paired_image_dir': '/home/admin/dsh/paired_images_group2',
-    # },
 ]
 
 # SDK 路径配置
@@ -111,3 +103,36 @@ SDK_PATH = {
 def get_enabled_groups() -> list:
     """获取所有启用的摄像头组配置"""
     return [g for g in CAMERA_GROUPS if g.get('enabled', False)]
+
+
+def parse_resolution(resolution) -> tuple:
+    """解析分辨率配置为 (width, height)
+
+    支持格式:
+    - (3840, 1080)
+    - [3840, 1080]
+    - "3840x1080"
+    - "3840*1080"
+    - "3840X1080"
+
+    Args:
+        resolution: 分辨率配置
+
+    Returns:
+        (width, height) 元组
+    """
+    if resolution is None:
+        return 1920, 1080
+
+    if isinstance(resolution, (tuple, list)) and len(resolution) == 2:
+        return int(resolution[0]), int(resolution[1])
+
+    if isinstance(resolution, str):
+        # 支持 x、X、* 作为分隔符
+        for sep in ['x', 'X', '*']:
+            if sep in resolution:
+                parts = resolution.split(sep)
+                if len(parts) == 2:
+                    return int(parts[0]), int(parts[1])
+
+    return 1920, 1080

+ 38 - 52
dual_camera_system/config/detection.py

@@ -1,69 +1,55 @@
 """
 检测配置
+
+注意:系统当前仅保留人体检测配置,安全帽/反光衣等安全检测配置已移除。
 """
 
-# 检测配置
+import platform
+
+
+def _default_model():
+    """根据平台选择默认检测模型"""
+    system = platform.system()
+    machine = platform.machine()
+
+    if system == 'Linux' and machine == 'aarch64':
+        # RK3588 / ARM64 NPU
+        # 使用 yolo26n end2end RKNN(单输出 1x300x6,x1/y1/x2/y2/conf/cls)
+        return '/home/admin/dsh/testrk3588/yolo26n_end2end.rknn', 'rknn'
+    elif system == 'Darwin':
+        # macOS 本地测试:使用 yolo11n.pt,Ultralytics 会自动下载
+        return '/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/sb/model/yolo11n.pt', 'yolo'
+    else:
+        # x86 Linux 等
+        return '/home/wen/dsh/yolo/yolo11n.pt', 'yolo'
+
+
+_MODEL_PATH, _MODEL_TYPE = _default_model()
+
+# 人体检测配置(用于 panorama_camera.ObjectDetector,多组模式)
 DETECTION_CONFIG = {
     'target_classes': ['person'],   # 检测目标类别 (支持中英文)
-    'confidence_threshold': 0.5,     # 置信度阈值
+    'confidence_threshold': 0.3,     # 置信度阈值(yolo26n end2end 内置 NMS)
     'detection_fps': 2,              # 检测帧率(每秒检测帧数),替代原来的detection_interval
     'detection_interval': 4,       # 兼容保留:检测间隔(秒),当detection_fps=2时间隔为0.5秒
-    
+
     # 检测图片保存配置
     'save_detection_image': False,   # 是否保存检测到人的图片
     'detection_image_dir': '/home/admin/dsh/detection_images',  # 图片保存目录
-    
+
     # 配对图片保存配置(全景+球机图片归入同一目录)
     'enable_paired_saving': True,   # 是否启用配对图片保存
     'paired_image_dir': '/home/admin/dsh/paired_images',  # 配对图片保存目录
     'paired_time_window': 5.0,      # 批次时间窗口(秒),同一窗口内的检测归为一批
-    
-    # RK3588 平台使用 RKNN 安全检测模型 (包含人体检测)
-    # 类别映射: 0=安全帽, 3=人, 4=反光衣
-    'model_path': '/home/admin/dsh/testrk3588/yolo11m_safety.rknn',
-    'model_type': 'rknn',           # 模型类型: 'rknn', 'yolo', 'onnx'
-    'use_gpu': False,               # RKNN 使用 NPU,不依赖 GPU
-    
-    # 安全检测模型的类别映射
-    'class_map': {
-        0: 'hat',
-        3: 'person',
-        4: 'reflective'
-    },
-    'person_class_id': 3,           # 人员在模型中的类别ID
-    'person_threshold': 0.4,        # 人员检测置信度阈值(降低以捕获更多目标)
-}
 
-# 安全检测模型配置
-SAFETY_DETECTION_CONFIG = {
-    # 模型路径 - 支持三种格式:
-    # - YOLO: .pt 文件, 使用 ultralytics
-    # - RKNN: .rknn 文件, 使用 rknnlite (RK3588 平台)
-    # - ONNX: .onnx 文件, 使用 onnxruntime
-    'model_path': '/home/admin/dsh/testrk3588/yolo11m_safety.rknn',
-    
-    'model_type': 'rknn',           # 模型类型: 'auto', 'yolo', 'rknn', 'onnx'
-    'use_gpu': False,                # RKNN 使用 NPU,不依赖 GPU
-    'conf_threshold': 0.5,           # 一般物品置信度阈值 (安全帽、反光衣)
-    'person_threshold': 0.8,         # 人员检测置信度阈值
-    
-    # 检测类别映射
-    'class_map': {
-        0: 'hat',
-        3: 'person',
-        4: 'reflective'
-    },
-    
-    # 检测帧率配置
-    'detection_fps': 2,              # 检测帧率(每秒检测帧数),默认每秒2帧
-    'detection_interval': 0.5,       # 兼容保留:检测间隔(秒),由detection_fps计算得出
-    
-    # 告警控制
-    'alert_cooldown': 3.0,           # 同一目标告警冷却时间(秒)
-    'max_alerts_per_minute': 10,     # 每分钟最大告警数
-    
-    # 检测图片保存配置
-    'save_detection_image': True,     # 是否保存检测到人的图片
-    'detection_image_dir': '/home/admin/dsh/detection_images',  # 图片保存目录
-    'detection_image_max_count': 100,  # 最大保存图片数量,超过后自动清理旧图片
+    # 默认人体检测模型
+    'model_path': _MODEL_PATH,
+    'model_type': _MODEL_TYPE,      # 模型类型: 'rknn', 'yolo', 'onnx'
+    'use_gpu': True,                # 是否使用 GPU / NPU
+
+    # 人体检测后处理阈值
+    'person_threshold': 0.5,    # 进入联动跟踪的人体置信度阈值
+
+    # RKNN/ONNX 模型类别映射(yolo26n COCO80:0=person)
+    'class_map': {0: 'person'},
 }

+ 0 - 33
dual_camera_system/config/llm.py

@@ -1,33 +0,0 @@
-"""
-大模型配置
-"""
-
-# 大模型 API 配置 (千问、llama-server 等 OpenAI 兼容接口)
-LLM_CONFIG = {
-    # API 连接
-    'api_host': 'localhost',          # API 主机地址
-    'api_port': 8111,                 # API 端口
-    'api_key': '',                    # API Key (如需要)
-    'use_https': False,               # 是否使用 HTTPS
-    
-    # 模型配置
-    'model': 'Qwen2.5-VL-7B-Instruct',  # 模型名称
-    
-    # 请求配置
-    'timeout': 30,                    # 超时时间(秒)
-    'max_retries': 3,                 # 最大重试次数
-    'retry_delay': 1.0,               # 重试延迟(秒)
-}
-
-# 安全分析配置 (大模型判断)
-LLM_SAFETY_CONFIG = {
-    'enabled': True,                  # 是否启用大模型判断
-    'use_llm_for_safety': True,       # 使用大模型判断安全状态
-    'use_llm_for_number': True,       # 使用大模型识别编号
-    
-    # 判断策略
-    'strategy': 'hybrid',             # 'llm'(仅大模型), 'rule'(仅规则), 'hybrid'(混合)
-    
-    # 混合模式下,当 YOLO 检测到安全装备时是否仍用大模型验证
-    'verify_with_llm': True,
-}

+ 2 - 9
dual_camera_system/config/system.py

@@ -14,22 +14,15 @@ SYSTEM_CONFIG = {
 
     # 检测模块
     'enable_detection': True,            # 启用人体检测 (YOLO)
-    'enable_safety_detection': False,     # 启用安全检测 (安全帽/反光衣)
 
     # 联动与校准
     'enable_calibration': True,          # 启用自动校准
     'enable_ptz_tracking': True,         # 启用 PTZ 跟踪联动
 
-    # 大模型
-    'enable_llm': False,                  # 启用大模型判断
-
     # 事件推送
     'enable_event_push': False,           # 启用事件推送
 
     # === 工作模式 ===
-    'mode': 'safety',                    # 工作模式: 'safety'(安全检测)
-    'tracking_mode': 'polling',          # 'polling' | 'async' | 'sequential'
-
-    # === 安全判断策略 ===
-    'safety_strategy': 'hybrid',         # 'llm'(仅大模型), 'rule'(仅规则), 'hybrid'(混合)
+    'tracking_mode': 'polling',          # 'polling'(默认) | 'async' | 'sequential'
+                                         # 注:async/sequential 为历史 OCR 联动模式,当前默认已移除 OCR
 }

+ 97 - 10
dual_camera_system/coordinator.py

@@ -268,6 +268,10 @@ class Coordinator:
         # 结果队列
         self.result_queue = queue.Queue()
         
+        # ByteTrack 跟踪器(基于 Ultralytics BYTETracker)
+        self.byte_tracker = None
+        self._byte_tracker_init_lock = threading.Lock()
+
         # 跨帧跟踪:全局track_id计数器
         self._next_track_id = 1
         self._track_id_lock = threading.Lock()
@@ -370,6 +374,9 @@ class Coordinator:
         if self.enable_ptz_camera:
             self.ptz.disconnect()
         
+        # 清理 BYTETracker
+        self.byte_tracker = None
+        
         # 打印统计信息
         self._print_stats()
         
@@ -434,14 +441,13 @@ class Coordinator:
                     
                     # 检测人体
                     detections = self._detect_persons(frame)
+                    # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
+                    detections = self._update_with_bytetrack(detections, frame, frame_size)
                     
                     # 更新检测统计
                     if detections:
                         self._update_stats('persons_detected', len(detections))
                     
-                    # 更新跟踪目标(track_id 在此方法内分配)
-                    self._update_tracking_targets(detections, frame_size)
-                    
                     # 处理检测结果
                     if detections:
                         self._process_detections(detections, frame, frame_size)
@@ -459,6 +465,86 @@ class Coordinator:
                 print(f"联动处理错误: {e}")
                 time.sleep(0.1)
     
+    def _init_byte_tracker(self):
+        """初始化 BYTETracker"""
+        with self._byte_tracker_init_lock:
+            if self.byte_tracker is not None:
+                return
+            try:
+                from ultralytics.trackers.byte_tracker import BYTETracker
+                import types
+                self._bt_args = types.SimpleNamespace(
+                    track_high_thresh=0.5,
+                    track_low_thresh=0.1,
+                    new_track_thresh=0.3,
+                    match_thresh=0.8,
+                    fuse_score=False,
+                    track_buffer=30,
+                    mot20=False,
+                )
+                self.byte_tracker = BYTETracker(args=self._bt_args)
+                logger.info("[跟踪] BYTETracker 初始化成功")
+            except Exception as e:
+                logger.warning(f"[跟踪] BYTETracker 初始化失败: {e},将使用简化位置匹配跟踪")
+                self.byte_tracker = None
+
+    def _update_with_bytetrack(self, detections: List[DetectedObject],
+                                frame: np.ndarray,
+                                frame_size: Tuple[int, int]) -> List[DetectedObject]:
+        """
+        使用 ObjectDetector + BYTETracker 进行跟踪
+        ByteTrack 失败时回退到旧位置匹配
+        """
+        conf_thr = DETECTION_CONFIG.get('confidence_threshold', 0.35)
+        person_dets = [d for d in detections if d.class_name == 'person'
+                       and d.confidence >= conf_thr]
+
+        self._init_byte_tracker()
+        if self.byte_tracker is not None and person_dets:
+            try:
+                import torch
+                dets_t = torch.tensor([[d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3], d.confidence, 0] for d in person_dets], dtype=torch.float32)
+                class _R:
+                    def __init__(s, x):
+                        s._raw = x
+                        s.xywh = torch.stack([(x[:,0]+x[:,2])/2,(x[:,1]+x[:,3])/2,x[:,2]-x[:,0],x[:,3]-x[:,1]], dim=-1)
+                        s.conf = x[:, 4]; s.cls = x[:, 5].long()
+                    def __getitem__(s, i): return _R(s._raw[i])
+                    def __len__(s): return len(s.conf)
+                tracks = self.byte_tracker.update(_R(dets_t), None)
+                if tracks is not None and len(tracks) > 0:
+                    frame_w, frame_h = frame_size
+                    cx_, cy_ = frame_w / 2, frame_h / 2
+                    now = time.time()
+                    with self.targets_lock:
+                        self.tracking_targets.clear()
+                        for tr in tracks:
+                            tx1, ty1, tx2, ty2, tid, tsc = int(tr[0]), int(tr[1]), int(tr[2]), int(tr[3]), int(tr[4]), float(tr[5])
+                            cxc, cyc = (tx1+tx2)//2, (ty1+ty2)//2
+                            self.tracking_targets[tid] = TrackingTarget(
+                                track_id=tid, position=(cxc/frame_w, cyc/frame_h),
+                                last_update=now, area=(tx2-tx1)*(ty2-ty1),
+                                confidence=tsc, center_distance=(abs(cxc-cx_)/cx_ + abs(cyc-cy_)/cy_)/2 if cx_>0 else 0)
+                        # IOU match
+                        import numpy as np
+                        for tr in np.array(tracks):
+                            tx1, ty1, tx2, ty2, tid = int(tr[0]), int(tr[1]), int(tr[2]), int(tr[3]), int(tr[4])
+                            best = None
+                            for d in person_dets:
+                                dx1, dy1, dw, dh = d.bbox; dx2, dy2 = dx1+dw, dy1+dh
+                                ix1, iy1 = max(tx1,dx1), max(ty1,dy1); ix2, iy2 = min(tx2,dx2), min(ty2,dy2)
+                                if ix1<ix2 and iy1<iy2 and ((ix2-ix1)*(iy2-iy1))/((tx2-tx1)*(ty2-ty1)+dw*dh-(ix2-ix1)*(iy2-iy1)+1e-6) > 0.3:
+                                    best = d
+                            if best is not None:
+                                best.track_id = tid
+                    return [d for d in person_dets if d.track_id is not None]
+            except Exception as e:
+                logger.warning(f"[跟踪] ByteTrack 执行异常: {e}")
+
+        # ByteTrack 不可用/无结果 → 回退旧位置匹配
+        self._update_tracking_targets(detections, frame_size)
+        return detections
+
     def _detect_persons(self, frame: np.ndarray) -> List[DetectedObject]:
         """检测人体"""
         if not self.enable_detection or self.detector is None:
@@ -887,6 +973,9 @@ class AsyncCoordinator(Coordinator):
             self._paired_saver.close()
             self._paired_saver = None
         
+        # 清理 BYTETracker
+        self.byte_tracker = None
+        
         self.panorama.disconnect()
         if self.enable_ptz_camera:
             self.ptz.disconnect()
@@ -969,16 +1058,15 @@ class AsyncCoordinator(Coordinator):
                     
                     # YOLO 人体检测
                     detections = self._detect_persons(frame)
+                    # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
+                    detections = self._update_with_bytetrack(detections, frame, frame_size)
 
                     if detections:
                         self._update_stats('persons_detected', len(detections))
                         detection_person_count += 1
                         detection_last_seen = current_time
                     
-                    # 更新跟踪目标(track_id 在此方法内分配)
-                    self._update_tracking_targets(detections, frame_size)
-                    
-                    # 配对图片保存:创建新批次(在 _update_tracking_targets 之后,使用正确的 track_id)
+                    # 配对图片保存:创建新批次
                     if detections and self._enable_paired_saving and self._paired_saver is not None:
                         self._create_detection_batch(frame, detections, frame_size)
                     
@@ -1746,15 +1834,14 @@ class SequentialCoordinator(AsyncCoordinator):
                         
                         # 执行检测
                         detections = self._detect_persons(frame)
+                        # 使用 BYTETracker 进行跟踪(失败时回退到位置匹配)
+                        detections = self._update_with_bytetrack(detections, frame, frame_size)
                         
                         if detections:
                             self._update_stats('persons_detected', len(detections))
                             detection_last_seen = current_time
                             detection_person_count += 1
                             
-                            # 更新跟踪目标
-                            self._update_tracking_targets(detections, frame_size)
-                            
                             # 【调试日志】检查跟踪目标数量
                             with self.targets_lock:
                                 tracking_count = len(self.tracking_targets)

+ 62 - 0
dual_camera_system/devices.md

@@ -0,0 +1,62 @@
+# DSH项目 — 设备与开发手册
+## 设备信息
+### 服务器/计算设备
+| 环境 | 设备 | IP | 用户/密码 | 说明 |
+|------|------|----|-----------|------|
+| 测试环境 | RK3588 (Radxa Rock 5B) | 192.168.8.3 | admin / admin | NPU 6 TOPS, Conda 环境: `rknn` |
+| 正式环境 | RK3588 (Radxa Rock 5B) | 10.126.126.1 | admin / admin | NPU 6 TOPS, Conda 环境: `rknn` |
+
+### 摄像机设备
+#### 测试环境
+| 设备类型 | 品牌 | IP | 用户/密码 | 分辨率 | 特殊参数 | 说明 |
+|----------|------|----|-----------|--------|----------|------|
+| 枪机 | 海康威视 | 192.168.8.2 | admin / QAZwsx12 | 2560*1440 | - | 宽视野人员检测 |
+| 球机 | 大华 | 192.168.8.5 | admin / Aa1234567 | 3840*2160 | pan_flip:True <br> ceiling_mount:True | PTZ 云台控制抓拍 |
+
+#### 正式环境
+| 设备类型 | 品牌 | IP | 用户/密码 | 分辨率 | 特殊参数 | 说明 |
+|----------|------|----|-----------|--------|----------|------|
+| 枪机 | 大华 | 192.168.20.196 | admin / Aa1234567 | 3840*1080 | - | 宽视野人员检测 |
+| 球机 | 大华 | 192.168.20.197 | admin / Aa1234567 | 3840*2160 | pan_flip:False <br> ceiling_mount:False | PTZ 云台控制抓拍 |
+
+## 接入地址与操作指令
+### RTSP 流地址
+#### 测试环境
+```
+# 海康威视枪机 主码流(人员检测)
+rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101
+
+# 大华球机 子码流(PTZ 抓拍预览)
+rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1
+```
+
+#### 正式环境
+```
+# 大华枪机 主码流(人员检测)
+rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0
+
+# 大华球机 子码流(PTZ 抓拍预览)
+rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=1
+```
+
+### SSH 登录指令
+#### 测试环境 RK3588
+```bash
+ssh admin@192.168.8.3
+/home/admin/miniconda3/condabin/conda activate rknn
+```
+
+#### 正式环境 RK3588
+```bash
+ssh admin@10.126.126.1
+/home/admin/miniconda3/condabin/conda activate rknn
+```
+
+---
+
+### 补充说明
+1. 正式环境大华枪机RTSP地址按大华通用格式补充,若实际地址有差异请按现场配置调整;
+2. 特殊参数说明:
+   - `pan_flip`:云台水平翻转开关,用于适配不同安装朝向的画面矫正;
+   - `ceiling_mount`:吸顶安装标识,True表示球机为倒挂安装模式,False为正装模式;
+3. 所有设备默认端口:SSH(22)、RTSP(554),若网络环境变更需同步更新端口配置。

+ 129 - 0
dual_camera_system/diag_rknn.py

@@ -0,0 +1,129 @@
+"""
+RKNN 模型诊断脚本
+加载 yolo11.rknn,分别在随机图和真实全景图上推理,
+打印每个输出的 shape / min / max / mean / std 和前 5 个 anchor 的 cls 分布,
+帮助判断 cls 分支是否异常。
+"""
+
+import os
+import sys
+import cv2
+import numpy as np
+from pathlib import Path
+
+# 项目路径
+sys.path.insert(0, str(Path(__file__).parent))
+
+MODEL_PATH = '/home/admin/dsh/yolo/yolo11.rknn'
+INPUT_SIZE = (640, 640)
+
+
+def letterbox(image, size=INPUT_SIZE):
+    h0, w0 = image.shape[:2]
+    ih, iw = size
+    scale = min(iw / w0, ih / h0)
+    new_w, new_h = int(w0 * scale), int(h0 * scale)
+    pad_w = (iw - new_w) // 2
+    pad_h = (ih - new_h) // 2
+    resized = cv2.resize(image, (new_w, new_h))
+    canvas = np.full((ih, iw, 3), 114, dtype=np.uint8)
+    canvas[pad_h:pad_h + new_h, pad_w:pad_w + new_w] = resized
+    return canvas, scale, pad_w, pad_h
+
+
+def run_inference(rknn, image_bgr):
+    canvas, scale, pad_w, pad_h = letterbox(image_bgr)
+    img = canvas[..., ::-1].astype(np.float32) / 255.0
+    blob = img[None, ...]
+    outputs = rknn.inference(inputs=[blob])
+    return outputs, (canvas, scale, pad_w, pad_h)
+
+
+def print_stats(outputs):
+    print(f"输出数量: {len(outputs)}")
+    for idx, out in enumerate(outputs):
+        print(f"\n--- output[{idx}] shape={out.shape} dtype={out.dtype} ---")
+        arr = out.flatten()
+        print(f"  min={arr.min():.6f} max={arr.max():.6f} "
+              f"mean={arr.mean():.6f} std={arr.std():.6f} "
+              f"ptp={np.ptp(arr):.6f}")
+        # 前 5 个 anchor 的 cls 最大值/argmax(仅对 cls 输出 shape (1,80,H,W))
+        if len(out.shape) == 4 and out.shape[1] == 80:
+            cls = out[0].transpose(1, 2, 0).reshape(-1, 80)
+            sig = 1 / (1 + np.exp(-cls))
+            top5 = sig.max(axis=1).argsort()[::-1][:5]
+            print(f"  top5 cls score: {sig.max(axis=1)[top5]}")
+            print(f"  top5 cls id:    {sig.argmax(axis=1)[top5]}")
+
+
+def decode_count(outputs, conf_threshold=0.5):
+    """用与生产代码相同的 DFL 解码,统计超过阈值的目标数"""
+    strides = [8, 16, 32]
+    reg_max = 16
+    dets = []
+    for scale_idx, stride in enumerate(strides):
+        bbox_out = outputs[scale_idx * 3]
+        cls_out = outputs[scale_idx * 3 + 1]
+        _, _, h, w = bbox_out.shape
+        bbox = bbox_out[0].transpose(1, 2, 0).reshape(h * w, 4, reg_max)
+        cls = cls_out[0].transpose(1, 2, 0).reshape(h * w, 80)
+        prob = np.exp(bbox - np.max(bbox, axis=-1, keepdims=True))
+        prob = prob / np.sum(prob, axis=-1, keepdims=True)
+        bins = np.arange(reg_max).reshape(1, 1, reg_max)
+        decoded = np.sum(prob * bins, axis=-1)
+        yv, xv = np.meshgrid(np.arange(h), np.arange(w), indexing='ij')
+        grid = np.stack([xv, yv], axis=-1).reshape(-1, 2) + 0.5
+        l, t, r, b = decoded[:, 0], decoded[:, 1], decoded[:, 2], decoded[:, 3]
+        x1 = (grid[:, 0] - l) * stride
+        y1 = (grid[:, 1] - t) * stride
+        x2 = (grid[:, 0] + r) * stride
+        y2 = (grid[:, 1] + b) * stride
+        cls = 1 / (1 + np.exp(-cls))
+        scores = cls.max(axis=1)
+        labels = cls.argmax(axis=1)
+        for i in range(len(scores)):
+            if scores[i] >= conf_threshold:
+                dets.append([x1[i], y1[i], x2[i], y2[i], scores[i], labels[i]])
+    return len(dets)
+
+
+def main():
+    from rknnlite.api import RKNNLite
+
+    print(f"加载模型: {MODEL_PATH}")
+    rknn = RKNNLite()
+    ret = rknn.load_rknn(MODEL_PATH)
+    if ret != 0:
+        print("加载失败")
+        return
+    ret = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2)
+    if ret != 0:
+        print("初始化失败")
+        return
+
+    # 1) 随机噪声图
+    print("\n==================== 随机噪声图 ====================")
+    noise = np.random.randint(0, 255, (480, 640, 3), dtype=np.uint8)
+    outs, _ = run_inference(rknn, noise)
+    print_stats(outs)
+    print(f"conf>=0.5 的目标数: {decode_count(outs, 0.5)}")
+    print(f"conf>=0.8 的目标数: {decode_count(outs, 0.8)}")
+
+    # 2) 真实全景图(如果存在)
+    sample_paths = list(Path('/home/admin/dsh/paired_images_group1').glob('**/00_panorama_original*.jpg'))
+    if sample_paths:
+        sample_path = sample_paths[0]
+        print(f"\n==================== 真实全景图: {sample_path} ====================")
+        frame = cv2.imread(str(sample_path))
+        outs, _ = run_inference(rknn, frame)
+        print_stats(outs)
+        print(f"conf>=0.5 的目标数: {decode_count(outs, 0.5)}")
+        print(f"conf>=0.8 的目标数: {decode_count(outs, 0.8)}")
+    else:
+        print("\n未找到保存的全景图样本")
+
+    rknn.release()
+
+
+if __name__ == '__main__':
+    main()

+ 55 - 342
dual_camera_system/event_pusher.py

@@ -1,111 +1,62 @@
 """
 事件推送模块
-将识别到的安全违规事件推送到业务平台
+
+当前仅保留跟踪抓拍事件的多图片上传与批量推送功能。
+OCR/LLM/安全违规相关事件推送已移除。
 """
 
 import os
 import time
 import json
-import threading
-import queue
 import tempfile
+import threading
 import requests
 import mimetypes
-from typing import Optional, Dict, Any, List, Callable
-from dataclasses import dataclass
+from typing import Optional, Dict, Any, List
 from datetime import datetime
-from enum import Enum
 
 import cv2
 import numpy as np
 
 
-class EventType(Enum):
-    """事件类型"""
-    SAFETY_VIOLATION = "安全违规"        # 安全违规(未戴安全帽/未穿反光衣)
-    INTRUSION = "入侵检测"              # 禁区入侵
-    LOITERING = "徘徊检测"              # 徘徊检测
-
-
-@dataclass
-class SafetyEvent:
-    """安全事件"""
-    event_type: EventType                 # 事件类型
-    description: str                      # 事件描述
-    image_path: Optional[str] = None      # 图片路径
-    image_url: Optional[str] = None       # 图片URL (上传后)
-    track_id: int = 0                     # 跟踪ID
-    confidence: float = 0.0               # 置信度
-    location: str = ""                    # 位置信息
-    timestamp: float = 0.0                # 时间戳
-    extra: Optional[Dict[str, Any]] = None  # 额外信息
-    
-    def __post_init__(self):
-        if self.timestamp == 0.0:
-            self.timestamp = time.time()
-        if self.extra is None:
-            self.extra = {}
-    
-    def to_dict(self) -> Dict[str, Any]:
-        """转换为字典"""
-        return {
-            'eventType': self.event_type.value,
-            'description': self.description,
-            'imageUrl': self.image_url,
-            'trackId': self.track_id,
-            'confidence': self.confidence,
-            'location': self.location,
-            'timestamp': self.timestamp,
-            'extra': self.extra
-        }
-
-
 class EventPusher:
     """
     事件推送器
-    负责将安全事件推送到业务平台
+    负责将跟踪抓拍事件推送到业务平台
     """
-    
+
     def __init__(self, config: Optional[Dict[str, Any]] = None):
         """
         初始化事件推送器
-        
+
         Args:
             config: 配置字典
         """
         self.config = config or {}
-        
+
         # API 配置
         self.api_host = self.config.get('api_host', 'jtjai.device.wenhq.top')
         self.api_port = self.config.get('api_port', 8583)
         self.use_https = self.config.get('use_https', True)
-        
+
         # 基础 URL(优先使用配置中的 base_url)
         self.base_url = self.config.get('base_url')
         if not self.base_url:
             protocol = 'https' if self.use_https else 'http'
             self.base_url = f"{protocol}://{self.api_host}:{self.api_port}"
-        
+
         # 上传接口
         self.upload_url = self.config.get('upload_url', '/api/resource/oss/upload')
         self.event_url = self.config.get('event_url', '/api/system/event')
-        
+
         # 推送控制
         self.enabled = self.config.get('enabled', True)
-        self.upload_interval = self.config.get('upload_interval', 2.0)  # 推送间隔
-        self.retry_count = self.config.get('retry_count', 3)            # 重试次数
-        self.retry_delay = self.config.get('retry_delay', 1.0)          # 重试延迟
-        
-        # 事件队列
-        self.event_queue = queue.Queue()
-        
+        self.retry_count = self.config.get('retry_count', 3)
+        self.retry_delay = self.config.get('retry_delay', 1.0)
+
         # 工作线程
         self.running = False
-        self.worker_thread = None
-        
-        # 上次推送时间
-        self.last_push_time = 0
-        
+
         # 统计
         self.stats = {
             'total_events': 0,
@@ -115,95 +66,35 @@ class EventPusher:
             'upload_failed': 0
         }
         self.stats_lock = threading.Lock()
-    
+
     def start(self):
-        """启动推送器"""
-        if self.running:
-            return
-        
+        """启动推送器(当前为无状态接口调用,保留方法便于系统统一生命周期管理)"""
         self.running = True
-        self.worker_thread = threading.Thread(target=self._worker, daemon=True)
-        self.worker_thread.start()
         print("事件推送器已启动")
-    
+
     def stop(self):
         """停止推送器"""
         self.running = False
-        if self.worker_thread:
-            self.worker_thread.join(timeout=3)
         print("事件推送器已停止")
-    
-    def push_event(self, event: SafetyEvent):
-        """
-        推送事件
-        
-        Args:
-            event: 安全事件
-        """
-        if not self.enabled:
-            return
-        
-        # 检查推送间隔
-        current_time = time.time()
-        if current_time - self.last_push_time < self.upload_interval:
-            return
-        
-        self.event_queue.put(event)
-        
-        with self.stats_lock:
-            self.stats['total_events'] += 1
-    
-    def push_safety_violation(self, description: str, image: Optional[np.ndarray] = None,
-                              track_id: int = 0, confidence: float = 0.0,
-                              location: str = "施工现场") -> bool:
-        """
-        推送安全违规事件
-        
-        Args:
-            description: 违规描述
-            image: 图像 (可选)
-            track_id: 跟踪ID
-            confidence: 置信度
-            location: 位置
-            
-        Returns:
-            是否成功加入队列
-        """
-        event = SafetyEvent(
-            event_type=EventType.SAFETY_VIOLATION,
-            description=description,
-            track_id=track_id,
-            confidence=confidence,
-            location=location
-        )
-        
-        # 保存图像
-        if image is not None:
-            temp_path = f"/tmp/safety_event_{int(time.time() * 1000)}.jpg"
-            cv2.imwrite(temp_path, image)
-            event.image_path = temp_path
-        
-        self.push_event(event)
-        return True
-    
+
     def upload_numpy_image(self, image: Optional[np.ndarray]) -> Optional[str]:
         """
         将 numpy 图片上传到 OSS
-        
+
         Args:
             image: numpy 图像数组
-            
+
         Returns:
             图片URL或None
         """
         if image is None:
             return None
-        
+
         temp_path = None
         try:
             with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
                 temp_path = f.name
-            
+
             cv2.imwrite(temp_path, image)
             url = self._upload_image(temp_path)
             return url
@@ -216,15 +107,15 @@ class EventPusher:
                     os.remove(temp_path)
                 except Exception:
                     pass
-    
+
     def push_tracking_capture(self, batch_time: float, captures: List[Dict[str, Any]]) -> Optional[requests.Response]:
         """
         推送一轮多目标跟踪抓拍事件
-        
+
         Args:
             batch_time: 批次时间戳
             captures: 抓拍记录列表
-            
+
         Returns:
             响应对象或None
         """
@@ -238,16 +129,28 @@ class EventPusher:
             }
         }
         url = f"{self.base_url}{self.event_url}"
-        return self._post(url, payload)
-    
+
+        with self.stats_lock:
+            self.stats['total_events'] += 1
+
+        response = self._post(url, payload)
+        if response is not None:
+            with self.stats_lock:
+                self.stats['pushed_events'] += 1
+        else:
+            with self.stats_lock:
+                self.stats['failed_events'] += 1
+
+        return response
+
     def _post(self, url: str, json_data: Dict[str, Any]) -> Optional[requests.Response]:
         """
         发送 POST 请求
-        
+
         Args:
             url: 请求URL
             json_data: JSON数据
-            
+
         Returns:
             响应对象或None
         """
@@ -259,81 +162,26 @@ class EventPusher:
                 print(f"POST 请求异常 (尝试 {attempt + 1}/{self.retry_count}): {e}")
                 if attempt < self.retry_count - 1:
                     time.sleep(self.retry_delay)
-        
+
         return None
-    
-    def _worker(self):
-        """工作线程"""
-        while self.running:
-            try:
-                # 获取事件
-                try:
-                    event = self.event_queue.get(timeout=1.0)
-                except queue.Empty:
-                    continue
-                
-                # 处理事件
-                self._process_event(event)
-                
-            except Exception as e:
-                print(f"事件处理错误: {e}")
-    
-    def _process_event(self, event: SafetyEvent):
-        """处理单个事件"""
-        try:
-            # 上传图片
-            if event.image_path:
-                image_url = self._upload_image(event.image_path)
-                if image_url:
-                    event.image_url = image_url
-                    with self.stats_lock:
-                        self.stats['upload_success'] += 1
-                else:
-                    with self.stats_lock:
-                        self.stats['upload_failed'] += 1
-                
-                # 清理临时文件
-                if os.path.exists(event.image_path):
-                    try:
-                        os.remove(event.image_path)
-                    except Exception as e:
-                        print(f"清理临时文件失败: {e}")
-            
-            # 创建事件
-            success = self._create_event(event)
-            
-            if success:
-                self.last_push_time = time.time()
-                with self.stats_lock:
-                    self.stats['pushed_events'] += 1
-                print(f"事件推送成功: {event.description}")
-            else:
-                with self.stats_lock:
-                    self.stats['failed_events'] += 1
-                print(f"事件推送失败: {event.description}")
-                
-        except Exception as e:
-            print(f"处理事件错误: {e}")
-            with self.stats_lock:
-                self.stats['failed_events'] += 1
-    
+
     def _upload_image(self, image_path: str) -> Optional[str]:
         """
         上传图片到 OSS
-        
+
         Args:
             image_path: 图片路径
-            
+
         Returns:
             图片URL或None
         """
         if not os.path.exists(image_path):
             return None
-        
+
         filename = os.path.basename(image_path)
         content_type = mimetypes.guess_type(image_path)[0] or 'image/jpeg'
         url = f"{self.base_url}{self.upload_url}"
-        
+
         for attempt in range(self.retry_count):
             try:
                 with open(image_path, 'rb') as f:
@@ -345,10 +193,12 @@ class EventPusher:
                         verify=False,
                         timeout=10
                     )
-                
+
                 if response.status_code == 200:
                     result = response.json()
                     if result.get('code') == 200:
+                        with self.stats_lock:
+                            self.stats['upload_success'] += 1
                         return result.get('data', {}).get('purl')
                     else:
                         print(f"上传失败: {result.get('msg', '未知错误')}")
@@ -358,149 +208,12 @@ class EventPusher:
                 print(f"上传异常 (尝试 {attempt + 1}/{self.retry_count}): {e}")
                 if attempt < self.retry_count - 1:
                     time.sleep(self.retry_delay)
-        
-        return None
-    
-    def _create_event(self, event: SafetyEvent) -> bool:
-        """
-        在业务平台创建事件
-        
-        Args:
-            event: 安全事件
-            
-        Returns:
-            是否成功
-        """
-        create_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(event.timestamp))
-
-        data = {
-            "createTime": create_time,
-            "addr": event.description,
-            "ext1": json.dumps([event.image_url]) if event.image_url else "[]",
-            "ext2": json.dumps({
-                "lx": "工地安全",
-                "type": event.event_type.value,
-                "trackId": event.track_id,
-                "confidence": event.confidence,
-                "location": event.location
-            })
-        }
-
-        url = f"{self.base_url}{self.event_url}"
-        response = self._post(url, data)
 
-        if response is None:
-            return False
-
-        try:
-            if response.status_code == 200:
-                result = response.json()
-                if result.get('code') == 200:
-                    return True
-                else:
-                    print(f"创建事件失败: {result.get('msg', '未知错误')}")
-            else:
-                print(f"创建事件失败: HTTP {response.status_code}")
-        except Exception as e:
-            print(f"创建事件解析响应异常: {e}")
+        with self.stats_lock:
+            self.stats['upload_failed'] += 1
+        return None
 
-        return False
-    
     def get_stats(self) -> Dict[str, int]:
         """获取统计信息"""
         with self.stats_lock:
             return self.stats.copy()
-
-
-class EventListener:
-    """
-    事件监听器
-    监听业务平台的指令(如语音播放指令)
-    """
-    
-    def __init__(self, config: Optional[Dict[str, Any]] = None):
-        """
-        初始化事件监听器
-        
-        Args:
-            config: 配置字典
-        """
-        self.config = config or {}
-        
-        # WebSocket 或 HTTP 长轮询配置
-        self.listen_url = self.config.get('listen_url', '')
-        self.poll_interval = self.config.get('poll_interval', 5.0)
-        
-        # 回调
-        self.on_voice_command = None
-        self.on_other_command = None
-        
-        # 运行状态
-        self.running = False
-        self.listener_thread = None
-    
-    def start(self):
-        """启动监听"""
-        if self.running:
-            return
-        
-        self.running = True
-        self.listener_thread = threading.Thread(target=self._listener_worker, daemon=True)
-        self.listener_thread.start()
-        print("事件监听器已启动")
-    
-    def stop(self):
-        """停止监听"""
-        self.running = False
-        if self.listener_thread:
-            self.listener_thread.join(timeout=3)
-        print("事件监听器已停止")
-    
-    def _listener_worker(self):
-        """监听工作线程"""
-        while self.running:
-            try:
-                # 轮询获取指令
-                # TODO: 实现 WebSocket 或 HTTP 长轮询
-                commands = self._poll_commands()
-                
-                for cmd in commands:
-                    self._process_command(cmd)
-                
-                time.sleep(self.poll_interval)
-                
-            except Exception as e:
-                print(f"监听错误: {e}")
-                time.sleep(1.0)
-    
-    def _poll_commands(self) -> List[Dict[str, Any]]:
-        """
-        轮询获取指令
-        
-        Returns:
-            指令列表
-        """
-        # TODO: 实现具体的轮询逻辑
-        # 这里可以对接业务平台的指令接口
-        return []
-    
-    def _process_command(self, cmd: Dict[str, Any]):
-        """处理指令"""
-        cmd_type = cmd.get('type', '')
-        
-        if cmd_type == 'voice':
-            # 语音播放指令
-            if self.on_voice_command:
-                self.on_voice_command(cmd)
-        else:
-            # 其他指令
-            if self.on_other_command:
-                self.on_other_command(cmd)
-    
-    def set_voice_callback(self, callback: Optional[Callable[[Dict[str, Any]], None]]):
-        """设置语音播放回调"""
-        self.on_voice_command = callback
-    
-    def set_other_callback(self, callback: Optional[Callable[[Dict[str, Any]], None]]):
-        """设置其他指令回调"""
-        self.on_other_command = callback

+ 212 - 0
dual_camera_system/inference_backend.py

@@ -0,0 +1,212 @@
+"""
+通用推理后端
+
+为 UltralyticsTracker 提供 RKNN / ONNX 模型的统一检测接口,
+与安全检测(安全帽/反光衣)解耦。
+"""
+
+import os
+import cv2
+import numpy as np
+from typing import List, Tuple, Dict, Any
+from dataclasses import dataclass
+
+
+@dataclass
+class Detection:
+    """检测结果 (用于 RKNN/ONNX 模型)"""
+    class_id: int
+    class_name: str
+    confidence: float
+    bbox: Tuple[int, int, int, int]
+
+
+def nms(dets, iou_threshold=0.45):
+    """非极大值抑制"""
+    if len(dets) == 0:
+        return []
+
+    boxes = np.array([[d.bbox[0], d.bbox[1], d.bbox[2], d.bbox[3], d.confidence] for d in dets])
+    x1 = boxes[:, 0]
+    y1 = boxes[:, 1]
+    x2 = boxes[:, 2]
+    y2 = boxes[:, 3]
+    scores = boxes[:, 4]
+
+    areas = (x2 - x1 + 1) * (y2 - y1 + 1)
+    order = scores.argsort()[::-1]
+
+    keep = []
+    while order.size > 0:
+        i = order[0]
+        keep.append(i)
+
+        xx1 = np.maximum(x1[i], x1[order[1:]])
+        yy1 = np.maximum(y1[i], y1[order[1:]])
+        xx2 = np.minimum(x2[i], x2[order[1:]])
+        yy2 = np.minimum(y2[i], y2[order[1:]])
+
+        w = np.maximum(0.0, xx2 - xx1 + 1)
+        h = np.maximum(0.0, yy2 - yy1 + 1)
+        inter = w * h
+        ovr = inter / (areas[i] + areas[order[1:]] - inter)
+
+        inds = np.where(ovr <= iou_threshold)[0]
+        order = order[inds + 1]
+
+    return [dets[i] for i in keep]
+
+
+class BaseDetector:
+    """检测器基类 (用于 RKNN/ONNX 模型)"""
+
+    # 默认 COCO 类别映射;子类可覆盖
+    LABEL_MAP = {0: 'person'}
+
+    def __init__(self, label_map: Dict[int, str] = None):
+        self.input_size = (640, 640)
+        self.num_classes = len(label_map) if label_map else max(self.LABEL_MAP.keys()) + 1
+        if label_map:
+            self.LABEL_MAP = label_map
+
+    def letterbox(self, image):
+        """Letterbox 预处理,保持宽高比"""
+        h0, w0 = image.shape[:2]
+        ih, iw = self.input_size
+        scale = min(iw / w0, ih / h0)
+        new_w, new_h = int(w0 * scale), int(h0 * scale)
+        pad_w = (iw - new_w) // 2
+        pad_h = (ih - new_h) // 2
+        resized = cv2.resize(image, (new_w, new_h))
+        canvas = np.full((ih, iw, 3), 114, dtype=np.uint8)
+        canvas[pad_h:pad_h+new_h, pad_w:pad_w+new_w] = resized
+        return canvas, scale, pad_w, pad_h, h0, w0
+
+    def postprocess(self, outputs, scale, pad_w, pad_h, h0, w0, conf_threshold_map):
+        """后处理"""
+        dets = []
+
+        if not outputs:
+            return dets
+
+        output = outputs[0]
+
+        if len(output.shape) == 3:
+            output = output[0]
+
+        num_boxes = output.shape[1]
+
+        for i in range(num_boxes):
+            x_center = float(output[0, i])
+            y_center = float(output[1, i])
+            width = float(output[2, i])
+            height = float(output[3, i])
+
+            class_probs = output[4:4+self.num_classes, i]
+            best_class = int(np.argmax(class_probs))
+            confidence = float(class_probs[best_class])
+
+            if best_class not in self.LABEL_MAP:
+                continue
+
+            conf_threshold = conf_threshold_map.get(best_class, 0.5)
+
+            if confidence < conf_threshold:
+                continue
+
+            # 移除 padding 并缩放到原始图像尺寸
+            x1 = int(((x_center - width / 2) - pad_w) / scale)
+            y1 = int(((y_center - height / 2) - pad_h) / scale)
+            x2 = int(((x_center + width / 2) - pad_w) / scale)
+            y2 = int(((y_center + height / 2) - pad_h) / scale)
+
+            x1 = max(0, min(w0, x1))
+            y1 = max(0, min(h0, y1))
+            x2 = max(0, min(w0, x2))
+            y2 = max(0, min(h0, y2))
+
+            det = Detection(
+                class_id=best_class,
+                class_name=self.LABEL_MAP[best_class],
+                confidence=confidence,
+                bbox=(x1, y1, x2, y2)
+            )
+            dets.append(det)
+
+        dets = nms(dets, iou_threshold=0.45)
+        return dets
+
+    def detect(self, image, conf_threshold_map):
+        raise NotImplementedError
+
+    def release(self):
+        pass
+
+
+class RKNNDetector(BaseDetector):
+    """RKNN 检测器 - 使用 NHWC 输入格式 (1, H, W, C)"""
+
+    def __init__(self, model_path: str, label_map: Dict[int, str] = None):
+        super().__init__(label_map=label_map)
+        self.model_path = model_path
+        self.rknn = None
+
+        try:
+            from rknnlite.api import RKNNLite
+            self.rknn = RKNNLite()
+        except ImportError:
+            raise ImportError("未安装 rknnlite,请运行: pip install rknnlite2 或参考 testrk3588/setup_rknn.sh")
+
+        ret = self.rknn.load_rknn(model_path)
+        if ret != 0:
+            raise RuntimeError(f"加载 RKNN 模型失败: {model_path}")
+
+        ret = self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2)
+        if ret != 0:
+            raise RuntimeError("初始化 RKNN 运行时失败")
+
+        print(f"RKNN 模型加载成功: {model_path}")
+
+    def detect(self, image, conf_threshold_map):
+        canvas, scale, pad_w, pad_h, h0, w0 = self.letterbox(image)
+        # RKNN 期望 NHWC (1, H, W, C), RGB, 归一化 0-1
+        img = canvas[..., ::-1].astype(np.float32) / 255.0
+        blob = img[None, ...]  # (1, 640, 640, 3)
+        outs = self.rknn.inference(inputs=[blob])
+        return self.postprocess(outs, scale, pad_w, pad_h, h0, w0, conf_threshold_map)
+
+    def release(self):
+        if self.rknn:
+            self.rknn.release()
+            self.rknn = None
+
+
+class ONNXDetector(BaseDetector):
+    """ONNX 检测器 - 使用 NCHW 输入格式 (1, C, H, W)"""
+
+    def __init__(self, model_path: str, label_map: Dict[int, str] = None):
+        super().__init__(label_map=label_map)
+        self.model_path = model_path
+
+        try:
+            import onnxruntime as ort
+            self.session = ort.InferenceSession(model_path)
+            self.input_name = self.session.get_inputs()[0].name
+            self.output_name = self.session.get_outputs()[0].name
+            print(f"ONNX 模型加载成功: {model_path}")
+        except ImportError:
+            raise ImportError("未安装 onnxruntime,请运行: pip install onnxruntime")
+        except Exception as e:
+            raise RuntimeError(f"加载 ONNX 模型失败: {e}")
+
+    def detect(self, image, conf_threshold_map):
+        canvas, scale, pad_w, pad_h, h0, w0 = self.letterbox(image)
+        # ONNX 期望 NCHW (1, C, H, W), RGB, 归一化 0-1
+        img = canvas[..., ::-1].astype(np.float32) / 255.0
+        img = img.transpose(2, 0, 1)
+        blob = img[None, ...]  # (1, 3, 640, 640)
+        outs = self.session.run([self.output_name], {self.input_name: blob})
+        return self.postprocess(outs, scale, pad_w, pad_h, h0, w0, conf_threshold_map)
+
+    def release(self):
+        self.session = None

+ 0 - 350
dual_camera_system/llm_service.py

@@ -1,350 +0,0 @@
-"""
-大模型 API 调用模块
-用于安全状态判断和 OCR 编号识别
-"""
-
-import os
-import json
-import time
-import base64
-import http.client
-from typing import Optional, Dict, Any, List
-from dataclasses import dataclass
-
-import cv2
-import numpy as np
-
-
-@dataclass
-class LLMResponse:
-    """大模型响应"""
-    content: str              # 响应内容
-    success: bool = True      # 是否成功
-    error: str = ""           # 错误信息
-    latency: float = 0.0      # 响应延迟(秒)
-
-
-class LLMClient:
-    """
-    大模型 API 客户端
-    支持 OpenAI 兼容接口 (千问、llama-server 等)
-    """
-    
-    def __init__(self, config: Dict[str, Any] = None):
-        """
-        初始化客户端
-        
-        Args:
-            config: 配置字典
-        """
-        self.config = config or {}
-        
-        # API 配置
-        self.api_host = self.config.get('api_host', 'localhost')
-        self.api_port = self.config.get('api_port', 8111)
-        self.api_key = self.config.get('api_key', '')
-        self.model = self.config.get('model', 'Qwen2.5-VL-7B-Instruct')
-        
-        # 超时和重试
-        self.timeout = self.config.get('timeout', 30)
-        self.max_retries = self.config.get('max_retries', 3)
-        self.retry_delay = self.config.get('retry_delay', 1.0)
-        
-        # 是否使用 HTTPS
-        self.use_https = self.config.get('use_https', 
-            self.api_host not in ['localhost', '127.0.0.1'])
-    
-    def _image_to_base64(self, image: np.ndarray) -> str:
-        """将图像转换为 base64 编码"""
-        if image is None:
-            return ""
-        
-        # 确保图像是连续的
-        image = np.ascontiguousarray(image)
-        
-        # 编码为 JPEG
-        success, buffer = cv2.imencode('.jpg', image, [cv2.IMWRITE_JPEG_QUALITY, 85])
-        if not success:
-            return ""
-        
-        base64_str = base64.b64encode(buffer).decode('utf-8')
-        return f"data:image/jpeg;base64,{base64_str}"
-    
-    def chat(self, messages: List[Dict], temperature: float = 0.3,
-             max_tokens: int = 1024, stream: bool = False) -> LLMResponse:
-        """
-        发送聊天请求
-        
-        Args:
-            messages: 消息列表
-            temperature: 温度参数
-            max_tokens: 最大生成 token 数
-            stream: 是否流式输出
-            
-        Returns:
-            LLMResponse 响应对象
-        """
-        payload = {
-            "model": self.model,
-            "messages": messages,
-            "temperature": temperature,
-            "max_tokens": max_tokens,
-            "stream": stream
-        }
-        
-        headers = {
-            'Content-Type': 'application/json',
-            'Accept': 'application/json',
-        }
-        
-        if self.api_key:
-            headers['Authorization'] = f'Bearer {self.api_key}'
-        
-        last_error = None
-        
-        for attempt in range(self.max_retries):
-            conn = None
-            try:
-                start_time = time.time()
-
-                # 创建连接
-                conn_class = http.client.HTTPSConnection if self.use_https else http.client.HTTPConnection
-                conn = conn_class(self.api_host, self.api_port, timeout=self.timeout)
-
-                conn.request("POST", "/v1/chat/completions",
-                            json.dumps(payload), headers)
-
-                res = conn.getresponse()
-                data = res.read()
-
-                latency = time.time() - start_time
-
-                if res.status != 200:
-                    error_msg = f"HTTP {res.status}: {data.decode('utf-8', errors='ignore')}"
-                    return LLMResponse(content="", success=False, error=error_msg, latency=latency)
-
-                response = json.loads(data.decode('utf-8'))
-
-                if 'choices' in response and len(response['choices']) > 0:
-                    content = response['choices'][0]['message']['content']
-                    return LLMResponse(content=content, success=True, latency=latency)
-                elif 'error' in response:
-                    return LLMResponse(content="", success=False,
-                                      error=response['error'].get('message', 'Unknown error'),
-                                      latency=latency)
-                else:
-                    return LLMResponse(content="", success=False,
-                                      error="Invalid response format", latency=latency)
-            except json.JSONDecodeError as e:
-                last_error = f"JSON 解析错误: {e}"
-            except http.client.HTTPException as e:
-                last_error = f"HTTP 错误: {e}"
-            except Exception as e:
-                last_error = str(e)
-            finally:
-                if conn:
-                    conn.close()
-            
-            # 重试
-            if attempt < self.max_retries - 1:
-                time.sleep(self.retry_delay * (attempt + 1))
-        
-        return LLMResponse(content="", success=False, error=last_error or "Unknown error")
-    
-    def vision_chat(self, image: np.ndarray, prompt: str,
-                    temperature: float = 0.3) -> LLMResponse:
-        """
-        视觉语言模型对话
-        
-        Args:
-            image: 图像
-            prompt: 提示词
-            temperature: 温度参数
-            
-        Returns:
-            LLMResponse 响应对象
-        """
-        image_base64 = self._image_to_base64(image)
-        
-        messages = [
-            {
-                "role": "user",
-                "content": [
-                    {"type": "text", "text": prompt},
-                    {"type": "image_url", "image_url": {"url": image_base64}}
-                ]
-            }
-        ]
-        
-        return self.chat(messages, temperature=temperature)
-    
-    def check_connection(self) -> bool:
-        """检查 API 连接"""
-        conn = None
-        try:
-            conn_class = http.client.HTTPSConnection if self.use_https else http.client.HTTPConnection
-            conn = conn_class(self.api_host, self.api_port, timeout=5)
-            conn.request("GET", "/v1/models")
-            res = conn.getresponse()
-            return res.status in [200, 404]  # 404 也表示服务在运行
-        except:
-            return False
-        finally:
-            if conn:
-                conn.close()
-
-
-class SafetyAnalyzer:
-    """
-    安全状态分析器
-    使用大模型判断安全状态
-    """
-    
-    # 安全分析提示词
-    SAFETY_PROMPT = """你是一个施工现场安全管理助手。请分析这张图片中的安全情况。
-
-请检查以下几点:
-1. 图片中是否有人员?
-2. 人员是否佩戴了安全帽?
-3. 人员是否穿着反光衣/安全背心?
-
-请以 JSON 格式回复,格式如下:
-{
-    "has_person": true/false,
-    "person_count": 数字,
-    "safety_status": "safe" 或 "violation",
-    "violations": ["违规项1", "违规项2"],
-    "description": "简要描述",
-    "confidence": 0.0-1.0
-}
-
-只返回 JSON,不要其他内容。"""
-
-    def __init__(self, llm_config: Dict[str, Any] = None):
-        """
-        初始化分析器
-        
-        Args:
-            llm_config: LLM 配置
-        """
-        self.llm = LLMClient(llm_config)
-        self.enabled = True
-    
-    def analyze(self, image: np.ndarray) -> Dict[str, Any]:
-        """
-        分析图像中的安全状态
-        
-        Args:
-            image: 输入图像
-            
-        Returns:
-            分析结果字典
-        """
-        if not self.enabled or image is None:
-            return self._default_result()
-        
-        # 调用大模型
-        response = self.llm.vision_chat(image, self.SAFETY_PROMPT, temperature=0.1)
-        
-        if not response.success:
-            print(f"安全分析失败: {response.error}")
-            return self._default_result()
-        
-        # 解析结果
-        try:
-            # 尝试提取 JSON
-            content = response.content.strip()
-            
-            # 处理 markdown 代码块
-            if '```json' in content:
-                content = content.split('```json')[1].split('```')[0]
-            elif '```' in content:
-                content = content.split('```')[1].split('```')[0]
-            
-            result = json.loads(content.strip())
-            
-            # 验证必要字段
-            if 'has_person' not in result:
-                result['has_person'] = False
-            if 'safety_status' not in result:
-                result['safety_status'] = 'unknown'
-            if 'violations' not in result:
-                result['violations'] = []
-            
-            result['success'] = True
-            result['latency'] = response.latency
-            
-            return result
-            
-        except json.JSONDecodeError as e:
-            print(f"解析安全分析结果失败: {e}")
-            print(f"原始响应: {response.content[:200]}")
-            return self._default_result()
-    
-    def _default_result(self) -> Dict[str, Any]:
-        """返回默认结果"""
-        return {
-            'has_person': False,
-            'person_count': 0,
-            'safety_status': 'unknown',
-            'violations': [],
-            'description': '',
-            'confidence': 0.0,
-            'success': False
-        }
-    
-    def check_person_safety(self, person_image: np.ndarray) -> Dict[str, Any]:
-        """
-        检查单个人员的安全状态
-        
-        Args:
-            person_image: 人员图像(裁剪后的人体区域)
-            
-        Returns:
-            安全状态字典
-        """
-        prompt = """分析这张图片中人员的安全装备佩戴情况。
-
-请检查:
-1. 是否佩戴安全帽?
-2. 是否穿着反光衣/安全背心?
-
-以 JSON 格式回复:
-{
-    "has_helmet": true/false,
-    "has_vest": true/false,
-    "is_violation": true/false,
-    "violation_desc": "违规描述,如果没有违规则为空",
-    "confidence": 0.0-1.0
-}
-
-只返回 JSON。"""
-        
-        if person_image is None:
-            return {'has_helmet': False, 'has_vest': False, 'is_violation': True,
-                   'violation_desc': '无法识别', 'confidence': 0.0}
-        
-        response = self.llm.vision_chat(person_image, prompt, temperature=0.1)
-        
-        if not response.success:
-            return {'has_helmet': False, 'has_vest': False, 'is_violation': True,
-                   'violation_desc': '识别失败', 'confidence': 0.0}
-        
-        try:
-            content = response.content.strip()
-            if '```json' in content:
-                content = content.split('```json')[1].split('```')[0]
-            elif '```' in content:
-                content = content.split('```')[1].split('```')[0]
-            
-            result = json.loads(content.strip())
-            result['success'] = True
-            return result
-        except:
-            return {'has_helmet': False, 'has_vest': False, 'is_violation': True,
-                   'violation_desc': '解析失败', 'confidence': 0.0}
-
-
-def create_safety_analyzer(config: Dict[str, Any] = None) -> SafetyAnalyzer:
-    """创建安全分析器"""
-    return SafetyAnalyzer(config)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 33 - 896
dual_camera_system/main.py


+ 385 - 97
dual_camera_system/panorama_camera.py

@@ -20,8 +20,10 @@ from dataclasses import dataclass
 from pathlib import Path
 
 from config import PANORAMA_CAMERA, DETECTION_CONFIG
+from config.camera import parse_resolution
 from dahua_sdk import DahuaSDK, PTZCommand
 from video_lock import safe_read, safe_is_opened
+from inference_backend import nms
 
 logger = logging.getLogger(__name__)
 
@@ -48,25 +50,36 @@ class PanoramaCamera:
         """
         self.sdk = sdk
         self.config = camera_config or PANORAMA_CAMERA
-        
+
+        # 解析期望分辨率
+        self.frame_width, self.frame_height = parse_resolution(self.config.get('resolution'))
+
+        # 摄像头品牌 / SDK 使用策略
+        # brand: 'dahua' | 'hikvision' | 'uniview' | 'auto'
+        # use_sdk: True 时使用大华 SDK 登录;False 时仅使用 RTSP 取流
+        self.brand = self.config.get('brand', 'auto').lower()
+        self.use_sdk = self.config.get('use_sdk', self.brand != 'hikvision')
+        if self.brand == 'hikvision':
+            self.use_sdk = False
+
         self.login_handle = None
         self.play_handle = None
         self.connected = False
-        
+
         # 视频流
         self.frame_queue = queue.Queue(maxsize=10)
         self.current_frame = None
         self.frame_lock = threading.Lock()
         self.rtsp_cap = None  # RTSP视频捕获
         self._camera_id = 'panorama'  # 用于per-camera锁
-        
+
         # 检测器
         self.detector = None
-        
+
         # 控制标志
         self.running = False
         self.stream_thread = None
-        
+
         # 断线重连
         self.auto_reconnect = True
         self.reconnect_interval = 5.0  # 重连间隔(秒)
@@ -78,52 +91,65 @@ class PanoramaCamera:
         Returns:
             是否成功
         """
+        if not self.use_sdk:
+            print(f"[PanoramaCamera] {self.config.get('ip')} 配置为 RTSP-only 模式,跳过 SDK 登录")
+            self.connected = True
+            return True
+
         login_handle, error = self.sdk.login(
             self.config['ip'],
             self.config['port'],
             self.config['username'],
             self.config['password']
         )
-        
+
         if login_handle is None:
             print(f"连接全景摄像头失败: IP={self.config['ip']}, 错误码={error}")
             return False
-        
+
         self.login_handle = login_handle
         self.connected = True
         print(f"成功连接全景摄像头: {self.config['ip']}")
         return True
-    
+
     def disconnect(self):
         """断开连接"""
         self.stop_stream()
-        if self.login_handle:
+        if self.use_sdk and self.login_handle:
             self.sdk.logout(self.login_handle)
             self.login_handle = None
         self.connected = False
-    
+
+    def is_connected(self) -> bool:
+        """是否已连接"""
+        return self.connected
+
     def start_stream(self) -> bool:
         """
-        开始视频流
+        开始视频流 (SDK 模式,仅 Dahua 等品牌支持)
         Returns:
             是否成功
         """
         if not self.connected:
             return False
-        
+
+        if not self.use_sdk:
+            print("[PanoramaCamera] 当前为 RTSP-only 模式,跳过 SDK 视频流")
+            return False
+
         self.play_handle = self.sdk.real_play(
-            self.login_handle, 
+            self.login_handle,
             self.config['channel']
         )
-        
+
         if self.play_handle is None:
             print("启动视频流失败")
             return False
-        
+
         self.running = True
         self.stream_thread = threading.Thread(target=self._stream_worker, daemon=True)
         self.stream_thread.start()
-        
+
         print("视频流已启动")
         return True
     
@@ -150,11 +176,11 @@ class PanoramaCamera:
                     return False
             
             self.rtsp_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
-            
+
             self.running = True
             self.stream_thread = threading.Thread(target=self._rtsp_stream_worker, daemon=True)
             self.stream_thread.start()
-            print(f"RTSP视频流已启动: {rtsp_url}")
+            print(f"RTSP视频流已启动: {rtsp_url} (期望分辨率 {self.frame_width}x{self.frame_height})")
             return True
         except Exception as e:
             print(f"RTSP流启动失败: {e}")
@@ -211,17 +237,17 @@ class PanoramaCamera:
                     retry_count += 1
                     time.sleep(1.0)  # 重试间隔
                 else:
-                    # 超过最大重试次数,使用模拟帧
-                    frame = np.zeros((1080, 1920, 3), dtype=np.uint8)
-                    
+                    # 超过最大重试次数,使用与配置分辨率一致的模拟帧
+                    frame = np.zeros((self.frame_height, self.frame_width, 3), dtype=np.uint8)
+
                     with self.frame_lock:
                         self.current_frame = frame
-                    
+
                     try:
                         self.frame_queue.put(frame, block=False)
                     except queue.Full:
                         pass
-                    
+
                     time.sleep(0.1)
                 
             except Exception as e:
@@ -266,10 +292,25 @@ class PanoramaCamera:
                     continue
                 
                 error_count = 0
-                
+
+                # 记录实际分辨率,仅做校验与提示(不做拉伸缩放,避免丢精度)
+                actual_h, actual_w = frame.shape[:2]
+                if not getattr(self, '_resolution_logged', False):
+                    print(f"全景摄像头实际分辨率: {actual_w}x{actual_h},期望分辨率: "
+                          f"{self.frame_width}x{self.frame_height}")
+                    self._resolution_logged = True
+                if (actual_w, actual_h) != (self.frame_width, self.frame_height):
+                    if not getattr(self, '_resolution_warned', False):
+                        logger.warning(
+                            f"全景摄像头分辨率 {actual_w}x{actual_h} 与期望分辨率 "
+                            f"{self.frame_width}x{self.frame_height} 不一致,"
+                            f"模型推理时将使用 letterbox 灰度填充保持比例"
+                        )
+                        self._resolution_warned = True
+
                 with self.frame_lock:
                     self.current_frame = frame.copy()
-                
+
                 try:
                     self.frame_queue.put(frame, block=False)
                 except queue.Full:
@@ -419,6 +460,7 @@ class ObjectDetector:
         self.use_gpu = use_gpu
         self.model_size = model_size
         self.model_type = model_type
+        self.is_end2end = False
         self.config = DETECTION_CONFIG
         self.device = 'cuda:0' if use_gpu else 'cpu'
         
@@ -444,6 +486,9 @@ class ObjectDetector:
                 self.model_type = 'onnx'
             elif ext == '.pt':
                 self.model_type = 'yolo'
+            # end2end 模型(内置NMS),输出格式 (N, 6) = (x1,y1,x2,y2,conf,cls)
+            if 'end2end' in os.path.basename(model_path).lower():
+                self.is_end2end = True
         
         self._load_model()
     
@@ -486,8 +531,8 @@ class ObjectDetector:
             import onnxruntime as ort
             self.session = ort.InferenceSession(self.model_path)
             self.input_name = self.session.get_inputs()[0].name
-            self.output_name = self.session.get_outputs()[0].name
-            print(f"ONNX 模型加载成功: {self.model_path}")
+            self.output_names = [o.name for o in self.session.get_outputs()]
+            print(f"ONNX 模型加载成功: {self.model_path}, 输出数量={len(self.output_names)}")
         except ImportError:
             raise ImportError("未安装 onnxruntime,请运行: pip install onnxruntime")
     
@@ -639,8 +684,139 @@ class ObjectDetector:
         canvas[pad_h:pad_h+new_h, pad_w:pad_w+new_w] = resized
         return canvas, scale, pad_w, pad_h, h0, w0
     
+    @staticmethod
+    def _make_grid(h: int, w: int):
+        """生成特征图网格坐标"""
+        yv, xv = np.meshgrid(np.arange(h), np.arange(w), indexing='ij')
+        return np.stack([xv, yv], axis=-1).reshape(-1, 2)
+
+    @staticmethod
+    def _pseudo_person_confidence(bboxes: np.ndarray, orig_h: int, orig_w: int) -> np.ndarray:
+        """
+        当 RKNN 模型 cls 输出恒定时,根据 bbox 形状生成伪置信度。
+        bboxes: (N, 4) [x1, y1, x2, y2] 原始图像坐标
+        """
+        x1, y1, x2, y2 = bboxes[:, 0], bboxes[:, 1], bboxes[:, 2], bboxes[:, 3]
+        w = np.maximum(1.0, x2 - x1)
+        h = np.maximum(1.0, y2 - y1)
+        aspect = h / w
+
+        # 人体宽高比通常在 1.5 ~ 4.0 之间,以 2.5 为最佳
+        aspect_score = np.exp(-0.5 * ((aspect - 2.5) / 1.0) ** 2)
+
+        # 面积占画面比例,过大/过小都降权
+        area = w * h
+        img_area = orig_w * orig_h
+        area_ratio = area / img_area
+        size_score = np.clip(area_ratio * 80, 0.0, 1.0)  # 占画面 1.25% 以上得满分
+
+        # 综合伪置信度,范围 0.55 ~ 0.95
+        conf = 0.55 + 0.30 * aspect_score + 0.10 * size_score
+        return conf
+
+    def _decode_yolo11_outputs(self, outputs: list, canvas_size: tuple,
+                                scale: float, pad_w: int, pad_h: int,
+                                orig_h: int, orig_w: int,
+                                conf_threshold: float = 0.5,
+                                iou_threshold: float = 0.45) -> list:
+        """
+        解码 YOLO11 RKNN 多输出格式 (DFL bbox + cls + mask,每尺度 3 个输出)
+
+        outputs: [bbox_80x80, cls_80x80, mask_80x80, bbox_40x40, cls_40x40, ...]
+        返回: [[x1, y1, x2, y2, score, class_id], ...] (原始图像坐标)
+        """
+        strides = [8, 16, 32]
+        reg_max = 16
+        dets = []
+        # 记录 cls 输出是否异常(常量),用于后续 fallback
+        cls_constant = True
+
+        for scale_idx, stride in enumerate(strides):
+            bbox_out = outputs[scale_idx * 3]
+            cls_out = outputs[scale_idx * 3 + 1]
+
+            # 检测 cls 是否为常量(量化/导出异常导致)
+            if np.ptp(cls_out) > 1e-4:
+                cls_constant = False
+
+            _, _, h, w = bbox_out.shape
+            # (64, H, W) -> (H*W, 4, 16)
+            bbox = bbox_out[0].transpose(1, 2, 0).reshape(h * w, 4, reg_max)
+            # (80, H, W) -> (H*W, 80)
+            cls = cls_out[0].transpose(1, 2, 0).reshape(h * w, 80)
+
+            # DFL 解码
+            prob = np.exp(bbox - np.max(bbox, axis=-1, keepdims=True))
+            prob = prob / np.sum(prob, axis=-1, keepdims=True)
+            bins = np.arange(reg_max).reshape(1, 1, reg_max)
+            decoded = np.sum(prob * bins, axis=-1)  # (H*W, 4)
+
+            # 网格中心
+            grid = self._make_grid(h, w) + 0.5  # (H*W, 2)
+
+            l, t, r, b = decoded[:, 0], decoded[:, 1], decoded[:, 2], decoded[:, 3]
+            x1 = (grid[:, 0] - l) * stride
+            y1 = (grid[:, 1] - t) * stride
+            x2 = (grid[:, 0] + r) * stride
+            y2 = (grid[:, 1] + b) * stride
+
+            # cls sigmoid
+            cls = 1.0 / (1.0 + np.exp(-cls))
+            scores = np.max(cls, axis=1)
+            labels = np.argmax(cls, axis=1)
+
+            for i in range(len(scores)):
+                if scores[i] < conf_threshold:
+                    continue
+                dets.append([x1[i], y1[i], x2[i], y2[i], scores[i], labels[i]])
+
+        if not dets:
+            return []
+
+        dets = np.array(dets)
+
+        # 从 canvas(640x640) 坐标映射回原始图像坐标:去 padding -> 除以 scale
+        dets[:, [0, 2]] = (dets[:, [0, 2]] - pad_w) / scale
+        dets[:, [1, 3]] = (dets[:, [1, 3]] - pad_h) / scale
+
+        # clip
+        dets[:, [0, 2]] = np.clip(dets[:, [0, 2]], 0, orig_w)
+        dets[:, [1, 3]] = np.clip(dets[:, [1, 3]], 0, orig_h)
+
+        # 若 cls 输出异常常量,用伪置信度替代,帮助 NMS 和后续过滤
+        if cls_constant:
+            pseudo_scores = self._pseudo_person_confidence(dets[:, :4], orig_h, orig_w)
+            dets[:, 4] = pseudo_scores
+            dets[:, 5] = 0  # 强制为 person 类别
+
+        # NMS
+        x1, y1, x2, y2 = dets[:, 0], dets[:, 1], dets[:, 2], dets[:, 3]
+        scores = dets[:, 4]
+        areas = (x2 - x1) * (y2 - y1)
+        order = scores.argsort()[::-1]
+
+        keep = []
+        while order.size > 0:
+            i = order[0]
+            keep.append(i)
+
+            xx1 = np.maximum(x1[i], x1[order[1:]])
+            yy1 = np.maximum(y1[i], y1[order[1:]])
+            xx2 = np.minimum(x2[i], x2[order[1:]])
+            yy2 = np.minimum(y2[i], y2[order[1:]])
+
+            w = np.maximum(0.0, xx2 - xx1)
+            h = np.maximum(0.0, yy2 - yy1)
+            inter = w * h
+            iou = inter / (areas[i] + areas[order[1:]] - inter + 1e-6)
+
+            inds = np.where(iou <= iou_threshold)[0]
+            order = order[inds + 1]
+
+        return dets[keep].tolist()
+
     def _detect_rknn(self, frame: np.ndarray) -> List[DetectedObject]:
-        """使用 RKNN/ONNX 模型检测 - 宽幅全景图分区域检测以提高远处目标识别率"""
+        """使用 RKNN/ONNX 模型检测"""
         results = []
         h0, w0 = frame.shape[:2]
 
@@ -649,6 +825,59 @@ class ObjectDetector:
             return self._detect_rknn_tiled(frame)
 
         try:
+            conf_threshold = self.config['confidence_threshold']
+            class_map = self.config.get('class_map', {0: 'person'})
+
+            # -------------------------------------------------------
+            # end2end 模型(内置NMS):resize + NCHW 预处理
+            # 输出格式 (N, 6) = (x1,y1,x2,y2,conf,cls) 在 640x640 空间
+            # -------------------------------------------------------
+            if self.is_end2end:
+                img = cv2.resize(frame, (640, 640))
+                img = img.astype(np.float32) / 255.0
+
+                if hasattr(self, 'rknn'):
+                    blob = img.transpose(2, 0, 1)[None, ...]  # NCHW
+                    outputs = self.rknn.inference(inputs=[blob])
+                else:
+                    blob = img.transpose(2, 0, 1)[None, ...]
+                    outputs = self.session.run(None, {self.input_name: blob})
+
+                output = outputs[0]
+                if len(output.shape) == 3:
+                    output = output[0]
+
+                for i in range(output.shape[0]):
+                    x1, y1, x2, y2, conf, cls_id = output[i]
+                    if conf < conf_threshold:
+                        continue
+                    cls_name = class_map.get(int(cls_id), str(int(cls_id)))
+                    if cls_name not in self.config['target_classes']:
+                        continue
+
+                    x1 = int(x1 * w0 / 640)
+                    y1 = int(y1 * h0 / 640)
+                    x2 = int(x2 * w0 / 640)
+                    y2 = int(y2 * h0 / 640)
+                    x1 = max(0, min(w0, x1))
+                    y1 = max(0, min(h0, y1))
+                    x2 = max(0, min(w0, x2))
+                    y2 = max(0, min(h0, y2))
+
+                    if x2 - x1 < 10 or y2 - y1 < 10:
+                        continue
+
+                    results.append(DetectedObject(
+                        class_name=cls_name,
+                        confidence=float(conf),
+                        bbox=(x1, y1, x2 - x1, y2 - y1),
+                        center=((x1 + x2) // 2, (y1 + y2) // 2)
+                    ))
+                return results
+
+            # -------------------------------------------------------
+            # 非 end2end 模型:letterbox + NHWC 预处理
+            # -------------------------------------------------------
             canvas, scale, pad_w, pad_h, h0, w0 = self._letterbox(frame)
 
             if hasattr(self, 'rknn'):
@@ -661,60 +890,79 @@ class ObjectDetector:
                 img = canvas[..., ::-1].astype(np.float32) / 255.0
                 img = img.transpose(2, 0, 1)
                 blob = img[None, ...]
-                outputs = self.session.run([self.output_name], {self.input_name: blob})
+                outputs = self.session.run(None, {self.input_name: blob})
 
-            output = outputs[0]
-            if len(output.shape) == 3:
-                output = output[0]
-
-            num_boxes = output.shape[1]
-            conf_threshold = self.config['confidence_threshold']
+            # 根据输出数量判断格式:YOLO11 DFL 为 9 个输出(3 scales x 3 branches)
+            if len(outputs) == 9:
+                dets = self._decode_yolo11_outputs(
+                    outputs, (640, 640), scale, pad_w, pad_h, h0, w0,
+                    conf_threshold=conf_threshold
+                )
+                for x1, y1, x2, y2, score, cls_id in dets:
+                    cls_name = class_map.get(int(cls_id), str(int(cls_id)))
+                    if cls_name not in self.config['target_classes']:
+                        continue
+                    if x2 - x1 < 10 or y2 - y1 < 10:
+                        continue
+                    obj = DetectedObject(
+                        class_name=cls_name,
+                        confidence=float(score),
+                        bbox=(int(x1), int(y1), int(x2 - x1), int(y2 - y1)),
+                        center=(int((x1 + x2) / 2), int((y1 + y2) / 2))
+                    )
+                    results.append(obj)
+            else:
+                # 标准 (84, 8400) 格式(ONNX 或新 RKNN)
+                output = outputs[0]
+                if len(output.shape) == 3:
+                    output = output[0]
 
-            for i in range(num_boxes):
-                x_center = float(output[0, i])
-                y_center = float(output[1, i])
-                width = float(output[2, i])
-                height = float(output[3, i])
+                num_boxes = output.shape[1]
+                candidates = []
+                for i in range(num_boxes):
+                    x_center = float(output[0, i])
+                    y_center = float(output[1, i])
+                    width = float(output[2, i])
+                    height = float(output[3, i])
 
-                class_probs = output[4:, i]
-                best_class = int(np.argmax(class_probs))
-                confidence = float(class_probs[best_class])
+                    class_probs = output[4:, i]
+                    best_class = int(np.argmax(class_probs))
+                    confidence = float(class_probs[best_class])
 
-                if confidence < conf_threshold:
-                    continue
+                    if confidence < conf_threshold:
+                        continue
 
-                # 转换到原始图像坐标
-                x1 = int(((x_center - width / 2) - pad_w) / scale)
-                y1 = int(((y_center - height / 2) - pad_h) / scale)
-                x2 = int(((x_center + width / 2) - pad_w) / scale)
-                y2 = int(((y_center + height / 2) - pad_h) / scale)
+                    x1 = int(((x_center - width / 2) - pad_w) / scale)
+                    y1 = int(((y_center - height / 2) - pad_h) / scale)
+                    x2 = int(((x_center + width / 2) - pad_w) / scale)
+                    y2 = int(((y_center + height / 2) - pad_h) / scale)
 
-                x1 = max(0, min(w0, x1))
-                y1 = max(0, min(h0, y1))
-                x2 = max(0, min(w0, x2))
-                y2 = max(0, min(h0, y2))
+                    x1 = max(0, min(w0, x1))
+                    y1 = max(0, min(h0, y1))
+                    x2 = max(0, min(w0, x2))
+                    y2 = max(0, min(h0, y2))
 
-                if x2 - x1 < 10 or y2 - y1 < 10:
-                    continue
+                    if x2 - x1 < 10 or y2 - y1 < 10:
+                        continue
 
-                # 使用配置的类别映射获取类别名称
-                class_map = self.config.get('class_map', {0: 'hat',3: 'person',4: 'reflective'})
-                cls_name = class_map.get(best_class, str(best_class))
+                    cls_name = class_map.get(best_class, str(best_class))
+                    if cls_name not in self.config['target_classes']:
+                        continue
 
-                # 检查是否为目标类别
-                if cls_name not in self.config['target_classes']:
-                    continue
+                    obj = DetectedObject(
+                        class_name=cls_name,
+                        confidence=confidence,
+                        bbox=(x1, y1, x2 - x1, y2 - y1),
+                        center=((x1 + x2) // 2, (y1 + y2) // 2)
+                    )
+                    candidates.append(obj)
 
-                obj = DetectedObject(
-                    class_name=cls_name,
-                    confidence=confidence,
-                    bbox=(x1, y1, x2 - x1, y2 - y1),
-                    center=((x1 + x2) // 2, (y1 + y2) // 2)
-                )
-                results.append(obj)
+                results = nms(candidates, iou_threshold=0.45)
 
         except Exception as e:
             logger.error(f"RKNN/ONNX 检测错误: {e}")
+            import traceback
+            logger.error(traceback.format_exc())
 
         return results
 
@@ -723,7 +971,7 @@ class ObjectDetector:
         results = []
         h0, w0 = frame.shape[:2]
         conf_threshold = self.config['confidence_threshold']
-        class_map = self.config.get('class_map', {0: 'hat', 3: 'person', 4: 'reflective'})
+        class_map = self.config.get('class_map', {0: 'person'})
 
         # 分3个重叠区域
         overlap = int(h0 * 0.2)
@@ -737,42 +985,82 @@ class ObjectDetector:
 
         for x_start, x_end in regions:
             crop = frame[:, x_start:x_end]
-            canvas, scale, pad_w, pad_h, ch, cw = self._letterbox(crop)
+            ch, cw = crop.shape[:2]
 
-            if hasattr(self, 'rknn'):
-                img = canvas[..., ::-1].astype(np.float32) / 255.0
-                outputs = self.rknn.inference(inputs=[img[None, ...]])
+            # end2end 模型:resize + NCHW
+            if self.is_end2end:
+                img = cv2.resize(crop, (640, 640))
+                img = img.astype(np.float32) / 255.0
+                if hasattr(self, 'rknn'):
+                    outputs = self.rknn.inference(inputs=[img.transpose(2, 0, 1)[None, ...]])
+                else:
+                    outputs = self.session.run(None, {self.input_name: img.transpose(2, 0, 1)[None, ...]})
+                output = outputs[0]
+                if len(output.shape) == 3:
+                    output = output[0]
+                dets = []
+                for i in range(output.shape[0]):
+                    x1, y1, x2, y2, conf, cls_id = output[i]
+                    if conf < conf_threshold:
+                        continue
+                    cls_name = class_map.get(int(cls_id), str(int(cls_id)))
+                    if cls_name not in self.config['target_classes']:
+                        continue
+                    _x1 = int(x1 * cw / 640)
+                    _y1 = int(y1 * ch / 640)
+                    _x2 = int(x2 * cw / 640)
+                    _y2 = int(y2 * ch / 640)
+                    dets.append([_x1, _y1, _x2, _y2, float(conf), int(cls_id)])
             else:
-                img = canvas[..., ::-1].astype(np.float32) / 255.0
-                img = img.transpose(2, 0, 1)
-                outputs = self.session.run([self.output_name], {self.input_name: img[None, ...]})
+                canvas, scale, pad_w, pad_h, ch, cw = self._letterbox(crop)
 
-            output = outputs[0]
-            if len(output.shape) == 3:
-                output = output[0]
+                if hasattr(self, 'rknn'):
+                    img = canvas[..., ::-1].astype(np.float32) / 255.0
+                    outputs = self.rknn.inference(inputs=[img[None, ...]])
+                else:
+                    img = canvas[..., ::-1].astype(np.float32) / 255.0
+                    img = img.transpose(2, 0, 1)
+                    outputs = self.session.run(None, {self.input_name: img[None, ...]})
 
-            for i in range(output.shape[1]):
-                class_probs = output[4:, i]
-                best_class = int(np.argmax(class_probs))
-                confidence = float(class_probs[best_class])
+                if len(outputs) == 9:
+                    dets = self._decode_yolo11_outputs(
+                        outputs, (640, 640), scale, pad_w, pad_h, ch, cw,
+                        conf_threshold=conf_threshold
+                    )
+                else:
+                    # 标准 (84, 8400) 格式
+                    output = outputs[0]
+                    if len(output.shape) == 3:
+                        output = output[0]
+                    dets = []
+                    for i in range(output.shape[1]):
+                        class_probs = output[4:, i]
+                        best_class = int(np.argmax(class_probs))
+                        confidence = float(class_probs[best_class])
+                        if confidence < conf_threshold:
+                            continue
+                        x1 = output[0, i] - output[2, i] / 2
+                        y1 = output[1, i] - output[3, i] / 2
+                        x2 = output[0, i] + output[2, i] / 2
+                        y2 = output[1, i] + output[3, i] / 2
+                        dets.append([x1, y1, x2, y2, confidence, best_class])
 
-                if confidence < conf_threshold:
-                    continue
+                    # 从 canvas 坐标映射回 crop 坐标
+                    dets = np.array(dets) if dets else np.zeros((0, 6))
+                    dets[:, [0, 2]] = (dets[:, [0, 2]] - pad_w) / scale
+                    dets[:, [1, 3]] = (dets[:, [1, 3]] - pad_h) / scale
+                    dets = dets.tolist()
 
-                cls_name = class_map.get(best_class, str(best_class))
+            for x1, y1, x2, y2, confidence, best_class in dets:
+                cls_name = class_map.get(int(best_class), str(int(best_class)))
                 if cls_name not in self.config['target_classes']:
                     continue
 
-                xc = float(output[0, i])
-                yc = float(output[1, i])
-                bw = float(output[2, i])
-                bh = float(output[3, i])
-
                 # 转换到原图坐标(加上区域偏移)
-                x1 = int(((xc - bw / 2) - pad_w) / scale) + x_start
-                y1 = int(((yc - bh / 2) - pad_h) / scale)
-                x2 = int(((xc + bw / 2) - pad_w) / scale) + x_start
-                y2 = int(((yc + bh / 2) - pad_h) / scale)
+                x1 = int(x1) + x_start
+                y1 = int(y1)
+                x2 = int(x2) + x_start
+                y2 = int(y2)
 
                 x1 = max(0, min(w0, x1))
                 y1 = max(0, min(h0, y1))
@@ -790,7 +1078,7 @@ class ObjectDetector:
 
                 obj = DetectedObject(
                     class_name=cls_name,
-                    confidence=confidence,
+                    confidence=float(confidence),
                     bbox=(x1, y1, x2 - x1, y2 - y1),
                     center=(cx, cy)
                 )

+ 14 - 2
dual_camera_system/ptz_camera.py

@@ -42,7 +42,15 @@ class PTZCamera:
         """
         self.sdk = sdk
         self.config = camera_config or PTZ_CAMERA
-        self.ptz_config = PTZ_CONFIG
+        # 全局 PTZ 配置作为默认值,camera_config 中的同名 key 可覆盖
+        self.ptz_config = dict(PTZ_CONFIG)
+        if camera_config:
+            for key in ['mount_type', 'pan_flip', 'tilt_flip', 'coordinate_offset',
+                        'tilt_offset', 'pan_offset', 'pan_edge_offset', 'pan_curve_power',
+                        'tilt_linear_enabled', 'tilt_y0', 'tilt_y1', 'tilt_curve_power',
+                        'pan_range', 'tilt_range', 'pan_center', 'tilt_center']:
+                if key in camera_config:
+                    self.ptz_config[key] = camera_config[key]
         
         self.login_handle = None
         self.connected = False
@@ -93,7 +101,11 @@ class PTZCamera:
             self.sdk.logout(self.login_handle)
             self.login_handle = None
         self.connected = False
-    
+
+    def is_connected(self) -> bool:
+        """是否已连接"""
+        return self.connected
+
     def start_stream_rtsp(self, rtsp_url: str = None) -> bool:
         """启动RTSP视频流 (用于校准时获取球机画面)"""
         if rtsp_url is None:

+ 0 - 463
dual_camera_system/safety_coordinator.py

@@ -1,463 +0,0 @@
-"""
-安全联动控制器
-整合安全检测、事件推送功能
-"""
-
-import time
-import threading
-import queue
-from typing import Optional, List, Dict, Tuple, Callable
-from dataclasses import dataclass
-from enum import Enum
-
-import numpy as np
-import cv2
-
-from config import (
-    COORDINATOR_CONFIG,
-    SAFETY_DETECTION_CONFIG,
-    EVENT_PUSHER_CONFIG,
-    EVENT_LISTENER_CONFIG,
-    SYSTEM_CONFIG
-)
-from safety_detector import (
-    SafetyDetector, SafetyDetection, PersonSafetyStatus, 
-    SafetyViolationType, draw_safety_result
-)
-from event_pusher import EventPusher, EventListener, SafetyEvent, EventType
-
-
-class CoordinatorState(Enum):
-    """控制器状态"""
-    IDLE = 0           # 空闲
-    DETECTING = 1      # 检测中
-    TRACKING = 2       # 跟踪中
-    ALERTING = 3       # 告警中
-
-
-@dataclass
-class AlertRecord:
-    """告警记录"""
-    track_id: int                    # 跟踪ID
-    violation_type: str              # 违规类型
-    description: str                 # 描述
-    frame: Optional[np.ndarray]      # 图像
-    timestamp: float                 # 时间戳
-    pushed: bool = False             # 是否已推送
-
-
-class SafetyCoordinator:
-    """安全联动控制器:协调摄像头、安全检测、事件推送、PTZ跟踪"""
-    
-    def __init__(self, camera, config: Dict = None, ptz_camera=None, calibrator=None):
-        self.camera = camera
-        self.config = config or {}
-        self.ptz = ptz_camera       # PTZ球机(可选)
-        self.calibrator = calibrator  # 校准器(可选)
-        
-        self.detector = None
-        self.event_pusher = None
-        self.event_listener = None
-        
-        self.state = CoordinatorState.IDLE
-        self.state_lock = threading.Lock()
-        
-        self.running = False
-        self.worker_thread = None
-        
-        self.alert_records: List[AlertRecord] = []
-        self.alert_cooldown = {}
-        
-        # 告警冷却时间(按违规类型)
-        self._violation_cooldown = {}
-        
-        self.stats = {
-            'frames_processed': 0,
-            'persons_detected': 0,
-            'violations_detected': 0,
-            'events_pushed': 0,
-            'ptz_commands_sent': 0,
-            'start_time': None
-        }
-        self.stats_lock = threading.Lock()
-        
-        self.on_violation_detected: Optional[Callable] = None
-        self.on_frame_processed: Optional[Callable] = None
-        
-        self._init_components()
-    
-    def _init_components(self):
-        """初始化各组件"""
-        # 从 SYSTEM_CONFIG 读取功能开关
-        enable_detection = SYSTEM_CONFIG.get('enable_detection', True)
-        enable_safety_detection = SYSTEM_CONFIG.get('enable_safety_detection', True)
-        enable_event_push = SYSTEM_CONFIG.get('enable_event_push', True)
-
-        # 安全检测器
-        if enable_detection and enable_safety_detection:
-            try:
-                self.detector = SafetyDetector(
-                    model_path=SAFETY_DETECTION_CONFIG.get('model_path'),
-                    use_gpu=SAFETY_DETECTION_CONFIG.get('use_gpu', True),
-                    conf_threshold=SAFETY_DETECTION_CONFIG.get('conf_threshold', 0.5),
-                    person_threshold=SAFETY_DETECTION_CONFIG.get('person_threshold', 0.8)
-                )
-                print("安全检测器初始化成功")
-            except Exception as e:
-                print(f"安全检测器初始化失败: {e}")
-        else:
-            print("安全检测功能已禁用")
-
-        # 事件推送器
-        if enable_event_push:
-            try:
-                self.event_pusher = EventPusher(EVENT_PUSHER_CONFIG)
-                print("事件推送器初始化成功")
-            except Exception as e:
-                print(f"事件推送器初始化失败: {e}")
-        else:
-            print("事件推送功能已禁用")
-
-        # 事件监听器
-        if EVENT_LISTENER_CONFIG.get('enabled', True):
-            try:
-                self.event_listener = EventListener(EVENT_LISTENER_CONFIG)
-                print("事件监听器初始化成功")
-            except Exception as e:
-                print(f"事件监听器初始化失败: {e}")
-    
-    def start(self) -> bool:
-        """启动控制器"""
-        if self.running:
-            return True
-        
-        if self.event_pusher:
-            self.event_pusher.start()
-
-        if self.event_listener:
-            self.event_listener.start()
-        
-        self.running = True
-        self.worker_thread = threading.Thread(target=self._worker, daemon=True)
-        self.worker_thread.start()
-        
-        # PTZ跟踪已禁用
-        
-        with self.stats_lock:
-            self.stats['start_time'] = time.time()
-        
-        print("安全联动控制器已启动")
-        return True
-    
-    def stop(self):
-        """停止控制器"""
-        self.running = False
-        
-        if self.worker_thread:
-            self.worker_thread.join(timeout=3)
-        
-        # PTZ跟踪已禁用
-        
-        if self.event_pusher:
-            self.event_pusher.stop()
-
-        if self.event_listener:
-            self.event_listener.stop()
-        
-        self._print_stats()
-        print("安全联动控制器已停止")
-    
-    def _worker(self):
-        """工作线程"""
-        # 优先使用 detection_fps,默认每秒2帧
-        detection_fps = SAFETY_DETECTION_CONFIG.get('detection_fps', 2)
-        detection_interval = 1.0 / detection_fps  # 根据FPS计算间隔
-        last_detection_time = 0
-        detection_run_count = 0
-        detection_violation_count = 0
-        frame_count = 0
-        last_log_time = time.time()
-        heartbeat_interval = 30.0
-        last_no_detect_log_time = 0
-        
-        import logging
-        sc_logger = logging.getLogger(__name__)
-        
-        if self.detector is None:
-            sc_logger.warning("[安全检测] ⚠️ 安全检测器未初始化! 安全检测不可用")
-        else:
-            sc_logger.info(f"[安全检测] ✓ 安全检测器已就绪, 检测帧率={detection_fps}fps(间隔={detection_interval:.2f}s)")
-        
-        while self.running:
-            try:
-                current_time = time.time()
-                
-                frame = self.camera.get_frame() if self.camera else None
-                if frame is None:
-                    time.sleep(0.01)
-                    continue
-                
-                frame_count += 1
-                self._update_stats('frames_processed')
-                
-                if current_time - last_log_time >= heartbeat_interval:
-                    stats = self.get_stats()
-                    state_str = self.state.name if hasattr(self.state, 'name') else str(self.state)
-                    sc_logger.info(
-                        f"[安全检测] 状态={state_str}, "
-                        f"检测轮次={detection_run_count}(有人={detection_violation_count}), "
-                        f"帧数={frame_count}"
-                    )
-                    frame_count = 0
-                    last_log_time = current_time
-                
-                if current_time - last_detection_time >= detection_interval:
-                    last_detection_time = current_time
-                    detection_run_count += 1
-                    
-                    result = self._process_frame_with_logging(frame, detection_run_count, detection_violation_count, last_no_detect_log_time, sc_logger)
-                    detection_violation_count = result
-                
-                time.sleep(0.01)
-                
-            except Exception as e:
-                sc_logger.error(f"[安全检测] 处理错误: {e}")
-                time.sleep(0.1)
-    
-    def _process_frame_with_logging(self, frame: np.ndarray, run_count: int, violation_count: int, last_no_detect_time: float, sc_logger) -> int:
-        """处理帧并返回更新的violation_count"""
-        if self.detector is None:
-            return violation_count
-        
-        self._set_state(CoordinatorState.DETECTING)
-        
-        detections = self.detector.detect(frame)
-        status_list = self.detector.check_safety(frame, detections)
-        
-        self._update_stats('persons_detected', len(status_list))
-        # 轨迹追踪已禁用
-        
-        has_violation = False
-        for status in status_list:
-            if status.is_violation:
-                self._handle_violation(status, frame)
-                has_violation = True
-        
-        if has_violation:
-            violation_count += 1
-        
-        if not status_list:
-            current_time = time.time()
-            if current_time - last_no_detect_time >= 30.0:
-                sc_logger.info(
-                    f"[安全检测] · YOLO检测运行正常, 本轮未检测到人员 "
-                    f"(累计检测{run_count}轮, 违规{violation_count}轮)"
-                )
-        
-        if self.on_frame_processed:
-            self.on_frame_processed(frame, detections, status_list)
-        
-        return violation_count
-    
-    def _process_frame(self, frame: np.ndarray):
-        """处理帧"""
-        if self.detector is None:
-            return
-        
-        self._set_state(CoordinatorState.DETECTING)
-        
-        # 安全检测
-        detections = self.detector.detect(frame)
-        status_list = self.detector.check_safety(frame, detections)
-        
-        self._update_stats('persons_detected', len(status_list))
-        
-        # 检查违规(轨迹追踪已禁用)
-        for status in status_list:
-            if status.is_violation:
-                self._handle_violation(status, frame)
-        
-        # 回调
-        if self.on_frame_processed:
-            self.on_frame_processed(frame, detections, status_list)
-    
-    # 轨迹追踪已禁用 - _update_tracks 和 _cleanup_tracks 方法已移除
-    
-    def _handle_violation(self, status: PersonSafetyStatus, frame: np.ndarray):
-        """处理违规"""
-        current_time = time.time()
-        
-        # 检查冷却时间(按违规类型)
-        violation_key = status.get_violation_desc()
-        cooldown = SAFETY_DETECTION_CONFIG.get('alert_cooldown', 3.0)
-        if violation_key in self.alert_cooldown:
-            if current_time - self.alert_cooldown[violation_key] < cooldown:
-                return
-        
-        # 记录告警
-        self.alert_cooldown[violation_key] = current_time
-        
-        description = status.get_violation_desc()
-        violation_type = status.violation_types[0].value if status.violation_types else "未知"
-        
-        # 裁剪人体区域
-        x1, y1, x2, y2 = status.person_bbox
-        margin = 20
-        x1 = max(0, x1 - margin)
-        y1 = max(0, y1 - margin)
-        x2 = min(frame.shape[1], x2 + margin)
-        y2 = min(frame.shape[0], y2 + margin)
-        person_image = frame[y1:y2, x1:x2].copy()
-        
-        record = AlertRecord(
-            track_id=0,  # 轨迹追踪已禁用
-            violation_type=violation_type,
-            description=description,
-            frame=person_image,
-            timestamp=current_time
-        )
-        
-        self.alert_records.append(record)
-        self._update_stats('violations_detected')
-        
-        # PTZ跟踪已禁用
-        
-        # 回调
-        if self.on_violation_detected:
-            self.on_violation_detected(status, frame)
-        
-        # 推送事件
-        if self.event_pusher:
-            self.event_pusher.push_safety_violation(
-                description=description,
-                image=person_image,
-                track_id=0,  # 轨迹追踪已禁用
-                confidence=status.person_conf
-            )
-            self._update_stats('events_pushed')
-
-        print(f"[告警] {description}")
-    
-    # PTZ跟踪已禁用 - _track_violator_ptz 和 _ptz_worker 方法已移除
-    
-    def _set_state(self, state: CoordinatorState):
-        """设置状态"""
-        with self.state_lock:
-            self.state = state
-    
-    def get_state(self) -> CoordinatorState:
-        """获取状态"""
-        with self.state_lock:
-            return self.state
-    
-    def _update_stats(self, key: str, value: int = 1):
-        """更新统计"""
-        with self.stats_lock:
-            if key in self.stats:
-                self.stats[key] += value
-    
-    def _print_stats(self):
-        """打印统计"""
-        with self.stats_lock:
-            if self.stats['start_time']:
-                elapsed = time.time() - self.stats['start_time']
-                print("\n=== 安全检测统计 ===")
-                print(f"运行时长: {elapsed:.1f}秒")
-                print(f"处理帧数: {self.stats['frames_processed']}")
-                print(f"检测人员: {self.stats['persons_detected']}次")
-                print(f"违规检测: {self.stats['violations_detected']}次")
-                print(f"事件推送: {self.stats['events_pushed']}次")
-
-                if self.event_pusher:
-                    push_stats = self.event_pusher.get_stats()
-                    print(f"推送详情: 成功{push_stats['pushed_events']}, 失败{push_stats['failed_events']}")
-
-                print("===================\n")
-    
-    def get_stats(self) -> Dict:
-        """获取统计"""
-        with self.stats_lock:
-            return self.stats.copy()
-    
-    def get_alerts(self) -> List[AlertRecord]:
-        """获取告警记录"""
-        return self.alert_records.copy()
-    
-    def force_detect(self, frame: np.ndarray = None) -> Tuple[List[SafetyDetection], List[PersonSafetyStatus]]:
-        """
-        强制执行一次检测
-        
-        Args:
-            frame: 输入帧,如果为 None 则从摄像头获取
-            
-        Returns:
-            (检测结果, 安全状态列表)
-        """
-        if frame is None:
-            frame = self.camera.get_frame() if self.camera else None
-        
-        if frame is None or self.detector is None:
-            return [], []
-        
-        detections = self.detector.detect(frame)
-        status_list = self.detector.check_safety(frame, detections)
-        
-        return detections, status_list
-
-
-class SimpleCamera:
-    """简单摄像头封装(用于测试)"""
-    
-    def __init__(self, source=0):
-        """
-        初始化摄像头
-        
-        Args:
-            source: 视频源 (摄像头索引、RTSP地址、视频文件路径)
-        """
-        self.cap = None
-        self.source = source
-        self.connected = False
-    
-    def connect(self) -> bool:
-        """连接摄像头"""
-        try:
-            # 使用 FFmpeg 单线程模式避免线程安全崩溃
-            import os
-            os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'threads;1'
-            self.cap = cv2.VideoCapture(self.source, cv2.CAP_FFMPEG)
-            self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
-            self.connected = self.cap.isOpened()
-            return self.connected
-        except Exception as e:
-            print(f"连接摄像头失败: {e}")
-            return False
-    
-    def disconnect(self):
-        """断开连接"""
-        if self.cap:
-            self.cap.release()
-        self.connected = False
-    
-    def get_frame(self) -> Optional[np.ndarray]:
-        """获取帧"""
-        if self.cap is None or not self.cap.isOpened():
-            return None
-        
-        ret, frame = self.cap.read()
-        return frame if ret else None
-
-
-def create_coordinator(camera_source=0, config: Dict = None) -> SafetyCoordinator:
-    """
-    创建安全联动控制器
-    
-    Args:
-        camera_source: 摄像头源
-        config: 配置
-        
-    Returns:
-        SafetyCoordinator 实例
-    """
-    camera = SimpleCamera(camera_source)
-    return SafetyCoordinator(camera, config)

+ 0 - 816
dual_camera_system/safety_detector.py

@@ -1,816 +0,0 @@
-"""
-施工现场安全行为检测模块
-使用 YOLO11 模型检测人员、安全帽、反光衣
-判断是否存在违规行为(未戴安全帽、未穿反光衣)
-
-支持两种模型格式:
-- YOLO (.pt/.onnx): 使用 ultralytics 库
-- RKNN (.rknn): 使用 rknnlite 库 (RK3588 平台)
-"""
-
-import cv2
-import numpy as np
-from typing import Optional, List, Tuple, Dict, Any
-from dataclasses import dataclass
-from enum import Enum
-import os
-
-
-# ============================================
-# RKNN 模型支持
-# ============================================
-
-@dataclass
-class Detection:
-    """检测结果 (用于 RKNN 模型)"""
-    class_id: int
-    class_name: str
-    confidence: float
-    bbox: Tuple[int, int, int, int]
-
-
-def nms(dets, iou_threshold=0.45):
-    """非极大值抑制"""
-    if len(dets) == 0:
-        return []
-    
-    boxes = np.array([[d.bbox[0], d.bbox[1], d.bbox[2], d.bbox[3], d.confidence] for d in dets])
-    x1 = boxes[:, 0]
-    y1 = boxes[:, 1]
-    x2 = boxes[:, 2]
-    y2 = boxes[:, 3]
-    scores = boxes[:, 4]
-    
-    areas = (x2 - x1 + 1) * (y2 - y1 + 1)
-    order = scores.argsort()[::-1]
-    
-    keep = []
-    while order.size > 0:
-        i = order[0]
-        keep.append(i)
-        
-        xx1 = np.maximum(x1[i], x1[order[1:]])
-        yy1 = np.maximum(y1[i], y1[order[1:]])
-        xx2 = np.minimum(x2[i], x2[order[1:]])
-        yy2 = np.minimum(y2[i], y2[order[1:]])
-        
-        w = np.maximum(0.0, xx2 - xx1 + 1)
-        h = np.maximum(0.0, yy2 - yy1 + 1)
-        inter = w * h
-        ovr = inter / (areas[i] + areas[order[1:]] - inter)
-        
-        inds = np.where(ovr <= iou_threshold)[0]
-        order = order[inds + 1]
-    
-    return [dets[i] for i in keep]
-
-
-class BaseDetector:
-    """检测器基类 (用于 RKNN/ONNX 模型)"""
-    
-    # 类别映射: 0: 安全帽, 3: 人, 4: 反光衣
-    LABEL_MAP = {0: '安全帽', 4: '安全衣', 3: '人'}
-    
-    def __init__(self):
-        self.input_size = (640, 640)
-        self.num_classes = 5
-    
-    def letterbox(self, image):
-        """Letterbox 预处理,保持宽高比"""
-        h0, w0 = image.shape[:2]
-        ih, iw = self.input_size
-        scale = min(iw / w0, ih / h0)
-        new_w, new_h = int(w0 * scale), int(h0 * scale)
-        pad_w = (iw - new_w) // 2
-        pad_h = (ih - new_h) // 2
-        resized = cv2.resize(image, (new_w, new_h))
-        canvas = np.full((ih, iw, 3), 114, dtype=np.uint8)
-        canvas[pad_h:pad_h+new_h, pad_w:pad_w+new_w] = resized
-        return canvas, scale, pad_w, pad_h, h0, w0
-    
-    def postprocess(self, outputs, scale, pad_w, pad_h, h0, w0, conf_threshold_map):
-        """后处理"""
-        dets = []
-        
-        if not outputs:
-            return dets
-        
-        output = outputs[0]
-        
-        if len(output.shape) == 3:
-            output = output[0]
-        
-        num_boxes = output.shape[1]
-        
-        for i in range(num_boxes):
-            x_center = float(output[0, i])
-            y_center = float(output[1, i])
-            width = float(output[2, i])
-            height = float(output[3, i])
-            
-            class_probs = output[4:4+self.num_classes, i]
-            best_class = int(np.argmax(class_probs))
-            confidence = float(class_probs[best_class])
-            
-            if best_class not in self.LABEL_MAP:
-                continue
-            
-            conf_threshold = conf_threshold_map.get(best_class, 0.5)
-            
-            if confidence < conf_threshold:
-                continue
-            
-            # 移除 padding 并缩放到原始图像尺寸
-            x1 = int(((x_center - width / 2) - pad_w) / scale)
-            y1 = int(((y_center - height / 2) - pad_h) / scale)
-            x2 = int(((x_center + width / 2) - pad_w) / scale)
-            y2 = int(((y_center + height / 2) - pad_h) / scale)
-            
-            x1 = max(0, min(w0, x1))
-            y1 = max(0, min(h0, y1))
-            x2 = max(0, min(w0, x2))
-            y2 = max(0, min(h0, y2))
-            
-            det = Detection(
-                class_id=best_class,
-                class_name=self.LABEL_MAP[best_class],
-                confidence=confidence,
-                bbox=(x1, y1, x2, y2)
-            )
-            dets.append(det)
-        
-        dets = nms(dets, iou_threshold=0.45)
-        return dets
-    
-    def detect(self, image, conf_threshold_map):
-        raise NotImplementedError
-    
-    def release(self):
-        pass
-
-
-class RKNNDetector(BaseDetector):
-    """RKNN 检测器 - 使用 NHWC 输入格式 (1, H, W, C)"""
-    
-    def __init__(self, model_path: str):
-        super().__init__()
-        self.model_path = model_path
-        self.rknn = None
-        
-        try:
-            from rknnlite.api import RKNNLite
-            self.rknn = RKNNLite()
-        except ImportError:
-            raise ImportError("未安装 rknnlite,请运行: pip install rknnlite2 或参考 testrk3588/setup_rknn.sh")
-        
-        ret = self.rknn.load_rknn(model_path)
-        if ret != 0:
-            raise RuntimeError(f"加载 RKNN 模型失败: {model_path}")
-        
-        ret = self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2)
-        if ret != 0:
-            raise RuntimeError(f"初始化 RKNN 运行时失败")
-        
-        print(f"RKNN 模型加载成功: {model_path}")
-    
-    def detect(self, image, conf_threshold_map):
-        canvas, scale, pad_w, pad_h, h0, w0 = self.letterbox(image)
-        # RKNN 期望 NHWC (1, H, W, C), RGB, 归一化 0-1
-        img = canvas[..., ::-1].astype(np.float32) / 255.0
-        blob = img[None, ...]  # (1, 640, 640, 3)
-        outs = self.rknn.inference(inputs=[blob])
-        return self.postprocess(outs, scale, pad_w, pad_h, h0, w0, conf_threshold_map)
-    
-    def release(self):
-        if self.rknn:
-            self.rknn.release()
-            self.rknn = None
-
-
-class ONNXDetector(BaseDetector):
-    """ONNX 检测器 - 使用 NCHW 输入格式 (1, C, H, W)"""
-    
-    def __init__(self, model_path: str):
-        super().__init__()
-        self.model_path = model_path
-        
-        try:
-            import onnxruntime as ort
-            self.session = ort.InferenceSession(model_path)
-            self.input_name = self.session.get_inputs()[0].name
-            self.output_name = self.session.get_outputs()[0].name
-            print(f"ONNX 模型加载成功: {model_path}")
-        except ImportError:
-            raise ImportError("未安装 onnxruntime,请运行: pip install onnxruntime")
-        except Exception as e:
-            raise RuntimeError(f"加载 ONNX 模型失败: {e}")
-    
-    def detect(self, image, conf_threshold_map):
-        canvas, scale, pad_w, pad_h, h0, w0 = self.letterbox(image)
-        # ONNX 期望 NCHW (1, C, H, W), RGB, 归一化 0-1
-        img = canvas[..., ::-1].astype(np.float32) / 255.0
-        img = img.transpose(2, 0, 1)
-        blob = img[None, ...]  # (1, 3, 640, 640)
-        outs = self.session.run([self.output_name], {self.input_name: blob})
-        return self.postprocess(outs, scale, pad_w, pad_h, h0, w0, conf_threshold_map)
-    
-    def release(self):
-        self.session = None
-
-
-def create_detector(model_path: str):
-    """
-    创建检测器工厂函数
-    
-    Args:
-        model_path: 模型路径 (.rknn, .onnx, .pt)
-        
-    Returns:
-        检测器实例
-    """
-    ext = os.path.splitext(model_path)[1].lower()
-    
-    if ext == '.rknn':
-        print(f"使用 RKNN 模型: {model_path}")
-        return RKNNDetector(model_path)
-    elif ext == '.onnx':
-        print(f"使用 ONNX 模型: {model_path}")
-        return ONNXDetector(model_path)
-    elif ext == '.pt':
-        print(f"使用 YOLO 模型: {model_path}")
-        return None  # YOLO 使用原来的 SafetyDetector
-    else:
-        raise ValueError(f"不支持的模型格式: {ext}")
-
-
-# ============================================
-# 原有 YOLO 安全检测器
-# ============================================
-
-
-class SafetyViolationType(Enum):
-    """安全违规类型"""
-    NO_HELMET = "未戴安全帽"           # 未戴安全帽
-    NO_SAFETY_VEST = "未穿反光衣"      # 未穿反光衣
-    NO_BOTH = "反光衣和安全帽都没戴"   # 都没有
-
-
-@dataclass
-class SafetyDetection:
-    """安全检测结果"""
-    # 基础信息
-    class_id: int                        # 类别ID
-    class_name: str                      # 类别名称
-    confidence: float                    # 置信度
-    bbox: Tuple[int, int, int, int]      # 边界框 (x1, y1, x2, y2)
-    center: Tuple[int, int]              # 中心点坐标
-    track_id: Optional[int] = None       # 跟踪ID
-
-
-@dataclass
-class PersonSafetyStatus:
-    """人员安全状态"""
-    track_id: int                              # 跟踪ID
-    person_bbox: Tuple[int, int, int, int]     # 人体边界框
-    person_conf: float                         # 人体置信度
-    has_helmet: bool = False                   # 是否戴安全帽
-    helmet_conf: float = 0.0                   # 安全帽置信度
-    has_safety_vest: bool = False              # 是否穿反光衣
-    vest_conf: float = 0.0                     # 反光衣置信度
-    is_violation: bool = False                 # 是否违规
-    violation_types: List[SafetyViolationType] = None  # 违规类型列表
-    
-    def __post_init__(self):
-        if self.violation_types is None:
-            self.violation_types = []
-    
-    def check_violation(self) -> bool:
-        """检查是否违规"""
-        self.violation_types = []
-        
-        if not self.has_helmet and not self.has_safety_vest:
-            self.violation_types.append(SafetyViolationType.NO_BOTH)
-        elif not self.has_helmet:
-            self.violation_types.append(SafetyViolationType.NO_HELMET)
-        elif not self.has_safety_vest:
-            self.violation_types.append(SafetyViolationType.NO_SAFETY_VEST)
-        
-        self.is_violation = len(self.violation_types) > 0
-        return self.is_violation
-    
-    def get_violation_desc(self) -> str:
-        """获取违规描述"""
-        if not self.is_violation:
-            return ""
-        
-        if SafetyViolationType.NO_BOTH in self.violation_types:
-            return "反光衣和安全帽都没戴"
-        elif SafetyViolationType.NO_HELMET in self.violation_types:
-            return "未戴安全帽"
-        elif SafetyViolationType.NO_SAFETY_VEST in self.violation_types:
-            return "未穿反光衣"
-        return ""
-
-
-class SafetyDetector:
-    """
-    施工现场安全检测器
-    使用 YOLO11 检测人员、安全帽、反光衣
-    """
-    
-    CLASS_MAP = {
-        0: '安全帽',
-        3: '人',
-        4: '反光衣'
-    }
-    
-    CLASS_ID_MAP = {
-        'helmet': 0,
-        'person': 3,
-        'safety_vest': 4
-    }
-    
-    def __init__(self, model_path: str = None, use_gpu: bool = True, 
-                 conf_threshold: float = 0.5, person_threshold: float = 0.8,
-                 model_type: str = 'auto'):
-        """
-        初始化安全检测器
-        
-        Args:
-            model_path: 模型路径,默认使用 yolo11m_safety.pt 或 .rknn
-            use_gpu: 是否使用 GPU (仅 YOLO 模型有效)
-            conf_threshold: 一般物品置信度阈值 (安全帽、反光衣)
-            person_threshold: 人员检测置信度阈值
-            model_type: 模型类型 ('auto', 'yolo', 'rknn', 'onnx')
-        """
-        self.model = None
-        self.rknn_detector = None
-        self.model_type = model_type
-        
-        # 根据扩展名自动判断模型类型
-        if model_path:
-            ext = os.path.splitext(model_path)[1].lower()
-            if ext == '.rknn':
-                self.model_type = 'rknn'
-            elif ext == '.onnx':
-                self.model_type = 'onnx'
-            elif ext == '.pt':
-                self.model_type = 'yolo'
-        
-        self.model_path = model_path
-        self.use_gpu = use_gpu
-        self.device = 'cuda:0' if use_gpu else 'cpu'
-        
-        self.conf_threshold = conf_threshold
-        self.person_threshold = person_threshold
-        
-        self._load_model()
-    
-    def _load_model(self):
-        """加载检测模型"""
-        if self.model_type == 'rknn':
-            self._load_rknn_model()
-        elif self.model_type == 'onnx':
-            self._load_onnx_model()
-        else:
-            self._load_yolo_model()
-    
-    def _load_rknn_model(self):
-        """加载 RKNN 模型"""
-        if not self.model_path:
-            raise ValueError("RKNN 模型需要指定 model_path")
-        
-        try:
-            self.rknn_detector = RKNNDetector(self.model_path)
-            print(f"RKNN 安全检测模型加载成功: {self.model_path}")
-        except ImportError as e:
-            raise ImportError(f"rknnlite 未安装: {e}")
-        except Exception as e:
-            raise RuntimeError(f"加载 RKNN 模型失败: {e}")
-    
-    def _load_onnx_model(self):
-        """加载 ONNX 模型"""
-        if not self.model_path:
-            raise ValueError("ONNX 模型需要指定 model_path")
-        
-        try:
-            self.rknn_detector = ONNXDetector(self.model_path)
-            print(f"ONNX 安全检测模型加载成功: {self.model_path}")
-        except ImportError as e:
-            raise ImportError(f"onnxruntime 未安装: {e}")
-        except Exception as e:
-            raise RuntimeError(f"加载 ONNX 模型失败: {e}")
-    
-    def _load_yolo_model(self):
-        """加载 YOLO11 安全检测模型"""
-        try:
-            from ultralytics import YOLO
-            
-            if not self.model_path:
-                self.model_path = '/home/wen/dsh/yolo/yolo11m_safety.pt'
-            
-            self.model = YOLO(self.model_path)
-            
-            dummy = np.zeros((640, 640, 3), dtype=np.uint8)
-            self.model(dummy, device=self.device, verbose=False)
-            
-            print(f"YOLO 安全检测模型加载成功: {self.model_path} (device={self.device})")
-        except ImportError:
-            raise ImportError("未安装 ultralytics,请运行: pip install ultralytics")
-        except Exception as e:
-            raise RuntimeError(f"加载 YOLO 模型失败: {e}")
-    
-    def detect(self, frame: np.ndarray) -> List[SafetyDetection]:
-        """
-        检测画面中的安全相关对象
-        
-        Args:
-            frame: 输入图像
-            
-        Returns:
-            检测结果列表
-        """
-        if frame is None:
-            return []
-        
-        if self.rknn_detector is not None:
-            return self._detect_rknn(frame)
-        else:
-            return self._detect_yolo(frame)
-    
-    def _detect_rknn(self, frame: np.ndarray) -> List[SafetyDetection]:
-        """使用 RKNN/ONNX 模型检测"""
-        results = []
-        
-        try:
-            conf_threshold_map = {
-                3: self.person_threshold,
-                0: self.conf_threshold,
-                4: self.conf_threshold
-            }
-            
-            detections = self.rknn_detector.detect(frame, conf_threshold_map)
-            
-            for det in detections:
-                x1, y1, x2, y2 = det.bbox
-                center_x = (x1 + x2) // 2
-                center_y = (y1 + y2) // 2
-                
-                safety_det = SafetyDetection(
-                    class_id=det.class_id,
-                    class_name=det.class_name,
-                    confidence=det.confidence,
-                    bbox=det.bbox,
-                    center=(center_x, center_y)
-                )
-                results.append(safety_det)
-                
-        except Exception as e:
-            print(f"RKNN 检测错误: {e}")
-        
-        return results
-    
-    def _detect_yolo(self, frame: np.ndarray) -> List[SafetyDetection]:
-        """使用 YOLO 模型检测"""
-        results = []
-        
-        try:
-            detections = self.model(frame, device=self.device, verbose=False)
-            
-            for det in detections:
-                boxes = det.boxes
-                if boxes is None:
-                    continue
-                
-                for i in range(len(boxes)):
-                    cls_id = int(boxes.cls[i])
-                    
-                    if cls_id not in self.CLASS_MAP:
-                        continue
-                    
-                    cls_name = self.CLASS_MAP[cls_id]
-                    conf = float(boxes.conf[i])
-                    
-                    threshold = self.person_threshold if cls_id == 3 else self.conf_threshold
-                    if conf < threshold:
-                        continue
-                    
-                    xyxy = boxes.xyxy[i].cpu().numpy()
-                    x1, y1, x2, y2 = map(int, xyxy)
-                    
-                    width = x2 - x1
-                    height = y2 - y1
-                    if width < 10 or height < 10:
-                        continue
-                    
-                    center_x = (x1 + x2) // 2
-                    center_y = (y1 + y2) // 2
-                    
-                    detection = SafetyDetection(
-                        class_id=cls_id,
-                        class_name=cls_name,
-                        confidence=conf,
-                        bbox=(x1, y1, x2, y2),
-                        center=(center_x, center_y)
-                    )
-                    results.append(detection)
-                    
-        except Exception as e:
-            print(f"YOLO 检测错误: {e}")
-        
-        return results
-    
-    def release(self):
-        """释放模型资源"""
-        if self.rknn_detector:
-            self.rknn_detector.release()
-            self.rknn_detector = None
-        self.model = None
-    
-    def check_safety(self, frame: np.ndarray, 
-                     detections: List[SafetyDetection] = None) -> List[PersonSafetyStatus]:
-        """
-        检查人员安全状态
-        
-        Args:
-            frame: 输入图像
-            detections: 检测结果,如果为 None 则自动检测
-            
-        Returns:
-            人员安全状态列表
-        """
-        if detections is None:
-            detections = self.detect(frame)
-        
-        # 分类检测结果
-        persons = []
-        helmets = []
-        vests = []
-        
-        for det in detections:
-            if det.class_id == 3:  # 人
-                persons.append(det)
-            elif det.class_id == 0:  # 安全帽
-                helmets.append(det)
-            elif det.class_id == 4:  # 反光衣
-                vests.append(det)
-        
-        # 检查每个人员的安全状态
-        results = []
-        
-        for person in persons:
-            status = PersonSafetyStatus(
-                track_id=person.track_id or 0,
-                person_bbox=person.bbox,
-                person_conf=person.confidence
-            )
-            
-            px1, py1, px2, py2 = person.bbox
-            
-            # 检查是否戴安全帽
-            # 安全帽应该在人体上方区域(头部附近)
-            for helmet in helmets:
-                hx1, hy1, hx2, hy2 = helmet.bbox
-                # 检查安全帽是否在人体框内
-                helmet_center_x = (hx1 + hx2) / 2
-                helmet_center_y = (hy1 + hy2) / 2
-                
-                # 安全帽中心在人体框内,且在人体上半部分
-                if (hx1 >= px1 and hx2 <= px2 and 
-                    helmet_center_y >= py1 and 
-                    helmet_center_y <= py1 + (py2 - py1) * 0.5):
-                    status.has_helmet = True
-                    status.helmet_conf = helmet.confidence
-                    break
-            
-            # 检查是否穿反光衣
-            # 反光衣应该与人体有重叠
-            for vest in vests:
-                vx1, vy1, vx2, vy2 = vest.bbox
-                
-                # 计算重叠区域
-                overlap_x1 = max(px1, vx1)
-                overlap_y1 = max(py1, vy1)
-                overlap_x2 = min(px2, vx2)
-                overlap_y2 = min(py2, vy2)
-                
-                # 如果有重叠
-                if overlap_x1 < overlap_x2 and overlap_y1 < overlap_y2:
-                    # 计算重叠面积占比
-                    overlap_area = (overlap_x2 - overlap_x1) * (overlap_y2 - overlap_y1)
-                    vest_area = (vx2 - vx1) * (vy2 - vy1)
-                    overlap_ratio = overlap_area / vest_area if vest_area > 0 else 0
-                    
-                    # 重叠比例超过30%认为穿了反光衣
-                    if overlap_ratio > 0.3:
-                        status.has_safety_vest = True
-                        status.vest_conf = vest.confidence
-                        break
-            
-            # 检查是否违规
-            status.check_violation()
-            results.append(status)
-        
-        return results
-    
-    # 轨迹追踪已禁用 - detect_with_tracking 方法已移除
-
-
-def draw_safety_result(frame: np.ndarray, 
-                       detections: List[SafetyDetection],
-                       status_list: List[PersonSafetyStatus]) -> np.ndarray:
-    """
-    在图像上绘制安全检测结果
-    
-    Args:
-        frame: 输入图像
-        detections: 检测结果
-        status_list: 人员安全状态
-        
-    Returns:
-        绘制后的图像
-    """
-    result = frame.copy()
-    
-    # 绘制检测框
-    for det in detections:
-        x1, y1, x2, y2 = det.bbox
-        
-        # 根据类别选择颜色
-        if det.class_id == 3:  # 人
-            color = (0, 255, 0)  # 绿色
-        elif det.class_id == 0:  # 安全帽
-            color = (255, 165, 0)  # 橙色
-        elif det.class_id == 4:  # 反光衣
-            color = (0, 165, 255)  # 黄色
-        else:
-            color = (255, 255, 255)
-        
-        cv2.rectangle(result, (x1, y1), (x2, y2), color, 2)
-        
-        # 绘制标签
-        label = f"{det.class_name}: {det.conf:.2f}"
-        cv2.putText(result, label, (x1, y1 - 5), 
-                   cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
-    
-    # 绘制安全状态
-    for status in status_list:
-        x1, y1, x2, y2 = status.person_bbox
-        
-        if status.is_violation:
-            # 违规 - 红色警告
-            color = (0, 0, 255)
-            text = status.get_violation_desc()
-            cv2.rectangle(result, (x1, y1), (x2, y2), color, 3)
-            cv2.putText(result, text, (x1, y2 + 20), 
-                       cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
-        else:
-            # 正常 - 显示安全标识
-            color = (0, 255, 0)
-            text = "安全装备齐全"
-            cv2.putText(result, text, (x1, y2 + 20), 
-                       cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
-    
-    return result
-
-
-class LLMSafetyDetector:
-    """
-    基于大模型的安全检测器
-    结合 YOLO 检测和大模型判断
-    """
-    
-    def __init__(self, yolo_model_path: str = None, 
-                 llm_config: Dict[str, Any] = None,
-                 use_gpu: bool = True,
-                 use_llm: bool = True,
-                 model_type: str = 'auto'):
-        """
-        初始化检测器
-        
-        Args:
-            yolo_model_path: 模型路径 (.pt, .rknn, .onnx)
-            llm_config: 大模型配置
-            use_gpu: 是否使用 GPU (仅 YOLO 模型有效)
-            use_llm: 是否使用大模型判断
-            model_type: 模型类型 ('auto', 'yolo', 'rknn', 'onnx')
-        """
-        # 安全检测器 (支持 YOLO/RKNN/ONNX)
-        self.yolo_detector = SafetyDetector(
-            model_path=yolo_model_path,
-            use_gpu=use_gpu,
-            model_type=model_type
-        )
-        
-        # 大模型分析器
-        self.use_llm = use_llm
-        self.llm_analyzer = None
-        
-        if use_llm:
-            try:
-                from llm_service import SafetyAnalyzer
-                self.llm_analyzer = SafetyAnalyzer(llm_config)
-                print("大模型安全分析器初始化成功")
-            except ImportError:
-                print("未找到 llm_service 模块,将使用规则判断")
-                self.use_llm = False
-            except Exception as e:
-                print(f"大模型初始化失败: {e},将使用规则判断")
-                self.use_llm = False
-    
-    def detect(self, frame: np.ndarray) -> List[SafetyDetection]:
-        """
-        YOLO 检测
-        
-        Args:
-            frame: 输入图像
-            
-        Returns:
-            检测结果列表
-        """
-        return self.yolo_detector.detect(frame)
-    
-    def check_safety(self, frame: np.ndarray,
-                     detections: List[SafetyDetection] = None,
-                     use_llm: bool = None) -> List[PersonSafetyStatus]:
-        """
-        检查人员安全状态
-        
-        Args:
-            frame: 输入图像
-            detections: YOLO 检测结果
-            use_llm: 是否使用大模型(覆盖默认设置)
-            
-        Returns:
-            人员安全状态列表
-        """
-        # 先用 YOLO 检测
-        if detections is None:
-            detections = self.yolo_detector.detect(frame)
-        
-        # 规则判断
-        rule_status_list = self.yolo_detector.check_safety(frame, detections)
-        
-        # 如果不使用大模型,直接返回规则判断结果
-        should_use_llm = use_llm if use_llm is not None else self.use_llm
-        if not should_use_llm or self.llm_analyzer is None:
-            return rule_status_list
-        
-        # 使用大模型对每个人员进行判断
-        llm_status_list = []
-        
-        for status in rule_status_list:
-            # 裁剪人员区域
-            x1, y1, x2, y2 = status.person_bbox
-            margin = 10
-            x1 = max(0, x1 - margin)
-            y1 = max(0, y1 - margin)
-            x2 = min(frame.shape[1], x2 + margin)
-            y2 = min(frame.shape[0], y2 + margin)
-            
-            person_image = frame[y1:y2, x1:x2]
-            
-            # 调用大模型分析
-            try:
-                llm_result = self.llm_analyzer.check_person_safety(person_image)
-                
-                # 更新状态
-                if llm_result.get('success', False):
-                    status.has_helmet = llm_result.get('has_helmet', False)
-                    status.has_safety_vest = llm_result.get('has_vest', False)
-                    
-                    # 重新检查违规
-                    status.check_violation()
-                    
-                    # 如果大模型判断有违规,使用大模型的描述
-                    if status.is_violation and llm_result.get('violation_desc'):
-                        # 更新违规类型
-                        desc = llm_result.get('violation_desc', '')
-                        if '安全帽' in desc and '反光' in desc:
-                            status.violation_types = [SafetyViolationType.NO_BOTH]
-                        elif '安全帽' in desc:
-                            status.violation_types = [SafetyViolationType.NO_HELMET]
-                        elif '反光' in desc:
-                            status.violation_types = [SafetyViolationType.NO_SAFETY_VEST]
-            except Exception as e:
-                print(f"大模型分析失败: {e}")
-            
-            llm_status_list.append(status)
-        
-        return llm_status_list
-    
-    def recognize_number(self, frame: np.ndarray,
-                         person_bbox: Tuple[int, int, int, int]) -> Dict[str, Any]:
-        """
-        识别人员编号(已禁用)
-
-        Args:
-            frame: 输入图像
-            person_bbox: 人员边界框
-
-        Returns:
-            编号识别结果
-        """
-        return {'number': None, 'success': False}
-    
-    # 轨迹追踪已禁用 - detect_with_tracking 方法已移除

binární
dual_camera_system/scripts/__pycache__/local_test.cpython-310.pyc


+ 147 - 0
dual_camera_system/scripts/local_test.py

@@ -0,0 +1,147 @@
+"""
+本地冒烟测试脚本
+
+无需大华 SDK 即可运行,用途:
+1. 验证 Hikvision 枪机 RTSP 流可连接
+2. 验证 YOLO 人体检测可运行
+3. 验证根据检测坐标计算 PTZ 角度(不实际转动球机)
+
+运行方式:
+    cd dual_camera_system
+    python scripts/local_test.py [--frames 10]
+"""
+
+import os
+import sys
+import argparse
+import time
+
+# 必须在 import cv2 之前
+os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
+
+import cv2
+import numpy as np
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from config import CAMERA_GROUPS, get_enabled_groups, DETECTION_CONFIG
+from config.camera import parse_resolution
+from panorama_camera import ObjectDetector
+from ptz_camera import PTZCamera
+
+
+def open_rtsp(url: str, timeout_sec: float = 10.0):
+    """尝试打开 RTSP 流,等待首帧"""
+    print(f"[RTSP] 正在打开: {url}")
+    cap = cv2.VideoCapture(url, cv2.CAP_FFMPEG)
+    if not cap.isOpened():
+        return None
+
+    deadline = time.time() + timeout_sec
+    while time.time() < deadline:
+        ret, frame = cap.read()
+        if ret and frame is not None:
+            return cap
+        time.sleep(0.2)
+
+    cap.release()
+    return None
+
+
+def main():
+    parser = argparse.ArgumentParser(description='本地冒烟测试(无需大华 SDK)')
+    parser.add_argument('--frames', type=int, default=10, help='读取并检测的帧数')
+    parser.add_argument('--show', action='store_true', help='显示检测结果窗口')
+    args = parser.parse_args()
+
+    groups = get_enabled_groups()
+    if not groups:
+        print('[错误] 没有启用的摄像头组,请检查 config/camera.py')
+        return 1
+
+    group = groups[0]
+    panorama_cfg = group['panorama']
+    ptz_cfg = group['ptz']
+
+    expected_w, expected_h = parse_resolution(panorama_cfg.get('resolution'))
+    print(f"[配置] 组: {group.get('name', group['group_id'])}")
+    print(f"[配置] 枪机 IP: {panorama_cfg['ip']}, 期望分辨率: {expected_w}x{expected_h}")
+    print(f"[配置] 球机 IP: {ptz_cfg['ip']}, mount_type={ptz_cfg.get('mount_type')}, pan_flip={ptz_cfg.get('pan_flip')}")
+
+    # 1. 打开 RTSP
+    cap = open_rtsp(panorama_cfg['rtsp_url'])
+    if cap is None:
+        print('[错误] 无法打开枪机 RTSP 流,请检查网络、用户名密码、RTSP 地址')
+        return 1
+
+    ret, frame = cap.read()
+    if not ret or frame is None:
+        print('[错误] RTSP 已打开但读取不到帧')
+        cap.release()
+        return 1
+
+    actual_h, actual_w = frame.shape[:2]
+    print(f"[RTSP] 连接成功,实际分辨率: {actual_w}x{actual_h}")
+    cap.release()
+
+    # 2. 初始化检测器
+    print('[检测] 正在加载 YOLO 检测器...')
+    detector = ObjectDetector(
+        model_path=DETECTION_CONFIG.get('model_path'),
+        use_gpu=DETECTION_CONFIG.get('use_gpu', True),
+        model_type=DETECTION_CONFIG.get('model_type', 'yolo'),
+        model_size='n',
+    )
+    print('[检测] 检测器加载完成')
+
+    # 3. 实例化 PTZCamera(不连接 SDK,仅用于坐标计算)
+    ptz = PTZCamera(sdk=None, camera_config=ptz_cfg)
+
+    # 4. 重新打开 RTSP 并检测
+    cap = open_rtsp(panorama_cfg['rtsp_url'])
+    if cap is None:
+        print('[错误] 第二次打开 RTSP 失败')
+        return 1
+
+    print(f'[检测] 开始读取 {args.frames} 帧...')
+    for i in range(args.frames):
+        ret, frame = cap.read()
+        if not ret or frame is None:
+            print(f'[检测] 第 {i+1}/{args.frames} 帧读取失败,跳过')
+            time.sleep(0.5)
+            continue
+
+        detections = detector.detect(frame)
+        persons = [d for d in detections if d.class_name == 'person']
+        print(f"[检测] 帧 {i+1}/{args.frames}: 检测到 {len(persons)} 个人体")
+
+        for p in persons:
+            cx, cy = p.center
+            x_ratio = cx / frame.shape[1]
+            y_ratio = cy / frame.shape[0]
+            pan, tilt, zoom = ptz.calculate_ptz_position(x_ratio, y_ratio)
+            print(f"  人体: center=({cx}, {cy}), ratio=({x_ratio:.3f}, {y_ratio:.3f}) "
+                  f"-> PTZ(pan={pan:.1f}, tilt={tilt:.1f}, zoom={zoom})")
+
+            if args.show:
+                x1, y1, x2, y2 = p.bbox
+                cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
+                cv2.circle(frame, (cx, cy), 4, (0, 0, 255), -1)
+                cv2.putText(frame, f"P{pan:.0f} T{tilt:.0f} Z{zoom}",
+                            (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
+
+        if args.show:
+            cv2.imshow('Local Test', frame)
+            if cv2.waitKey(1) & 0xFF == ord('q'):
+                break
+
+    cap.release()
+    if args.show:
+        cv2.destroyAllWindows()
+
+    print('[完成] 本地冒烟测试结束')
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())

binární
dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310-pytest-9.0.2.pyc


binární
dual_camera_system/tests/__pycache__/test_event_pusher_upload.cpython-310.pyc


binární
dual_camera_system/tests/__pycache__/test_integration_polling.cpython-310-pytest-9.0.2.pyc


binární
dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310-pytest-9.0.2.pyc


binární
dual_camera_system/tests/__pycache__/test_tracker.cpython-310-pytest-9.0.2.pyc


+ 2 - 2
dual_camera_system/tests/test_tracker.py

@@ -133,7 +133,7 @@ def test_rknn_import_fallback(fake_yolo, tmp_path, monkeypatch, caplog):
         def __init__(self, path):
             raise ImportError("rknnlite not installed")
 
-    monkeypatch.setattr("safety_detector.RKNNDetector", FailingRKNN)
+    monkeypatch.setattr("inference_backend.RKNNDetector", FailingRKNN)
     fake_yolo([
         FakeResult(
             names={0: "person"},
@@ -160,7 +160,7 @@ def test_onnx_import_fallback(fake_yolo, tmp_path, monkeypatch, caplog):
         def __init__(self, path):
             raise ImportError("onnxruntime not installed")
 
-    monkeypatch.setattr("safety_detector.ONNXDetector", FailingONNX)
+    monkeypatch.setattr("inference_backend.ONNXDetector", FailingONNX)
     fake_yolo([
         FakeResult(
             names={0: "person"},

+ 3 - 3
dual_camera_system/tracker.py

@@ -12,7 +12,7 @@ from dataclasses import dataclass
 import numpy as np
 
 from config import TRACKING_CONFIG
-from safety_detector import Detection
+from inference_backend import Detection
 
 
 logger = logging.getLogger(__name__)
@@ -142,7 +142,7 @@ class UltralyticsTracker:
 
     def _load_rknn_model(self) -> None:
         try:
-            from safety_detector import RKNNDetector
+            from inference_backend import RKNNDetector
             self.rknn_detector = RKNNDetector(self.model_path)
             self._init_byte_tracker()
             logger.info("RKNN 跟踪模型加载成功: %s", self.model_path)
@@ -153,7 +153,7 @@ class UltralyticsTracker:
 
     def _load_onnx_model(self) -> None:
         try:
-            from safety_detector import ONNXDetector
+            from inference_backend import ONNXDetector
             self.rknn_detector = ONNXDetector(self.model_path)
             self._init_byte_tracker()
             logger.info("ONNX 跟踪模型加载成功: %s", self.model_path)

binární
testrk3588/1781324210075.png


+ 144 - 0
testrk3588/test_yolo26n.py

@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+import cv2
+import numpy as np
+from rknnlite.api import RKNNLite
+from dataclasses import dataclass
+from typing import List, Tuple
+
+@dataclass
+class Detection:
+    class_id: int
+    confidence: float
+    bbox: Tuple[int, int, int, int]
+
+# TODO: 根据模型实际训练类别修改
+COCO_CLASSES = ['人', '自行车', '汽车', '摩托车', '飞机', '公交车', '火车', '卡车',
+    '船', '交通灯', '消防栓', '停车标志', '停车计时器', '长凳', '鸟', '猫', '狗', '马',
+    '羊', '牛', '大象', '熊', '斑马', '长颈鹿', '背包', '雨伞', '手提包', '领带', '行李箱',
+    '飞盘', '滑雪板', '滑雪杖', '球', '风筝', '棒球棒', '棒球手套', '滑板', '冲浪板',
+    '网球拍', '瓶子', '酒杯', '杯子', '叉子', '刀', '勺子', '碗', '香蕉', '苹果', '三明治',
+    '橙子', '西兰花', '胡萝卜', '热狗', '披萨', '甜甜圈', '蛋糕', '椅子', '沙发', '盆栽',
+    '床', '餐桌', '马桶', '电视', '笔记本电脑', '鼠标', '遥控器', '键盘', '手机', '微波炉',
+    '烤箱', '烤面包机', '水槽', '冰箱', '书', '钟', '花瓶', '剪刀', '泰迪熊', '吹风机',
+    '牙刷']
+
+INPUT_SIZE = (640, 640)
+
+def nms(dets, iou_threshold=0.45):
+    if len(dets) == 0:
+        return []
+    boxes = np.array([[d.bbox[0], d.bbox[1], d.bbox[2], d.bbox[3], d.confidence] for d in dets])
+    x1, y1, x2, y2, scores = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3], boxes[:, 4]
+    areas = (x2 - x1 + 1) * (y2 - y1 + 1)
+    order = scores.argsort()[::-1]
+    keep = []
+    while order.size > 0:
+        i = order[0]
+        keep.append(i)
+        xx1 = np.maximum(x1[i], x1[order[1:]])
+        yy1 = np.maximum(y1[i], y1[order[1:]])
+        xx2 = np.minimum(x2[i], x2[order[1:]])
+        yy2 = np.minimum(y2[i], y2[order[1:]])
+        w = np.maximum(0.0, xx2 - xx1 + 1)
+        h = np.maximum(0.0, yy2 - yy1 + 1)
+        inter = w * h
+        ovr = inter / (areas[i] + areas[order[1:]] - inter)
+        inds = np.where(ovr <= iou_threshold)[0]
+        order = order[inds + 1]
+    return [dets[i] for i in keep]
+
+def letterbox(image, input_size=(640, 640)):
+    h0, w0 = image.shape[:2]
+    ih, iw = input_size
+    scale = min(iw / w0, ih / h0)
+    new_w, new_h = int(w0 * scale), int(h0 * scale)
+    pad_w = (iw - new_w) // 2
+    pad_h = (ih - new_h) // 2
+    resized = cv2.resize(image, (new_w, new_h))
+    canvas = np.full((ih, iw, 3), 114, dtype=np.uint8)
+    canvas[pad_h:pad_h+new_h, pad_w:pad_w+new_w] = resized
+    return canvas, scale, pad_w, pad_h, h0, w0
+
+def test_model():
+    model_path = "yolo26n_end2end.rknn"
+    
+    print(f"[INFO] 加载模型: {model_path}")
+    rknn = RKNNLite()
+    ret = rknn.load_rknn(model_path)
+    if ret != 0:
+        print("[ERROR] load_rknn 失败")
+        return
+    
+    ret = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2)
+    if ret != 0:
+        print("[ERROR] init_runtime 失败")
+        return
+    print("[INFO] RKNN 运行时初始化成功")
+    
+    image = cv2.imread("c.jpg")
+    if image is None:
+        print("[ERROR] 无法读取 c.jpg")
+        return
+    h0, w0 = image.shape[:2]
+    print(f"[INFO] 原始图片: {w0}x{h0}")
+    
+    canvas, scale, pad_w, pad_h, h0, w0 = letterbox(image)
+    img = canvas[..., ::-1].astype(np.float32) / 255.0
+    blob = img[None, ...]
+    
+    print("[INFO] 开始推理...")
+    outputs = rknn.inference(inputs=[blob])
+    
+    output = outputs[0]
+    print(f"[INFO] 输出 shape: {output.shape}")
+    
+    # end2end 格式: (1, max_det, 6) 每行为 [x1, y1, x2, y2, conf, class_id]
+    dets_raw = output[0]
+    print(f"\n[RAW 前 20 个检测框]:")
+    for i in range(min(20, len(dets_raw))):
+        x1_lb, y1_lb, x2_lb, y2_lb, conf, cls_id = dets_raw[i]
+        if conf > 0:
+            cls_name = COCO_CLASSES[int(cls_id)] if int(cls_id) < len(COCO_CLASSES) else f'cls{int(cls_id)}'
+            print(f"  [{i}] cls={int(cls_id)}({cls_name}) conf={conf:.3f} box_lb=[{x1_lb:.1f},{y1_lb:.1f},{x2_lb:.1f},{y2_lb:.1f}]")
+    
+    # 解码: end2end 坐标在 letterbox 空间,需要映射回原图
+    dets = []
+    for i in range(len(dets_raw)):
+        x1_lb, y1_lb, x2_lb, y2_lb, conf, cls_id = dets_raw[i]
+        if conf < 0.25:
+            continue
+        
+        # 从 letterbox 空间映射回原图
+        x1 = int((x1_lb - pad_w) / scale)
+        y1 = int((y1_lb - pad_h) / scale)
+        x2 = int((x2_lb - pad_w) / scale)
+        y2 = int((y2_lb - pad_h) / scale)
+        x1 = max(0, min(w0, x1))
+        y1 = max(0, min(h0, y1))
+        x2 = max(0, min(w0, x2))
+        y2 = max(0, min(h0, y2))
+        
+        dets.append(Detection(int(cls_id), float(conf), (x1, y1, x2, y2)))
+    
+    dets = nms(dets)
+    
+    print(f"\n=== 最终检测结果: {len(dets)} 个目标 ===")
+    for d in dets:
+        cls_name = COCO_CLASSES[d.class_id] if d.class_id < len(COCO_CLASSES) else f'未知({d.class_id})'
+        print(f"  [{cls_name}] conf={d.confidence:.3f}, box={d.bbox}")
+    
+    for d in dets:
+        x1, y1, x2, y2 = d.bbox
+        cls_name = COCO_CLASSES[d.class_id] if d.class_id < len(COCO_CLASSES) else f'C{d.class_id}'
+        cv2.rectangle(image, (x1, y1), (x2, y2), (0, 255, 0), 2)
+        cv2.putText(image, f"{cls_name} {d.confidence:.2f}", (x1, max(15, y1-5)),
+                   cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
+    
+    out_path = "c_result_yolo26n.jpg"
+    cv2.imwrite(out_path, image)
+    print(f"\n[INFO] 结果图已保存: {out_path}")
+    
+    rknn.release()
+
+if __name__ == "__main__":
+    test_model()

binární
testrk3588/yolo26n_end2end.rknn


+ 7 - 0
tmp_fix_threshold.py

@@ -0,0 +1,7 @@
+with open("/home/admin/dsh/dual_camera_system/config/detection.py") as f:
+    c = f.read()
+c = c.replace("'confidence_threshold': 0.05", "'confidence_threshold': 0.02")
+c = c.replace("'person_threshold': 0.05", "'person_threshold': 0.02")
+with open("/home/admin/dsh/dual_camera_system/config/detection.py", "w") as f:
+    f.write(c)
+print("DONE")

+ 7 - 0
tmp_set_thr.py

@@ -0,0 +1,7 @@
+with open("/home/admin/dsh/dual_camera_system/config/detection.py") as f:
+    c = f.read()
+c = c.replace("'confidence_threshold': 0.02", "'confidence_threshold': 0.4")
+c = c.replace("'person_threshold': 0.02", "'person_threshold': 0.4")
+with open("/home/admin/dsh/dual_camera_system/config/detection.py", "w") as f:
+    f.write(c)
+print("DONE")

+ 7 - 0
tmp_set_thr35.py

@@ -0,0 +1,7 @@
+with open("/home/admin/dsh/dual_camera_system/config/detection.py") as f:
+    c = f.read()
+c = c.replace("'confidence_threshold': 0.4", "'confidence_threshold': 0.35")
+c = c.replace("'person_threshold': 0.4", "'person_threshold': 0.35")
+with open("/home/admin/dsh/dual_camera_system/config/detection.py", "w") as f:
+    f.write(c)
+print("DONE")

+ 14 - 0
tmp_switch_model.py

@@ -0,0 +1,14 @@
+with open("/home/admin/dsh/dual_camera_system/config/detection.py") as f:
+    c = f.read()
+
+c = c.replace("return '/home/admin/dsh/testrk3588/yolo26n_end2end.rknn', 'rknn'",
+              "return '/home/admin/dsh/testrk3588/yolo26n.onnx', 'onnx'")
+
+with open("/home/admin/dsh/dual_camera_system/config/detection.py", "w") as f:
+    f.write(c)
+
+# Verify
+with open("/home/admin/dsh/dual_camera_system/config/detection.py") as f:
+    for i, line in enumerate(f.readlines()):
+        if 'model' in line.lower() or 'return' in line:
+            print(f"L{i+1}: {line.rstrip()}")

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů