Просмотр исходного кода

feat: 初始化双摄安全检测系统完整代码库

初始化项目全量代码,包含:
1. 基础配置模块(系统、摄像头、检测、设备、OSS等配置)
2. 核心业务模块(流管理、检测服务、文件清理、上传等)
3. 部署脚本与systemd服务配置
4. 完整依赖声明与安装脚本
wenhongquan 1 день назад
Родитель
Сommit
780bf0ee51

+ 33 - 0
deploy/dsh-dual-camera.service

@@ -0,0 +1,33 @@
+[Unit]
+Description=DSH 双摄安全检测系统
+Documentation=https://github.com/anomalyco/dsh
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=admin
+Group=admin
+
+# 工作目录
+WorkingDirectory=/home/admin/dsh/dual_camera_system
+
+# Conda 环境激活 + 启动服务
+ExecStart=/bin/bash -c 'source /home/admin/miniconda3/etc/profile.d/conda.sh && conda activate rknn && exec python main.py --host 0.0.0.0 --port 8000'
+
+# 重启策略
+Restart=always
+RestartSec=5
+
+# 日志
+StandardOutput=append:/home/admin/dsh/dual_camera_system/logs/service.log
+StandardError=append:/home/admin/dsh/dual_camera_system/logs/service.log
+
+# 安全与资源限制
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=full
+LimitNOFILE=65536
+
+[Install]
+WantedBy=multi-user.target

+ 313 - 0
deploy/install.sh

@@ -0,0 +1,313 @@
+#!/bin/bash
+set -euo pipefail
+
+# ============================================================
+# DSH 双摄安全检测系统 — 一键安装脚本
+# 支持:RK3588 (aarch64) / x86_64 Linux / macOS
+# ============================================================
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+
+# ---------- 颜色输出 ----------
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+CYAN='\033[0;36m'
+NC='\033[0m'
+
+log_info()  { echo -e "${GREEN}[INFO]${NC}  $*"; }
+log_warn()  { echo -e "${YELLOW}[WARN]${NC}  $*"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
+log_step()  { echo -e "\n${CYAN}═══════════════════════════════════════════${NC}"; echo -e "${CYAN}  $*${NC}"; echo -e "${CYAN}═══════════════════════════════════════════${NC}\n"; }
+
+# ---------- 检测平台 ----------
+detect_platform() {
+    local arch
+    arch="$(uname -m)"
+    local os
+    os="$(uname -s)"
+
+    if [ "$os" = "Darwin" ]; then
+        echo "macos"
+    elif [ "$os" = "Linux" ] && [ "$arch" = "aarch64" ]; then
+        echo "rk3588"
+    elif [ "$os" = "Linux" ] && { [ "$arch" = "x86_64" ] || [ "$arch" = "amd64" ]; }; then
+        echo "linux_x86"
+    else
+        echo "unknown"
+    fi
+}
+
+# ---------- 默认路径 ----------
+setup_paths() {
+    local platform="$1"
+
+    case "$platform" in
+        rk3588)
+            INSTALL_DIR="/home/admin/dsh"
+            SERVICE_USER="admin"
+            SERVICE_GROUP="admin"
+            CONDA_PATH="/home/admin/miniconda3"
+            CONDA_ENV="rknn"
+            ;;
+        linux_x86)
+            INSTALL_DIR="/home/wen/dsh"
+            SERVICE_USER="$USER"
+            SERVICE_GROUP="$(id -gn)"
+            CONDA_PATH="${CONDA_PATH:-$HOME/miniconda3}"
+            CONDA_ENV="dsh"
+            ;;
+        macos)
+            INSTALL_DIR="$HOME/dsh"
+            SERVICE_USER="$USER"
+            SERVICE_GROUP="$(id -gn)"
+            CONDA_PATH="${CONDA_PATH:-$HOME/miniconda3}"
+            CONDA_ENV="dsh"
+            ;;
+    esac
+
+    SYSTEM_DIR="${INSTALL_DIR}/dual_camera_system"
+    LOG_DIR="${SYSTEM_DIR}/logs"
+}
+
+# ============================================================
+# 安装步骤
+# ============================================================
+
+install_system_deps() {
+    local platform="$1"
+    log_step "1/6 安装系统依赖"
+
+    case "$platform" in
+        rk3588|linux_x86)
+            if command -v apt-get &>/dev/null; then
+                sudo apt-get update -y
+                sudo apt-get install -y \
+                    python3-pip python3-dev \
+                    libgl1-mesa-glx libglib2.0-0 \
+                    libsm6 libxext6 libxrender-dev \
+                    libgomp1 cmake build-essential \
+                    ffmpeg
+            elif command -v yum &>/dev/null; then
+                sudo yum install -y epel-release
+                sudo yum install -y python3-pip python3-devel \
+                    mesa-libGL glib2-devel \
+                    libSM libXext libXrender \
+                    libgomp cmake gcc-c++ make \
+                    ffmpeg ffmpeg-devel
+            fi
+            ;;
+        macos)
+            if command -v brew &>/dev/null; then
+                brew install ffmpeg
+            fi
+            ;;
+    esac
+    log_info "系统依赖安装完成"
+}
+
+setup_conda_env() {
+    local platform="$1"
+    log_step "2/6 配置 Conda 环境"
+
+    # 检查 conda
+    if [ ! -f "${CONDA_PATH}/etc/profile.d/conda.sh" ]; then
+        log_warn "未找到 Miniconda3 在 ${CONDA_PATH}"
+        log_info "请手动安装 Miniconda3:"
+        log_info "  wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-aarch64.sh"
+        log_info "  bash Miniconda3-latest-Linux-aarch64.sh -b -p ${CONDA_PATH}"
+        log_info "按回车键继续,或 Ctrl+C 退出..."
+        read -r
+    fi
+
+    # 创建 conda 环境(如果不存在)
+    if [ -f "${CONDA_PATH}/etc/profile.d/conda.sh" ]; then
+        # shellcheck disable=SC1091
+        source "${CONDA_PATH}/etc/profile.d/conda.sh"
+        if ! conda env list | grep -q "^${CONDA_ENV}\s"; then
+            log_info "创建 Conda 环境: ${CONDA_ENV} (Python 3.10)"
+            conda create -y -n "${CONDA_ENV}" python=3.10
+        else
+            log_info "Conda 环境 '${CONDA_ENV}' 已存在,跳过创建"
+        fi
+    fi
+    log_info "Conda 环境就绪"
+}
+
+install_python_deps() {
+    local platform="$1"
+    log_step "3/6 安装 Python 依赖"
+
+    local pip_cmd="pip3"
+    if [ -f "${CONDA_PATH}/etc/profile.d/conda.sh" ]; then
+        # shellcheck disable=SC1091
+        source "${CONDA_PATH}/etc/profile.d/conda.sh"
+        pip_cmd="${CONDA_PATH}/envs/${CONDA_ENV}/bin/pip"
+    fi
+
+    # 升级 pip
+    $pip_cmd install --upgrade pip setuptools wheel
+
+    # 平台特定依赖
+    if [ "$platform" = "rk3588" ]; then
+        # RK3588 使用 ONNX Runtime 而非 ultralytics
+        $pip_cmd install onnxruntime~=1.17.0
+    fi
+
+    # 安装主要依赖
+    $pip_cmd install -r "${SCRIPT_DIR}/requirements.txt"
+
+    # macOS arm64 需要单独安装 tensorflow-metal 等
+    if [ "$platform" = "macos" ] && [ "$(uname -m)" = "arm64" ]; then
+        $pip_cmd install --upgrade torch torchvision
+    fi
+
+    log_info "Python 依赖安装完成"
+}
+
+deploy_project_files() {
+    local platform="$1"
+    log_step "4/6 部署项目文件"
+
+    # 创建目录结构
+    sudo mkdir -p "${SYSTEM_DIR}"
+    sudo mkdir -p "${SYSTEM_DIR}/config"
+    sudo mkdir -p "${SYSTEM_DIR}/core"
+    sudo mkdir -p "${SYSTEM_DIR}/data/captures"
+    sudo mkdir -p "${SYSTEM_DIR}/data/previews"
+    sudo mkdir -p "${LOG_DIR}"
+
+    # 复制源码 — 从 deploy/src/dual_camera_system/ 复制
+    local SRC="${SCRIPT_DIR}/src/dual_camera_system"
+    if [ ! -d "$SRC" ]; then
+        log_error "未找到源码目录: $SRC"
+        log_error "请确保 deploy/src/dual_camera_system/ 目录存在"
+        exit 1
+    fi
+
+    # 复制所有 .py 文件
+    log_info "复制 Python 模块..."
+    find "${SRC}" -maxdepth 1 -name '*.py' -exec sudo cp -v {} "${SYSTEM_DIR}/" \;
+    find "${SRC}/config" -name '*.py' -exec sudo cp -v {} "${SYSTEM_DIR}/config/" \;
+    find "${SRC}/core" -name '*.py' -exec sudo cp -v {} "${SYSTEM_DIR}/core/" \;
+
+    # 复制 requirements.txt 和 service.sh
+    sudo cp "${SRC}/requirements.txt" "${SYSTEM_DIR}/" 2>/dev/null || true
+    sudo cp "${SRC}/service.sh" "${SYSTEM_DIR}/" 2>/dev/null || true
+
+    # 设置权限
+    sudo chown -R "${SERVICE_USER}:${SERVICE_GROUP}" "${INSTALL_DIR}"
+
+    log_info "项目文件部署完成"
+}
+
+install_systemd_service() {
+    local platform="$1"
+    log_step "5/6 安装 Systemd 服务"
+
+    if [ "$platform" = "macos" ]; then
+        log_warn "macOS 不支持 systemd,跳过服务安装"
+        log_info "可手动启动: cd ${SYSTEM_DIR} && python main.py --port 8000"
+        return
+    fi
+
+    # 复制 service 文件
+    sudo cp "${SCRIPT_DIR}/dsh-dual-camera.service" /etc/systemd/system/dsh-dual-camera.service
+    sudo chmod 644 /etc/systemd/system/dsh-dual-camera.service
+
+    # 重载、启用、启动
+    sudo systemctl daemon-reload
+    sudo systemctl enable dsh-dual-camera.service
+    sudo systemctl restart dsh-dual-camera.service
+
+    # 检查状态
+    sleep 2
+    if systemctl is-active --quiet dsh-dual-camera.service; then
+        log_info "服务已启动并设置为开机自启"
+    else
+        log_warn "服务启动异常,请检查: sudo journalctl -u dsh-dual-camera.service -n 50 --no-pager"
+    fi
+}
+
+verify_installation() {
+    local platform="$1"
+    log_step "6/6 验证安装"
+
+    echo ""
+    echo "  ┌─────────────────────────────────────────────┐"
+    echo "  │   DSH 双摄安全检测系统                       │"
+    echo "  │                                              │"
+    echo "  │  安装目录: ${INSTALL_DIR}"
+
+    if [ "$platform" != "macos" ]; then
+        if systemctl is-active --quiet dsh-dual-camera.service; then
+            echo "  │  服务状态: ✅ 运行中 (systemd)            │"
+        else
+            echo "  │  服务状态: ❌ 未运行                       │"
+        fi
+        echo "  │  管理命令:                                  │"
+        echo "  │    sudo systemctl start dsh-dual-camera     │"
+        echo "  │    sudo systemctl stop dsh-dual-camera      │"
+        echo "  │    sudo systemctl restart dsh-dual-camera   │"
+        echo "  │    sudo journalctl -u dsh-dual-camera -f    │"
+    else
+        echo "  │  服务状态: ⚡ 手动启动                       │"
+        echo "  │  启动命令:                                  │"
+        echo "  │    cd ${SYSTEM_DIR}                         │"
+        echo "  │    python main.py --port 8000               │"
+    fi
+
+    echo "  │                                              │"
+    echo "  │  API 文档: http://localhost:8000/docs         │"
+    echo "  │  健康检查: http://localhost:8000/             │"
+    echo "  │                                              │"
+    echo "  └─────────────────────────────────────────────┘"
+    echo ""
+}
+
+# ============================================================
+# 主流程
+# ============================================================
+
+main() {
+    echo ""
+    echo "  ╔══════════════════════════════════════════╗"
+    echo "  ║   DSH 双摄安全检测系统 — 一键部署        ║"
+    echo "  ║   Dual-camera Safety Detection System    ║"
+    echo "  ╚══════════════════════════════════════════╝"
+    echo ""
+
+    # 检测平台
+    local PLATFORM
+    PLATFORM="$(detect_platform)"
+    log_info "检测到平台: ${PLATFORM} ($(uname -m))"
+
+    if [ "$PLATFORM" = "unknown" ]; then
+        log_error "不支持的平台: $(uname -s) / $(uname -m)"
+        log_error "仅支持 RK3588 (aarch64 Linux)、x86_64 Linux 和 macOS"
+        exit 1
+    fi
+
+    # 设置路径
+    setup_paths "$PLATFORM"
+
+    # 检查 root 权限
+    if [ "$PLATFORM" != "macos" ]; then
+        if [ "$EUID" -ne 0 ]; then
+            log_info "部分操作需要 sudo 权限(安装依赖、部署文件、注册服务)"
+        fi
+    fi
+
+    # 执行安装步骤
+    install_system_deps "$PLATFORM"
+    setup_conda_env "$PLATFORM"
+    install_python_deps "$PLATFORM"
+    deploy_project_files "$PLATFORM"
+    install_systemd_service "$PLATFORM"
+    verify_installation "$PLATFORM"
+
+    log_info "安装完成!"
+}
+
+main "$@"

+ 13 - 0
deploy/requirements.txt

@@ -0,0 +1,13 @@
+opencv-python>=4.8.0
+opencv-contrib-python>=4.8.0
+numpy>=1.24.0
+Pillow>=10.0.0
+ultralytics>=8.0.0
+requests>=2.28.0
+boto3>=1.26.0
+urllib3>=2.0.0
+colorlog>=6.7.0
+fastapi>=0.110.0
+uvicorn[standard]>=0.27.0
+python-multipart>=0.0.9
+httpx>=0.27.0

+ 259 - 0
deploy/src/dual_camera_system/app.py

@@ -0,0 +1,259 @@
+"""FastAPI 应用工厂与全局服务初始化."""
+import logging
+import os
+import threading
+import time
+from contextlib import asynccontextmanager
+
+import cv2
+from fastapi import FastAPI
+
+from config import CAMERA_GROUPS, SYSTEM_CONFIG, STORAGE_CONFIG
+from third_party_pusher import get_third_party_pusher
+
+from core.stream_manager import StreamManager
+from core.capture_uploader import CaptureUploader
+from core.detector_service import DetectorService
+from core.group_state import group_state
+from core.oss_uploader import OSSUploader
+from core.file_cleanup import make_cleanup_workers
+
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    force=True,
+)
+logger = logging.getLogger(__name__)
+
+
+def build_rtsp_url(camera_config: dict) -> str:
+    if camera_config.get("rtsp_url"):
+        return camera_config["rtsp_url"]
+    ip = camera_config["ip"]
+    port = camera_config.get("rtsp_port", 554)
+    username = camera_config["username"]
+    password = camera_config["password"]
+    channel = camera_config.get("rtsp_channel") or camera_config.get("channel", 1)
+    subtype = camera_config.get("subtype", 0)
+    return f"rtsp://{username}:{password}@{ip}:{port}/cam/realmonitor?channel={channel}&subtype={subtype}"
+
+
+def create_app(test_mode: bool = False) -> FastAPI:
+    @asynccontextmanager
+    async def lifespan(app: FastAPI):
+        group_state.reset()
+
+        stream_manager = StreamManager()
+        threads: list = []
+        stop_event = threading.Event()
+
+        pusher = None
+        detector_service = None
+        oss_uploader = None
+        cleanup_workers: list = []
+        app.state.stream_manager = stream_manager
+        app.state.pusher = None
+        app.state.oss_uploader = None
+        app.state.detector_service = None
+
+        if not test_mode:
+            if SYSTEM_CONFIG.get("enable_detection", True):
+                detector_service = DetectorService()
+                app.state.detector_service = detector_service
+
+            # 始终启用第三方推送(只要在配置中 enabled=True)
+            try:
+                oss_uploader = OSSUploader()
+            except Exception as exc:
+                print(f"[lifespan] OSS uploader init failed: {exc}")
+                oss_uploader = None
+
+            try:
+                pusher = get_third_party_pusher()
+                if pusher and not pusher.running:
+                    pusher.start()
+                app.state.pusher = pusher
+            except Exception as exc:
+                print(f"[lifespan] Pusher start failed: {exc}")
+                pusher = None
+                app.state.pusher = None
+
+            if pusher is not None:
+                def _delete_captures_on_success(report):
+                    if STORAGE_CONFIG.get("captures", {}).get("keep_local_copy", False):
+                        return
+                    for path in report.batch_info.get("image_paths") or []:
+                        try:
+                            if path and os.path.exists(path):
+                                os.remove(path)
+                                logger.info("[cleanup] 上报成功后删除本地图片: %s", path)
+                        except Exception as exc:
+                            logger.warning("[cleanup] 删除本地图片失败: %s, %s", path, exc)
+
+                try:
+                    pusher.set_callbacks(on_success=_delete_captures_on_success)
+                except Exception as exc:
+                    print(f"[lifespan] set pusher success callback failed: {exc}")
+
+            try:
+                group_ids = [
+                    g.get("group_id", g.get("id"))
+                    for g in CAMERA_GROUPS
+                    if g.get("enabled", True)
+                ]
+                cleanup_workers = make_cleanup_workers(STORAGE_CONFIG, group_ids)
+                for w in cleanup_workers:
+                    w.start()
+            except Exception as exc:
+                print(f"[lifespan] cleanup worker start failed: {exc}")
+
+        for group in CAMERA_GROUPS:
+            if not group.get("enabled", True):
+                continue
+            gid = group.get("group_id", group.get("id"))
+
+            try:
+                pano_cfg = group["panorama"]
+                pano_url = build_rtsp_url(pano_cfg)
+                ptz_cfg = group.get("ptz")
+                ptz_url = build_rtsp_url(ptz_cfg) if ptz_cfg else None
+
+                group_state.init_group(gid, pano_url, ptz_url, None)
+
+                if test_mode:
+                    continue
+
+                if SYSTEM_CONFIG.get("enable_panorama_camera", True):
+                    stream_manager.register(f"{gid}_panorama", pano_url)
+                    if ptz_url:
+                        stream_manager.register(f"{gid}_ptz", ptz_url)
+
+                uploader = CaptureUploader(
+                    gid,
+                    upload_callback=pusher.report_batch if pusher else None,
+                    oss_uploader=oss_uploader,
+                )
+
+                if SYSTEM_CONFIG.get("enable_detection", True) and detector_service is not None:
+                    def panorama_detect_loop(g=gid, uploader=uploader, detector=detector_service):
+                        interval = 0.5
+                        frame_count = 0
+                        while not stop_event.is_set():
+                            try:
+                                stream = stream_manager.get(f"{g}_panorama")
+                                frame = stream.get_frame() if stream else None
+                                if frame is not None:
+                                    frame_count += 1
+                                    if frame_count % 10 == 0:
+                                        print(f"[detect] {g} frame count: {frame_count}")
+                                    dets = detector.detect(frame)
+                                    marked = frame.copy()
+                                    if dets:
+                                        print(f"[detect] {g} found {len(dets)} objects")
+                                        det_dicts = [{
+                                            "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
+                                            "confidence": d.confidence,
+                                        } for d in dets]
+                                        for dd in det_dicts:
+                                            print(f"[detect] {g} bbox={dd['bbox']} conf={dd['confidence']:.3f}")
+                                        uploader.handle_detection("panorama", frame, det_dicts)
+                                        for d in det_dicts:
+                                            x1, y1, x2, y2 = d["bbox"]
+                                            cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+                                    stream.set_marked_frame(marked)
+                                elif stream is None:
+                                    err = stream.last_error if stream else "stream not found"
+                                    if frame_count == 0:
+                                        print(f"[detect] {g} no stream, last_error: {err}")
+                            except Exception as exc:
+                                print(f"[panorama_detect_loop {g}] error: {exc}")
+                            time.sleep(interval)
+
+                    t_panorama = threading.Thread(target=panorama_detect_loop, daemon=True)
+                    t_panorama.start()
+                    threads.append(t_panorama)
+
+                    if ptz_url:
+                        def ptz_detect_loop(g=gid, uploader=uploader, detector=detector_service):
+                            interval = 0.5
+                            while not stop_event.is_set():
+                                try:
+                                    stream = stream_manager.get(f"{g}_ptz")
+                                    frame = stream.get_frame() if stream else None
+                                    if frame is not None:
+                                        dets = detector.detect(frame)
+                                        if dets:
+                                            det_dicts = [{
+                                                "bbox": [d.bbox[0], d.bbox[1], d.bbox[0]+d.bbox[2], d.bbox[1]+d.bbox[3]],
+                                                "confidence": d.confidence,
+                                            } for d in dets]
+                                            for dd in det_dicts:
+                                                print(f"[detect] {g} PTZ bbox={dd['bbox']} conf={dd['confidence']:.3f}")
+                                            uploader.handle_detection("ptz", frame, det_dicts)
+                                except Exception as exc:
+                                    print(f"[ptz_detect_loop {g}] error: {exc}")
+                                time.sleep(interval)
+
+                        t_ptz = threading.Thread(target=ptz_detect_loop, daemon=True)
+                        t_ptz.start()
+                        threads.append(t_ptz)
+
+            except Exception as exc:
+                print(f"[lifespan] Group {gid} setup failed: {exc}")
+                continue
+
+        yield
+
+        stop_event.set()
+        for t in threads:
+            try:
+                t.join(timeout=2.0)
+            except Exception as exc:
+                print(f"[lifespan] Thread join error: {exc}")
+
+        stream_manager.stop_all()
+
+        if pusher and getattr(pusher, "running", False):
+            try:
+                pusher.stop()
+            except Exception as exc:
+                print(f"[lifespan] Pusher stop error: {exc}")
+
+        for w in cleanup_workers:
+            try:
+                w.stop()
+            except Exception as exc:
+                print(f"[lifespan] cleanup worker stop error: {exc}")
+
+    app = FastAPI(lifespan=lifespan)
+
+    @app.get("/")
+    async def root():
+        return {"status": "running", "service": "panorama detection"}
+
+    @app.get("/debug")
+    async def debug():
+        sm = app.state.stream_manager
+        ps = app.state.pusher
+        ou = app.state.oss_uploader
+        ds = app.state.detector_service
+        info = {}
+        for g in CAMERA_GROUPS:
+            if not g.get("enabled", True):
+                continue
+            gid = g.get("group_id", g.get("id"))
+            stream = sm.get(f"{gid}_panorama") if sm else None
+            if stream:
+                info[gid] = {
+                    "stream_alive": stream.thread is not None and stream.thread.is_alive(),
+                    "has_frame": stream.latest_frame is not None,
+                    "frame_shape": str(stream.latest_frame.shape) if stream.latest_frame is not None else None,
+                    "last_error": stream.last_error,
+                }
+            else:
+                info[gid] = {"error": "stream not registered"}
+        info["pusher_running"] = ps.running if ps else False
+        info["detector"] = ds is not None
+        return info
+
+    return app

+ 27 - 0
deploy/src/dual_camera_system/config/__init__.py

@@ -0,0 +1,27 @@
+"""
+配置模块
+按功能拆分的配置文件集合
+"""
+
+from .camera import (
+    CAMERA_GROUPS, get_enabled_groups
+)
+from .detection import DETECTION_CONFIG
+from .device import DEVICE_CONFIG, THIRD_PARTY_CONFIG, STORAGE_CONFIG as DEVICE_STORAGE_CONFIG
+from .system import SYSTEM_CONFIG, STORAGE_CONFIG
+from .oss import S3_COMPATIBLE_CONFIG as OSS_CONFIG
+
+
+# 导出活跃代码实际使用的配置
+__all__ = [
+    # 摄像头
+    'CAMERA_GROUPS', 'get_enabled_groups',
+    # 检测
+    'DETECTION_CONFIG',
+    # 设备
+    'DEVICE_CONFIG', 'THIRD_PARTY_CONFIG',
+    # 系统
+    'SYSTEM_CONFIG', 'STORAGE_CONFIG',
+    # OSS
+    'OSS_CONFIG',
+]

+ 105 - 0
deploy/src/dual_camera_system/config/camera.py

@@ -0,0 +1,105 @@
+"""
+摄像头配置
+"""
+import platform
+import os
+
+LOG_CONFIG = {
+    'level': 'INFO',
+    'format': '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    'file': None,
+    'max_bytes': 10 * 1024 * 1024,
+    'backup_count': 5,
+    'retention_days': 7,
+}
+
+# ============================================================
+# 多组摄像头配置
+# 使用方法:启用需要的组(enabled=True),配置对应的IP地址
+# ============================================================
+# 摄像头组配置(devices.md)
+CAMERA_GROUPS = [
+    {
+        'group_id': 'group_test',
+        'name': '测试环境',
+        'enabled': True,
+        'panorama': {
+            'ip': '192.168.8.2',
+            'port': 37777,
+            'rtsp_port': 554,
+            'username': 'admin',
+            'password': 'QAZwsx12',
+            'channel': 1,
+            'brand': 'hikvision',
+            'use_sdk': False,
+            'resolution': (2560, 1440),
+            'rtsp_url': 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101',
+        },
+        'ptz': {
+            'ip': '192.168.8.5',
+            'port': 37777,
+            'rtsp_port': 554,
+            'username': 'admin',
+            'password': 'Aa1234567',
+            'channel': 1,
+            'brand': 'dahua',
+            'subtype': 1,
+            'resolution': (3840, 2160),
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
+            'pan_flip': True,
+            'ceiling_mount': True,
+        },
+    },
+    {
+        'group_id': 'group_prod',
+        'name': '正式环境',
+        'enabled': True,
+        'panorama': {
+            'ip': '192.168.20.196',
+            'port': 37777,
+            'rtsp_port': 554,
+            'username': 'admin',
+            'password': 'Aa1234567',
+            'channel': 1,
+            'brand': 'dahua',
+            'use_sdk': False,
+            'resolution': (3840, 1080),
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0',
+        },
+        'ptz': {
+            'ip': '192.168.20.197',
+            'port': 37777,
+            'rtsp_port': 554,
+            'username': 'admin',
+            'password': 'Aa1234567',
+            'channel': 1,
+            'brand': 'dahua',
+            'subtype': 0,
+            'resolution': (3840, 2160),
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=0',
+            'pan_flip': False,
+            'ceiling_mount': True,
+        },
+    },
+]
+
+def get_enabled_groups() -> list:
+    """获取所有启用的摄像头组配置"""
+    return [g for g in CAMERA_GROUPS if g.get('enabled', False)]
+
+
+def parse_resolution(resolution) -> tuple:
+    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):
+        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

+ 54 - 0
deploy/src/dual_camera_system/config/detection.py

@@ -0,0 +1,54 @@
+"""
+检测配置
+
+注意:系统当前仅保留人体检测配置,安全帽/反光衣等安全检测配置已移除。
+"""
+
+import platform
+
+
+def _default_model():
+    """根据平台选择默认检测模型"""
+    system = platform.system()
+    machine = platform.machine()
+
+    if system == 'Linux' and machine == 'aarch64':
+        # RK3588: 使用 yolo26n.onnx via ONNX Runtime (CPU)
+        return '/home/admin/testrk3588/yolo26n.onnx', 'onnx'
+    elif system == 'Darwin':
+        # macOS 本地测试
+        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.30,     # 置信度阈值(yolo26n 对全景远距离人员置信度偏低)
+    'detection_fps': 2,              # 检测帧率
+    'detection_interval': 4,       # 兼容保留:检测间隔(秒)
+
+    # 检测图片保存配置
+    '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,      # 批次时间窗口(秒),同一窗口内的检测归为一批
+
+    # 默认人体检测模型
+    'model_path': _MODEL_PATH,
+    'model_type': _MODEL_TYPE,      # 模型类型: 'rknn', 'yolo', 'onnx'
+    'use_gpu': False,               # 使用 CPU (yolo26n.pt via ultralytics)
+
+    # 人体检测后处理阈值
+    'person_threshold': 0.30,    # 进入联动跟踪的人体置信度阈值
+
+    # 模型类别映射(yolo26n COCO80:0=person)
+    'class_map': {0: 'person'},
+}

+ 131 - 0
deploy/src/dual_camera_system/config/device.py

@@ -0,0 +1,131 @@
+"""
+设备配置
+设备编号、第三方平台接口等配置
+"""
+
+# 设备配置
+DEVICE_CONFIG = {
+    # 设备编号(必填,用于标识当前设备)
+    'device_id': '9c9a8000-3d13-11f1-9ffa-01f22beacf2b',
+    
+    # 设备名称
+    'device_name': '施工现场安全识别设备',
+    
+    # 设备安装位置
+    'location': '施工现场A区',
+    
+    # 项目编号
+    'project_id': 'PROJECT_001',
+    
+    # 项目密钥(用于接口鉴权)
+    'project_secret': '',
+
+    # API Key(用于 Web 控制接口鉴权;留空则不鉴权,保持本地开发兼容)
+    'api_key': '',
+}
+
+# 第三方平台接口配置
+THIRD_PARTY_CONFIG = {
+    'enabled': True,  # 启用第三方平台推送
+
+    # 平台类型: 'custom', 'jtjai', 'huawei', 'aliyun'
+    'platform_type': 'custom',
+
+    # 接口基础配置
+    'base_url': 'http://58.213.48.54:9999',  # 人体分析平台
+    # 'base_url': 'http://192.168.8.48:9090',
+    'api_version': 'v1',
+    
+    # 认证配置
+    'auth_type': 'none',  # 可选: 'none', 'api_key', 'oauth2', 'basic'
+    'api_key': '',
+    'api_secret': '',
+    
+    # OAuth2 配置(当 auth_type='oauth2' 时使用)
+    'oauth2': {
+        'token_url': '',
+        'client_id': '',
+        'client_secret': '',
+        'scope': '',
+    },
+    
+    # 接口路径配置
+    'endpoints': {
+        # 批次信息上报接口(接收 batch_info.json)
+        'batch_report': '/api/v1/human-analysis/',
+        
+        # 心跳接口
+        'heartbeat': '/api/device/heartbeat',
+        
+        # 图片上传回调接口(可选,如果第三方平台需要单独通知)
+        'image_upload_callback': '/api/image/uploaded',
+    },
+    
+    # 推送控制
+    'push_interval': 1.0,     # 推送间隔(秒)
+    'retry_count': 3,         # 重试次数
+    'retry_delay': 2.0,       # 重试延迟(秒)
+    'timeout': 10,            # 请求超时(秒)
+    
+    # 数据格式
+    'data_format': 'json',    # 可选: 'json', 'form'
+    
+    # 是否包含图片文件(multipart/form-data 上传)
+    'include_images': False,
+}
+
+# 批次信息上报配置
+BATCH_REPORT_CONFIG = {
+    # 上报时机
+    'report_on_complete': True,   # 批次完成时上报
+    'report_realtime': False,     # 实时上报(每保存一张图就上报)
+
+    # 上报内容
+    'include_panorama_url': True,   # 包含全景图 OSS URL
+    'include_ptz_urls': True,       # 包含球机图 OSS URLs
+    'include_raw_images': False,    # 是否包含原始图片数据(Base64)
+
+    # 本地保留(已迁移到 STORAGE_CONFIG,此处保留仅作兼容)
+    'keep_local_copy': False,       # 上报成功后是否删除本地副本(False=删除,省磁盘)
+    'local_retention_days': 7,      # 本地保留天数
+}
+
+# 本地图片存储与清理配置
+STORAGE_CONFIG = {
+    'captures': {
+        # 抓拍图片保存根目录(实际会按 group_id 创建子目录)
+        'base_dir': 'data/captures',
+        # 第三方平台上报成功后是否保留本地图片
+        'keep_local_copy': False,
+        # 本地保留天数,超过此时间的文件会被定期清理
+        'retention_days': 1,
+        # 每组目录最大文件数,超过时按修改时间删除最旧的
+        'max_files': 2000,
+        # 清理线程执行间隔(秒)
+        'cleanup_interval_seconds': 3600,
+    },
+    'previews': {
+        # 预览图保存根目录
+        'base_dir': 'data/previews',
+        # 本地保留天数
+        'retention_days': 1,
+        # 每组目录最大文件数
+        'max_files': 1000,
+        # 清理线程执行间隔(秒)
+        'cleanup_interval_seconds': 3600,
+    },
+}
+
+# 配对图片保存配置
+PAIRED_IMAGE_CONFIG = {
+    # 本地存储目录
+    'base_dir': '/home/admin/dsh/paired_images',
+
+    # 清理策略
+    'cleanup_enabled': True,           # 是否启用自动清理
+    'max_batches': 10,                 # 最大保留批次数量
+    'retention_days': 1,                # 保留天数(与 max_batches 互斥,优先按数量清理)
+
+    # 时间窗口(秒):同一窗口内的检测归为一批
+    'time_window': 5.0,
+}

+ 20 - 0
deploy/src/dual_camera_system/config/oss.py

@@ -0,0 +1,20 @@
+"""
+OSS 配置
+兼容 S3 的对象存储配置(MinIO、AWS S3、阿里云 OSS 等)
+"""
+
+# 兼容 S3 的 OSS 配置(如 MinIO、AWS S3、阿里云 OSS)
+S3_COMPATIBLE_CONFIG = {
+    'enabled': True,           # 是否启用 OSS 上传
+    'upload_timeout': 30,       # 上传超时时间(秒)
+    'retry_times': 3,           # 重试次数
+    'custom_domain': '',        # 自定义域名(可选,用于生成访问 URL)
+
+    'endpoint_url': 'http://58.213.48.57:15900',  # 如: http://localhost:9000
+    'region_name': 'us-east-1',
+    'access_key_id': 'wvp',
+    'secret_access_key': '6MnZFxZxRwbvS01khA9ldiawJuc9mytyiq2kEv3k',
+    'bucket_name': 'wvp',
+    'path_prefix': 'device',
+    'use_ssl': False,
+}

+ 13 - 0
deploy/src/dual_camera_system/config/system.py

@@ -0,0 +1,13 @@
+"""系统级功能开关."""
+
+SYSTEM_CONFIG = {
+    'enable_panorama_camera': True,
+    'enable_detection': True,
+}
+
+# 存储配置 (从 device.py 迁移)
+STORAGE_CONFIG = {
+    'base_dir': '/home/admin/dsh',
+    'detection_dir': '/home/admin/dsh/detection_images',
+    'paired_dir': '/home/admin/dsh/paired_images',
+}

+ 0 - 0
deploy/src/dual_camera_system/core/__init__.py


+ 174 - 0
deploy/src/dual_camera_system/core/capture_uploader.py

@@ -0,0 +1,174 @@
+"""检测到人后的保存 + 上传."""
+import logging
+import os
+import threading
+import time
+import uuid
+from typing import Any, Callable, Dict, List, Optional
+import cv2
+import numpy as np
+
+from config.device import DEVICE_CONFIG
+from core.oss_uploader import OSSUploader
+
+logger = logging.getLogger(__name__)
+
+
+class CaptureUploader:
+    def __init__(
+        self,
+        group_id: str,
+        save_dir: str = "data/captures",
+        upload_callback: Optional[Callable[[Dict], None]] = None,
+        oss_uploader: Optional[OSSUploader] = None,
+        dedup_seconds: float = 5.0,
+    ):
+        self.group_id = group_id
+        self.save_dir = os.path.join(save_dir, group_id)
+        os.makedirs(self.save_dir, exist_ok=True)
+        self.upload_callback = upload_callback
+        self.oss_uploader = oss_uploader
+        self.dedup_seconds = dedup_seconds
+        self._last_uploads: List[Dict[str, Any]] = []
+        self._lock = threading.Lock()
+        self._counter = 0
+
+    def _should_upload(self, bbox: List[float]) -> bool:
+        cx = (bbox[0] + bbox[2]) / 2
+        cy = (bbox[1] + bbox[3]) / 2
+        for u in self._last_uploads:
+            dx = abs(u["cx"] - cx)
+            dy = abs(u["cy"] - cy)
+            if dx < 50 and dy < 50:
+                return False
+        return True
+
+    def _validate_inputs(
+        self,
+        frame: np.ndarray,
+        detections: List[Dict],
+    ) -> None:
+        if not isinstance(frame, np.ndarray):
+            raise ValueError("frame must be a numpy ndarray")
+        if frame.ndim != 3 or frame.shape[2] != 3:
+            raise ValueError("frame must have shape (H, W, 3)")
+        if frame.dtype != np.uint8:
+            raise ValueError("frame must have dtype uint8")
+
+        for i, det in enumerate(detections):
+            if not isinstance(det, dict):
+                raise ValueError(f"detection {i} must be a dict")
+            if "bbox" not in det:
+                raise ValueError(f"detection {i} missing bbox")
+            bbox = det["bbox"]
+            if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
+                raise ValueError(f"detection {i} bbox must be a list/tuple of 4 numbers")
+            try:
+                [float(v) for v in bbox]
+            except (TypeError, ValueError):
+                raise ValueError(f"detection {i} bbox must contain numbers")
+            if "confidence" not in det:
+                raise ValueError(f"detection {i} missing confidence")
+            try:
+                float(det["confidence"])
+            except (TypeError, ValueError):
+                raise ValueError(f"detection {i} confidence must be a number")
+
+    def handle_detection(
+        self,
+        camera_type: str,
+        frame: np.ndarray,
+        detections: List[Dict],
+    ) -> List[Dict]:
+        self._validate_inputs(frame, detections)
+
+        if not detections:
+            return []
+
+        with self._lock:
+            now = time.monotonic()
+            self._last_uploads = [
+                u for u in self._last_uploads
+                if now - u["time"] < self.dedup_seconds
+            ]
+
+            upload_decisions = [
+                (det, self._should_upload(det["bbox"]))
+                for det in detections
+            ]
+            to_upload = [det for det, should in upload_decisions if should]
+            if not to_upload:
+                logger.debug("All detections deduplicated; skipping file writes")
+                return []
+
+            self._counter += 1
+            counter = self._counter
+            ts = int(time.time() * 1000)
+            original_path = os.path.join(
+                self.save_dir, f"{ts}_{counter}_original.jpg"
+            )
+            marked_path = os.path.join(
+                self.save_dir, f"{ts}_{counter}_marked.jpg"
+            )
+
+            for det in to_upload:
+                self._last_uploads.append({
+                    "cx": (det["bbox"][0] + det["bbox"][2]) / 2,
+                    "cy": (det["bbox"][1] + det["bbox"][3]) / 2,
+                    "time": time.monotonic(),
+                })
+
+        logger.info("Saving original image to %s", original_path)
+        if not cv2.imwrite(original_path, frame):
+            raise RuntimeError(f"Failed to write {original_path}")
+
+        marked = frame.copy()
+        for det in to_upload:
+            x1, y1, x2, y2 = map(int, det["bbox"])
+            cv2.rectangle(marked, (x1, y1), (x2, y2), (0, 255, 0), 2)
+            cv2.putText(marked, f"{det['confidence']:.2f}", (x1, y1 - 5),
+                        cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
+        logger.info("Saving marked image to %s", marked_path)
+        if not cv2.imwrite(marked_path, marked):
+            raise RuntimeError(f"Failed to write {marked_path}")
+
+        results: List[Dict] = []
+        for det in to_upload:
+            x1, y1, x2, y2 = map(int, det["bbox"])
+            payload = {
+                "group_id": self.group_id,
+                "camera_type": camera_type,
+                "timestamp": ts,
+                "original": original_path,
+                "marked": marked_path,
+                "bbox": [x1, y1, x2, y2],
+                "confidence": det["confidence"],
+            }
+            results.append(payload)
+
+        image_urls = {}
+        if self.oss_uploader is not None and self.oss_uploader.enabled:
+            image_urls = self.oss_uploader.upload_pair(original_path, marked_path, prefix=camera_type)
+            logger.info("OSS URLs: %s", image_urls)
+
+        if self.upload_callback and results:
+            batch_info = {
+                "batch_id": str(uuid.uuid4()),
+                "device_id": DEVICE_CONFIG.get("device_id", "unknown"),
+                "project_id": DEVICE_CONFIG.get("project_id", ""),
+                "timestamp": ts,
+                "camera_type": camera_type,
+                "image_paths": [original_path, marked_path],
+                "image_urls": image_urls,
+                "detections": [
+                    {"bbox": det["bbox"], "confidence": det["confidence"]}
+                    for det in to_upload
+                ],
+            }
+            logger.info("Uploading batch info")
+            try:
+                self.upload_callback(batch_info)
+            except Exception as exc:
+                logger.warning("Upload callback failed: %s", exc)
+
+        return results

+ 18 - 0
deploy/src/dual_camera_system/core/detector_service.py

@@ -0,0 +1,18 @@
+"""复用现有 ObjectDetector 的薄封装."""
+import threading
+
+from config.detection import DETECTION_CONFIG
+from panorama_camera import ObjectDetector
+
+
+class DetectorService:
+    def __init__(self, model_path: str = None, use_gpu: bool = None):
+        model_path = model_path or DETECTION_CONFIG.get("model_path")
+        if use_gpu is None:
+            use_gpu = DETECTION_CONFIG.get("use_gpu", True)
+        self.detector = ObjectDetector(model_path=model_path, use_gpu=use_gpu)
+        self._lock = threading.Lock()
+
+    def detect(self, frame):
+        with self._lock:
+            return self.detector.detect(frame)

+ 202 - 0
deploy/src/dual_camera_system/core/file_cleanup.py

@@ -0,0 +1,202 @@
+"""本地图片文件清理工具."""
+import logging
+import os
+import threading
+import time
+from pathlib import Path
+from typing import Callable, List, Optional
+
+logger = logging.getLogger(__name__)
+
+
+def _file_mtime(path: Path) -> float:
+    """获取文件修改时间,失败返回 0。"""
+    try:
+        return path.stat().st_mtime
+    except OSError:
+        return 0.0
+
+
+def cleanup_directory(
+    directory: str,
+    retention_days: int,
+    max_files: Optional[int] = None,
+    pattern: str = "*",
+    now: Optional[float] = None,
+) -> int:
+    """
+    清理目录中的过期文件。
+
+    规则:
+    1. 删除修改时间超过 retention_days 的文件。
+    2. 如果指定 max_files 且文件总数超过,删除最旧的文件直到数量符合限制。
+
+    Args:
+        directory: 要清理的目录。
+        retention_days: 保留天数。
+        max_files: 最大文件数,None 表示不限制。
+        pattern: 匹配模式,默认匹配所有文件。
+        now: 当前时间戳,默认 time.time()。
+
+    Returns:
+        删除的文件数量。
+    """
+    if retention_days <= 0 and max_files is None:
+        return 0
+
+    dir_path = Path(directory)
+    if not dir_path.exists():
+        return 0
+
+    now = now or time.time()
+    cutoff = now - retention_days * 86400.0
+
+    try:
+        files = [p for p in dir_path.rglob(pattern) if p.is_file()]
+    except OSError as exc:
+        logger.warning("[cleanup] 扫描目录失败: %s, %s", directory, exc)
+        return 0
+
+    deleted = 0
+    for p in files:
+        mtime = _file_mtime(p)
+        if retention_days > 0 and mtime < cutoff:
+            try:
+                p.unlink()
+                deleted += 1
+                logger.debug("[cleanup] 删除过期文件: %s", p)
+            except OSError as exc:
+                logger.warning("[cleanup] 删除文件失败: %s, %s", p, exc)
+
+    # 如果仍然超过最大数量,按修改时间删除最旧的
+    if max_files is not None and max_files > 0:
+        try:
+            remaining = sorted(
+                [p for p in dir_path.rglob(pattern) if p.is_file()],
+                key=_file_mtime,
+            )
+        except OSError:
+            remaining = []
+        while len(remaining) > max_files:
+            oldest = remaining.pop(0)
+            try:
+                oldest.unlink()
+                deleted += 1
+                logger.debug("[cleanup] 删除超量旧文件: %s", oldest)
+            except OSError as exc:
+                logger.warning("[cleanup] 删除文件失败: %s, %s", oldest, exc)
+                break
+
+    if deleted:
+        logger.info("[cleanup] %s 清理完成,删除 %d 个文件", directory, deleted)
+    return deleted
+
+
+class CleanupWorker:
+    """定时清理 worker,每个实例管理一个目录。"""
+
+    def __init__(
+        self,
+        directory: str,
+        retention_days: int,
+        max_files: Optional[int] = None,
+        interval_seconds: float = 3600.0,
+        pattern: str = "*",
+    ):
+        self.directory = directory
+        self.retention_days = retention_days
+        self.max_files = max_files
+        self.interval_seconds = interval_seconds
+        self.pattern = pattern
+        self._stop_event = threading.Event()
+        self._thread: Optional[threading.Thread] = None
+
+    def start(self) -> None:
+        if self._thread is not None and self._thread.is_alive():
+            return
+        self._stop_event.clear()
+        self._thread = threading.Thread(target=self._loop, daemon=True)
+        self._thread.start()
+        logger.info(
+            "[cleanup] worker 启动: %s, retention=%d天, max=%s, interval=%.0fs",
+            self.directory,
+            self.retention_days,
+            self.max_files,
+            self.interval_seconds,
+        )
+
+    def stop(self) -> None:
+        self._stop_event.set()
+        if self._thread is not None:
+            self._thread.join(timeout=2.0)
+
+    def run_once(self) -> int:
+        return cleanup_directory(
+            self.directory,
+            self.retention_days,
+            self.max_files,
+            self.pattern,
+        )
+
+    def _loop(self) -> None:
+        while not self._stop_event.is_set():
+            try:
+                self.run_once()
+            except Exception as exc:
+                logger.error("[cleanup] 清理异常: %s", exc)
+            # 等待下一次清理或停止信号
+            self._stop_event.wait(timeout=self.interval_seconds)
+
+
+def make_cleanup_workers(
+    storage_config: dict,
+    group_ids: List[str],
+    base_path: str = ".",
+) -> List[CleanupWorker]:
+    """
+    根据 STORAGE_CONFIG 为 captures 和 previews 创建清理 worker。
+
+    Args:
+        storage_config: STORAGE_CONFIG 字典。
+        group_ids: 摄像头组 ID 列表,用于为 captures 每组创建 worker。
+        base_path: 项目根目录,相对路径基于此。
+
+    Returns:
+        CleanupWorker 列表。
+    """
+    workers: List[CleanupWorker] = []
+
+    captures_cfg = storage_config.get("captures", {})
+    captures_base = captures_cfg.get("base_dir", "data/captures")
+    captures_retention = captures_cfg.get("retention_days", 7)
+    captures_max = captures_cfg.get("max_files")
+    captures_interval = captures_cfg.get("cleanup_interval_seconds", 3600)
+
+    for gid in group_ids:
+        workers.append(
+            CleanupWorker(
+                directory=os.path.join(base_path, captures_base, gid),
+                retention_days=captures_retention,
+                max_files=captures_max,
+                interval_seconds=captures_interval,
+                pattern="*",
+            )
+        )
+
+    previews_cfg = storage_config.get("previews", {})
+    previews_base = previews_cfg.get("base_dir", "data/previews")
+    previews_retention = previews_cfg.get("retention_days", 7)
+    previews_max = previews_cfg.get("max_files")
+    previews_interval = previews_cfg.get("cleanup_interval_seconds", 3600)
+
+    workers.append(
+        CleanupWorker(
+            directory=os.path.join(base_path, previews_base),
+            retention_days=previews_retention,
+            max_files=previews_max,
+            interval_seconds=previews_interval,
+            pattern="*",
+        )
+    )
+
+    return workers

+ 77 - 0
deploy/src/dual_camera_system/core/group_state.py

@@ -0,0 +1,77 @@
+"""各摄像头组运行时共享状态."""
+import copy
+import logging
+import threading
+from typing import Any, Dict
+
+
+class GroupState:
+    def __init__(self):
+        self._data: Dict[str, Dict[str, Any]] = {}
+        self._lock = threading.Lock()
+
+    def reset(self):
+        """重置所有状态(主要用于测试)."""
+        with self._lock:
+            self._data.clear()
+
+    def init_group(self, group_id: str, panorama_rtsp: str, ptz_rtsp: str, ptz_config: Dict):
+        with self._lock:
+            if group_id in self._data:
+                raise ValueError(f"Group {group_id} already initialized")
+            self._data[group_id] = {
+                "panorama_rtsp": panorama_rtsp,
+                "ptz_rtsp": ptz_rtsp,
+                "ptz_position": {"pan": 0.0, "tilt": 0.0, "zoom": 1},
+                "polling_state": "idle",  # idle|scanning|polling|paused
+                "scan_progress": {"total": 0, "current": 0, "state": "idle"},
+                "last_detection": None,
+                "logs": [],
+            }
+
+    def get(self, group_id: str) -> Dict[str, Any]:
+        with self._lock:
+            return copy.deepcopy(self._data.get(group_id, {}))
+
+    def list_groups(self) -> list:
+        with self._lock:
+            return list(self._data.keys())
+
+    def update(self, group_id: str, key: str, value: Any):
+        with self._lock:
+            if group_id not in self._data:
+                logging.warning("Cannot update unknown group: %s", group_id)
+                return
+            self._data[group_id][key] = value
+
+    def compare_and_update(
+        self, group_id: str, key: str, expected: Any, new_value: Any
+    ) -> bool:
+        with self._lock:
+            if group_id not in self._data:
+                return False
+            if self._data[group_id].get(key) != expected:
+                return False
+            self._data[group_id][key] = new_value
+            return True
+
+    def update_nested(self, group_id: str, key: str, sub_key: str, value: Any):
+        with self._lock:
+            if group_id not in self._data:
+                raise KeyError(f"Group {group_id} not initialized")
+            if key not in self._data[group_id] or not isinstance(self._data[group_id][key], dict):
+                raise KeyError(f"Key {key} is not a dict")
+            self._data[group_id][key][sub_key] = value
+
+    def append_log(self, group_id: str, message: str):
+        with self._lock:
+            if group_id not in self._data:
+                logging.warning("Cannot append log to unknown group: %s", group_id)
+                return
+            logs = self._data[group_id]["logs"]
+            logs.append(message)
+            self._data[group_id]["logs"] = logs[-200:]
+
+
+# 全局单例(保持 import 兼容性;测试可调用 reset() 重置)
+group_state = GroupState()

+ 107 - 0
deploy/src/dual_camera_system/core/oss_uploader.py

@@ -0,0 +1,107 @@
+"""兼容 S3 的对象存储上传模块."""
+import logging
+import os
+import time
+import uuid
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+
+class OSSUploader:
+    """基于 boto3 的 S3 兼容对象存储上传器."""
+
+    def __init__(self, config: Optional[dict] = None):
+        from config.oss import S3_COMPATIBLE_CONFIG
+
+        self.config = config or S3_COMPATIBLE_CONFIG
+        self.enabled = self.config.get("enabled", False)
+        self.client = None
+        self.bucket = self.config.get("bucket_name", "")
+        self.path_prefix = self.config.get("path_prefix", "device")
+        self.custom_domain = self.config.get("custom_domain", "")
+        self.endpoint_url = self.config.get("endpoint_url", "")
+        self.retry_times = self.config.get("retry_times", 3)
+        self.upload_timeout = self.config.get("upload_timeout", 30)
+
+        if self.enabled:
+            try:
+                import boto3
+                from botocore.config import Config
+
+                s3_config = Config(
+                    s3={"addressing_style": "path"},
+                    signature_version="s3v4",
+                    retries={"max_attempts": self.retry_times, "mode": "standard"},
+                )
+                self.client = boto3.client(
+                    "s3",
+                    endpoint_url=self.endpoint_url,
+                    region_name=self.config.get("region_name", "us-east-1"),
+                    aws_access_key_id=self.config.get("access_key_id", ""),
+                    aws_secret_access_key=self.config.get("secret_access_key", ""),
+                    config=s3_config,
+                    verify=False,
+                )
+                logger.info("[OSS] 上传器初始化完成: %s/%s", self.endpoint_url, self.bucket)
+            except Exception as exc:
+                logger.error("[OSS] 初始化失败: %s", exc)
+                self.enabled = False
+
+    def _object_key(self, local_path: str, suffix: str = "") -> str:
+        """生成对象存储 key."""
+        filename = Path(local_path).name
+        if suffix:
+            stem = Path(filename).stem
+            ext = Path(filename).suffix
+            filename = f"{stem}_{suffix}{ext}"
+        date_dir = time.strftime("%Y%m%d")
+        unique = uuid.uuid4().hex[:8]
+        return f"{self.path_prefix}/{date_dir}/{unique}_{filename}"
+
+    def _make_url(self, key: str) -> str:
+        """根据 endpoint 或自定义域名生成访问 URL."""
+        if self.custom_domain:
+            base = self.custom_domain.rstrip("/")
+            return f"{base}/{key}"
+        base = self.endpoint_url.rstrip("/")
+        return f"{base}/{self.bucket}/{key}"
+
+    def upload(self, local_path: str, suffix: str = "") -> Optional[str]:
+        """上传单个文件到 OSS,返回可访问 URL."""
+        if not self.enabled or self.client is None:
+            return None
+
+        if not os.path.isfile(local_path):
+            logger.warning("[OSS] 文件不存在: %s", local_path)
+            return None
+
+        key = self._object_key(local_path, suffix)
+        for attempt in range(1, self.retry_times + 1):
+            try:
+                self.client.upload_file(
+                    local_path,
+                    self.bucket,
+                    key,
+                    ExtraArgs={"ContentType": "image/jpeg"},
+                )
+                url = self._make_url(key)
+                logger.info("[OSS] 上传成功: %s -> %s", local_path, url)
+                return url
+            except Exception as exc:
+                logger.warning("[OSS] 上传失败 (尝试 %d/%d): %s - %s", attempt, self.retry_times, local_path, exc)
+                if attempt < self.retry_times:
+                    time.sleep(0.5 * attempt)
+
+        logger.error("[OSS] 上传最终失败: %s", local_path)
+        return None
+
+    def upload_pair(self, original_path: str, marked_path: str, prefix: str = "") -> dict:
+        """上传原图和标注图,返回 URL 字典."""
+        orig_suffix = f"original_{prefix}" if prefix else "original"
+        mark_suffix = f"marked_{prefix}" if prefix else "marked"
+        return {
+            "original": self.upload(original_path, suffix=orig_suffix),
+            "marked": self.upload(marked_path, suffix=mark_suffix),
+        }

+ 147 - 0
deploy/src/dual_camera_system/core/stream_manager.py

@@ -0,0 +1,147 @@
+"""多路 RTSP 取流管理 + MJPEG 输出."""
+import logging
+import threading
+import time
+from typing import Dict, Optional, Callable
+import cv2
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+class CameraStream:
+    """单个摄像头的 RTSP 流封装."""
+
+    def __init__(self, name: str, rtsp_url: str, reconnect_delay: float = 1.0):
+        self.name = name
+        self.rtsp_url = rtsp_url
+        self.reconnect_delay = reconnect_delay
+        self.latest_frame: Optional[np.ndarray] = None
+        self.marked_frame: Optional[np.ndarray] = None
+        self._stop_event = threading.Event()
+        self.thread: Optional[threading.Thread] = None
+        self.lock = threading.Lock()
+        self._last_error: Optional[str] = None
+
+    def start(self):
+        if self.thread and self.thread.is_alive():
+            return
+        self._stop_event.clear()
+        self.thread = threading.Thread(target=self._worker, daemon=True)
+        self.thread.start()
+
+    def stop(self):
+        self._stop_event.set()
+        if self.thread:
+            self.thread.join(timeout=5.0)
+            self.thread = None
+
+    def _worker(self):
+        # OpenCV read timeouts are best-effort and depend on the video backend;
+        # cap.set() may return False on some backends (e.g. FFmpeg) and is ignored.
+        reconnect_delay = self.reconnect_delay
+        cap = None
+        try:
+            while not self._stop_event.is_set():
+                try:
+                    if cap is not None:
+                        cap.release()
+                    cap = cv2.VideoCapture(self.rtsp_url)
+                    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+                    if hasattr(cv2, 'CAP_PROP_OPEN_TIMEOUT_MSEC'):
+                        if not cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 10000):
+                            logger.warning("[%s] CAP_PROP_OPEN_TIMEOUT_MSEC not supported", self.name)
+                    if hasattr(cv2, 'CAP_PROP_READ_TIMEOUT_MSEC'):
+                        if not cap.set(cv2.CAP_PROP_READ_TIMEOUT_MSEC, 10000):
+                            logger.warning("[%s] CAP_PROP_READ_TIMEOUT_MSEC not supported", self.name)
+                    if not cap.isOpened():
+                        raise RuntimeError(f"Cannot open {self.rtsp_url}")
+                    while not self._stop_event.is_set():
+                        ret, frame = cap.read()
+                        if not ret:
+                            raise RuntimeError("Frame read failed")
+                        with self.lock:
+                            self.latest_frame = frame
+                        reconnect_delay = self.reconnect_delay
+                except Exception as e:
+                    with self.lock:
+                        self._last_error = str(e)
+                    time.sleep(reconnect_delay)
+                    reconnect_delay = min(reconnect_delay * 2, 30.0)
+        finally:
+            if cap is not None:
+                cap.release()
+
+    def get_frame(self) -> Optional[np.ndarray]:
+        with self.lock:
+            return self.latest_frame.copy() if self.latest_frame is not None else None
+
+    def set_marked_frame(self, frame: np.ndarray):
+        with self.lock:
+            self.marked_frame = frame.copy()
+
+    def get_marked_frame(self) -> Optional[np.ndarray]:
+        with self.lock:
+            return self.marked_frame.copy() if self.marked_frame is not None else None
+
+    @property
+    def last_error(self) -> Optional[str]:
+        with self.lock:
+            return self._last_error
+
+
+class StreamManager:
+    def __init__(self):
+        self._streams: Dict[str, CameraStream] = {}
+        self._lock = threading.Lock()
+
+    def register(self, stream_id: str, rtsp_url: str, reconnect_delay: float = 1.0) -> CameraStream:
+        with self._lock:
+            if stream_id not in self._streams:
+                stream = CameraStream(stream_id, rtsp_url, reconnect_delay=reconnect_delay)
+                self._streams[stream_id] = stream
+                stream.start()
+            return self._streams[stream_id]
+
+    def unregister(self, stream_id: str):
+        with self._lock:
+            stream = self._streams.pop(stream_id, None)
+            if stream:
+                stream.stop()
+
+    def get(self, stream_id: str) -> Optional[CameraStream]:
+        with self._lock:
+            return self._streams.get(stream_id)
+
+    def stop_all(self):
+        with self._lock:
+            streams = list(self._streams.values())
+            self._streams.clear()
+        for s in streams:
+            s.stop()
+
+
+def encode_mjpeg_frame(frame: np.ndarray, quality: int = 80) -> bytes:
+    """把 numpy 帧编码为 JPEG bytes。"""
+    encode_params = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
+    ret, buf = cv2.imencode(".jpg", frame, encode_params)
+    if not ret:
+        raise RuntimeError("JPEG encode failed")
+    return buf.tobytes()
+
+
+def generate_mjpeg_stream(get_frame: Callable[[], Optional[np.ndarray]], fps: int = 15):
+    """MJPEG 流生成器,用于 FastAPI StreamingResponse。"""
+    period = 1.0 / fps
+    while True:
+        frame = get_frame()
+        if frame is None:
+            time.sleep(period)
+            continue
+        jpeg = encode_mjpeg_frame(frame)
+        yield (
+            b"--frame\r\n"
+            b"Content-Type: image/jpeg\r\n"
+            b"Content-Length: " + str(len(jpeg)).encode() + b"\r\n\r\n" + jpeg + b"\r\n"
+        )
+        time.sleep(period)

+ 212 - 0
deploy/src/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

+ 37 - 0
deploy/src/dual_camera_system/main.py

@@ -0,0 +1,37 @@
+"""FastAPI 入口."""
+import argparse
+import os
+
+import uvicorn
+from app import create_app
+
+
+def main():
+    parser = argparse.ArgumentParser(description="PTZ 360 scan polling system")
+    parser.add_argument("--demo", action="store_true", help="Demo mode (no-op currently)")
+    parser.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
+    parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8000)))
+    parser.add_argument("--model", default=None, help="Path to detection model")
+    parser.add_argument(
+        "--model-size", default=None, choices=["n", "s", "m", "l", "x"],
+        help="YOLO11 model size",
+    )
+    parser.add_argument("--no-gpu", action="store_true", help="Disable GPU/NPU")
+    args = parser.parse_args()
+
+    # Forward model args to detection config when provided
+    if args.model or args.model_size or args.no_gpu:
+        from config import detection
+        if args.model:
+            detection.DETECTION_CONFIG["model_path"] = args.model
+        if args.model_size:
+            detection.DETECTION_CONFIG["model_size"] = args.model_size
+        if args.no_gpu:
+            detection.DETECTION_CONFIG["use_gpu"] = False
+
+    app = create_app()
+    uvicorn.run(app, host=args.host, port=args.port)
+
+
+if __name__ == "__main__":
+    main()

+ 1270 - 0
deploy/src/dual_camera_system/panorama_camera.py

@@ -0,0 +1,1270 @@
+"""
+全景摄像头模块
+负责获取视频流和物体检测
+"""
+
+import os
+# 必须在导入cv2之前设置,防止FFmpeg多线程解码崩溃
+# pthread_frame.c:167 async_lock assertion
+os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
+
+import cv2
+import numpy as np
+import threading
+import queue
+import time
+import logging
+from datetime import datetime
+from typing import Optional, List, Tuple, Dict, Any
+from dataclasses import dataclass
+from pathlib import Path
+
+from config import DETECTION_CONFIG
+from config.camera import parse_resolution
+from video_lock import safe_read, safe_is_opened
+from inference_backend import nms
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class DetectedObject:
+    """检测到的物体"""
+    class_name: str          # 类别名称
+    confidence: float        # 置信度
+    bbox: Tuple[int, int, int, int]  # 边界框 (x, y, width, height)
+    center: Tuple[int, int]  # 中心点坐标
+    track_id: Optional[int] = None  # 跟踪ID
+
+
+class PanoramaCamera:
+    """全景摄像头类"""
+
+    def __init__(self, camera_config: Dict = None):
+        """
+        初始化全景摄像头
+        Args:
+            camera_config: 摄像头配置
+        """
+        self.config = camera_config or {}
+
+        # 解析期望分辨率
+        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  # 重连间隔(秒)
+        self.max_reconnect_attempts = 3  # 最大重连次数
+    
+    def connect(self) -> bool:
+        """
+        连接摄像头
+        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.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.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
+    
+    def start_stream_rtsp(self, rtsp_url: str = None) -> bool:
+        if rtsp_url is None:
+            rtsp_url = self.config.get('rtsp_url') or f"rtsp://{self.config['username']}:{self.config['password']}@{self.config['ip']}:{self.config.get('rtsp_port', 554)}/h264/ch{self.config['channel']}/main/av_stream"
+        
+        try:
+            # 先尝试FFmpeg后端
+            self.rtsp_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
+            if not self.rtsp_cap.isOpened():
+                # FFmpeg失败,尝试GStreamer后端
+                print(f"FFmpeg后端无法打开RTSP流,尝试GStreamer后端...")
+                try:
+                    gst_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_GSTREAMER)
+                    if gst_cap.isOpened():
+                        self.rtsp_cap = gst_cap
+                        print(f"使用GStreamer后端打开RTSP流成功")
+                    else:
+                        print(f"无法打开RTSP流: {rtsp_url}")
+                        return False
+                except Exception as ge:
+                    print(f"GStreamer后端也不可用: {ge}")
+                    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} (期望分辨率 {self.frame_width}x{self.frame_height})")
+            return True
+        except Exception as e:
+            print(f"RTSP流启动失败: {e}")
+            return False
+    
+    def _stream_worker(self):
+        """视频流工作线程 (SDK模式)"""
+        retry_count = 0
+        max_retries = 10
+        
+        while self.running:
+            try:
+                # 尝试从 SDK 帧缓冲区获取帧 (如果可用)
+                frame_buffer = self.sdk.get_video_frame_buffer(self.config['channel'])
+                
+                if frame_buffer:
+                    frame_info = frame_buffer.get(timeout=0.1)
+                    if frame_info and frame_info.get('data'):
+                        # 解码帧数据 (如果需要)
+                        # 注意: SDK回调返回的是编码数据,需要解码
+                        # 这里暂时跳过,因为解码需要额外处理
+                        pass
+                
+                # RTSP 模式获取帧 (推荐方式)
+                if self.rtsp_cap is not None and safe_is_opened(self.rtsp_cap, self._camera_id):
+                    ret, frame = safe_read(self.rtsp_cap, self._camera_id)
+                    if ret and frame is not None:
+                        with self.frame_lock:
+                            self.current_frame = frame.copy()
+                        
+                        try:
+                            self.frame_queue.put(frame.copy(), block=False)
+                        except queue.Full:
+                            pass
+                        
+                        retry_count = 0  # 重置重试计数
+                        time.sleep(0.001)  # 减少CPU占用
+                        continue
+                
+                # 如果 RTSP 不可用,尝试自动连接
+                if retry_count < max_retries:
+                    rtsp_url = self._build_rtsp_url()
+                    try:
+                        if self.rtsp_cap is None:
+                            self.rtsp_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
+                            self.rtsp_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)  # 减少缓冲延迟
+                        
+                        if safe_is_opened(self.rtsp_cap, self._camera_id):
+                            retry_count = 0
+                            continue
+                    except Exception as e:
+                        pass
+                    
+                    retry_count += 1
+                    time.sleep(1.0)  # 重试间隔
+                else:
+                    # 超过最大重试次数,使用与配置分辨率一致的模拟帧
+                    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:
+                err_str = str(e)
+                if 'async_lock' in err_str or 'Assertion' in err_str:
+                    print(f"视频流FFmpeg内部错误,重建连接: {e}")
+                    self._reconnect_rtsp()
+                else:
+                    print(f"视频流错误: {e}")
+                time.sleep(0.5)
+    
+    def _build_rtsp_url(self) -> str:
+        return self.config.get('rtsp_url') or f"rtsp://{self.config['username']}:{self.config['password']}@{self.config['ip']}:{self.config.get('rtsp_port', 554)}/h264/ch{self.config['channel']}/main/av_stream"
+    
+    def _rtsp_stream_worker(self):
+        """RTSP视频流工作线程"""
+        import signal
+        # 屏蔽SIGINT在此线程,由主线程处理
+        if hasattr(signal, 'pthread_sigmask'):
+            try:
+                signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
+            except (AttributeError, OSError):
+                pass
+        
+        max_consecutive_errors = 50
+        error_count = 0
+        
+        while self.running:
+            try:
+                if self.rtsp_cap is None or not safe_is_opened(self.rtsp_cap, self._camera_id):
+                    time.sleep(0.1)
+                    continue
+                
+                ret, frame = safe_read(self.rtsp_cap, self._camera_id)
+                if not ret or frame is None:
+                    error_count += 1
+                    if error_count > max_consecutive_errors:
+                        print(f"全景RTSP流连续{max_consecutive_errors}次读取失败,尝试重连...")
+                        self._reconnect_rtsp()
+                        error_count = 0
+                    time.sleep(0.01)
+                    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:
+                    pass
+                    
+            except Exception as e:
+                err_str = str(e)
+                if 'async_lock' in err_str or 'Assertion' in err_str:
+                    print(f"全景RTSP流FFmpeg内部错误,3秒后重建连接: {e}")
+                    time.sleep(3)
+                    self._reconnect_rtsp()
+                else:
+                    print(f"全景RTSP视频流错误: {e}")
+                    time.sleep(0.5)
+    
+    def _reconnect_rtsp(self):
+        """重建RTSP连接"""
+        rtsp_url = self._build_rtsp_url()
+        if self.rtsp_cap is not None:
+            try:
+                self.rtsp_cap.release()
+            except Exception:
+                pass
+            self.rtsp_cap = None
+        
+        time.sleep(1)
+        
+        try:
+            self.rtsp_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
+            if safe_is_opened(self.rtsp_cap, self._camera_id):
+                self.rtsp_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
+                print("全景RTSP流重连成功")
+            else:
+                print("全景RTSP流重连失败")
+                self.rtsp_cap = None
+        except Exception as e:
+            print(f"全景RTSP流重连异常: {e}")
+            self.rtsp_cap = None
+    
+    def stop_stream(self):
+        """停止视频流"""
+        self.running = False
+        if self.stream_thread:
+            self.stream_thread.join(timeout=2)
+        if self.play_handle:
+            self.sdk.stop_real_play(self.play_handle)
+            self.play_handle = None
+        if self.rtsp_cap:
+            self.rtsp_cap.release()
+            self.rtsp_cap = None
+    
+    def get_frame(self) -> Optional[np.ndarray]:
+        """
+        获取当前帧
+        Returns:
+            当前帧图像
+        """
+        with self.frame_lock:
+            return self.current_frame.copy() if self.current_frame is not None else None
+    
+    def get_frame_from_queue(self, timeout: float = 0.1) -> Optional[np.ndarray]:
+        """
+        从帧队列获取帧 (用于批量处理)
+        Args:
+            timeout: 等待超时时间
+        Returns:
+            帧图像或None
+        """
+        try:
+            return self.frame_queue.get(timeout=timeout)
+        except:
+            return None
+    
+    def get_frame_buffer(self, count: int = 5) -> List[np.ndarray]:
+        """
+        获取帧缓冲 (用于运动检测等需要多帧的场景)
+        Args:
+            count: 获取帧数
+        Returns:
+            帧列表
+        """
+        frames = []
+        while len(frames) < count:
+            frame = self.get_frame_from_queue(timeout=0.05)
+            if frame is not None:
+                frames.append(frame)
+            else:
+                break
+        return frames
+    
+    def set_detector(self, detector):
+        """设置物体检测器"""
+        self.detector = detector
+    
+    def detect_objects(self, frame: np.ndarray = None) -> List[DetectedObject]:
+        """
+        检测物体
+        Args:
+            frame: 输入帧,如果为None则使用当前帧
+        Returns:
+            检测到的物体列表
+        """
+        if frame is None:
+            frame = self.get_frame()
+        
+        if frame is None or self.detector is None:
+            return []
+        
+        return self.detector.detect(frame)
+    
+    def get_detection_position(self, obj: DetectedObject, 
+                               frame_size: Tuple[int, int]) -> Tuple[float, float]:
+        """
+        获取检测物体在画面中的相对位置
+        Args:
+            obj: 检测到的物体
+            frame_size: 画面尺寸 (width, height)
+        Returns:
+            相对位置 (x_ratio, y_ratio) 范围0-1
+        """
+        width, height = frame_size
+        x_ratio = obj.center[0] / width
+        y_ratio = obj.center[1] / height
+        return (x_ratio, y_ratio)
+
+
+class ObjectDetector:
+    """
+    物体检测器
+    使用YOLO11模型进行人体检测
+    支持 YOLO (.pt), RKNN (.rknn), ONNX (.onnx) 模型
+    """
+    
+    def __init__(self, model_path: str = None, use_gpu: bool = True, model_size: str = 'n',
+                 model_type: str = 'auto'):
+        """
+        初始化检测器
+        Args:
+            model_path: 模型路径 (支持 .pt, .rknn, .onnx)
+            use_gpu: 是否使用GPU
+            model_size: 模型尺寸 ('n', 's', 'm', 'l', 'x') - 仅 YOLO 模型有效
+            model_type: 模型类型 ('auto', 'yolo', 'rknn', 'onnx')
+        """
+        self.model = None
+        self.rknn_detector = None
+        self.model_path = model_path
+        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'
+        
+        # 检测图片保存配置
+        self._save_image_enabled = self.config.get('save_detection_image', False)
+        self._image_save_dir = Path(self.config.get('detection_image_dir', './detection_images'))
+        self._image_max_count = self.config.get('detection_image_max_count', 1000)
+        self._last_save_time = 0
+        # 保存间隔:优先使用配置值,否则基于检测帧率计算(检测间隔的1.5倍)
+        detection_fps = self.config.get('detection_fps', 2)
+        self._save_interval = self.config.get('save_interval', 1.5 / detection_fps)
+        
+        # 创建保存目录
+        if self._save_image_enabled:
+            self._ensure_save_dir()
+        
+        # 根据扩展名自动判断模型类型
+        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'
+            # 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()
+    
+    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:
+            from rknnlite.api import RKNNLite
+            
+            self.rknn = RKNNLite()
+            ret = self.rknn.load_rknn(self.model_path)
+            if ret != 0:
+                raise RuntimeError(f"加载 RKNN 模型失败: {self.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 模型加载成功: {self.model_path}")
+        except ImportError:
+            raise ImportError("未安装 rknnlite,请运行: pip install rknnlite2")
+    
+    def _load_onnx_model(self):
+        """加载 ONNX 模型(优先 ONNX Runtime,回退 Ultralytics)"""
+        if not self.model_path:
+            raise ValueError("ONNX 模型需要指定 model_path")
+
+        # 尝试直接使用 ONNX Runtime(end2end 格式)
+        try:
+            import onnxruntime as ort
+            available = ort.get_available_providers()
+            providers = [p for p in ['CPUExecutionProvider'] if p in available]
+            session = ort.InferenceSession(self.model_path, providers=providers)
+            self.session = session
+            self.input_name = session.get_inputs()[0].name
+            self.is_end2end = True  # 导出的 ONNX 为 end2end 格式 (1, 300, 6)
+            print(f"ONNX 模型加载成功: {self.model_path} (via ONNX Runtime, providers={providers})")
+            return
+        except Exception as e:
+            print(f"ONNX Runtime 加载失败,尝试 Ultralytics: {e}")
+
+        try:
+            from ultralytics import YOLO
+            self.model = YOLO(self.model_path, task='detect')
+            self.device = 'cpu'
+            print(f"ONNX 模型加载成功: {self.model_path} (via Ultralytics, device=cpu)")
+        except ImportError:
+            raise ImportError("未安装 ultralytics")
+    
+    def _load_yolo_model(self):
+        """加载YOLO11检测模型"""
+        try:
+            from ultralytics import YOLO
+            
+            if self.model_path:
+                self.model = YOLO(self.model_path)
+            else:
+                model_name = f'yolo11{self.model_size}.pt'
+                self.model = YOLO(model_name)
+            
+            dummy = np.zeros((640, 640, 3), dtype=np.uint8)
+            self.model(dummy, device=self.device, verbose=False)
+            
+            print(f"成功加载YOLO11检测模型 (device={self.device})")
+        except ImportError:
+            print("未安装ultralytics,请运行: pip install ultralytics")
+            self._load_opencv_model()
+        except Exception as e:
+            print(f"加载YOLO11模型失败: {e}")
+            self._load_opencv_model()
+    
+    def _load_opencv_model(self):
+        """使用OpenCV加载模型"""
+        pass
+    
+    def _ensure_save_dir(self):
+        """确保保存目录存在"""
+        try:
+            self._image_save_dir.mkdir(parents=True, exist_ok=True)
+            logger.info(f"检测图片保存目录: {self._image_save_dir}")
+        except Exception as e:
+            logger.error(f"创建检测图片目录失败: {e}")
+            self._save_image_enabled = False
+    
+    def _cleanup_old_images(self):
+        """清理旧图片,保持目录下图片数量不超过上限"""
+        try:
+            image_files = list(self._image_save_dir.glob("*.jpg"))
+            if len(image_files) > self._image_max_count:
+                # 按修改时间排序,删除最旧的
+                image_files.sort(key=lambda x: x.stat().st_mtime)
+                to_delete = image_files[:len(image_files) - self._image_max_count]
+                for f in to_delete:
+                    f.unlink()
+                logger.info(f"已清理 {len(to_delete)} 张旧检测图片")
+        except Exception as e:
+            logger.error(f"清理旧图片失败: {e}")
+    
+    def _save_detection_image(self, frame: np.ndarray, detections: List[DetectedObject]):
+        """
+        保存带有检测标记的图片(只标记达到置信度阈值的人)
+        Args:
+            frame: 原始图像
+            detections: 检测结果列表
+        """
+        if not self._save_image_enabled or not detections:
+            return
+        
+        # 检查保存间隔
+        current_time = time.time()
+        if current_time - self._last_save_time < self._save_interval:
+            return
+        
+        try:
+            # 复制图像避免修改原图
+            marked_frame = frame.copy()
+            
+            # 置信度阈值(人员检测用更高阈值)
+            person_threshold = self.config.get('person_threshold', 0.8)
+            
+            # 只标记达到阈值的人
+            person_count = 0
+            
+            for det in detections:
+                # 只处理人且达到阈值
+                is_person = det.class_name in ['person']
+                if not is_person:
+                    continue
+                
+                # 未达阈值的不标记
+                if det.confidence < person_threshold:
+                    continue
+                
+                x, y, w, h = det.bbox
+                
+                # 绘制边界框(绿色)
+                cv2.rectangle(marked_frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
+                
+                # 绘制序号标签
+                label = f"person_{person_count}"
+                person_count += 1
+                
+                (label_w, label_h), baseline = cv2.getTextSize(
+                    label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2
+                )
+                cv2.rectangle(
+                    marked_frame, 
+                    (x, y - label_h - 8),
+                    (x + label_w, y),
+                    (0, 255, 0), 
+                    -1
+                )
+                
+                # 绘制标签文字(黑色)
+                cv2.putText(
+                    marked_frame, label,
+                    (x, y - 4),
+                    cv2.FONT_HERSHEY_SIMPLEX, 0.8,
+                    (0, 0, 0), 2
+                )
+            
+            
+            # 无有效目标则不保存
+            if person_count == 0:
+                return
+            
+            
+            # 生成文件名(时间戳+有效人数)
+            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+            filename = f"panorama_{timestamp}_n{person_count}.png"
+            filepath = self._image_save_dir / filename
+            
+            # 保存图片(PNG无损格式,不压缩)
+            cv2.imwrite(str(filepath), marked_frame)
+            self._last_save_time = current_time
+            
+            logger.info(f"[全景] 已保存检测图片: {filepath},有效人数 {person_count} (阈值={person_threshold})")
+            
+            # 定期清理旧图片
+            self._cleanup_old_images()
+            
+        except Exception as e:
+            logger.error(f"[全景] 保存检测图片失败: {e}")
+    
+    def _letterbox(self, image, size=(640, 640)):
+        """Letterbox 预处理"""
+        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, 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 模型检测"""
+        results = []
+        h0, w0 = frame.shape[:2]
+
+        # 宽幅图(宽高比>1.5)使用分区域检测,避免letterbox后人太小
+        if w0 / h0 > 1.5:
+            return self._detect_rknn_tiled(frame)
+
+        try:
+            conf_threshold = self.config['confidence_threshold']
+            class_map = self.config.get('class_map', {0: 'person'})
+
+            # -------------------------------------------------------
+            # end2end 模型(内置NMS):letterbox + NHWC 预处理
+            # 输出格式 (N, max_det, 6) = (x1,y1,x2,y2,conf,cls) 在 letterbox 空间
+            # -------------------------------------------------------
+            if self.is_end2end:
+                canvas, scale, pad_w, pad_h, h0, w0 = self._letterbox(frame)
+                img = canvas[..., ::-1].astype(np.float32) / 255.0
+                blob = img[None, ...]  # NHWC
+
+                if hasattr(self, 'rknn'):
+                    outputs = self.rknn.inference(inputs=[blob])
+                else:
+                    # ONNX 通常期望 NCHW,需转置
+                    nchw = blob.transpose(0, 3, 1, 2)
+                    outputs = self.session.run(None, {self.input_name: nchw})
+
+                output = outputs[0]
+                if len(output.shape) == 3:
+                    output = output[0]
+
+                for i in range(output.shape[0]):
+                    x1_lb, y1_lb, x2_lb, y2_lb, conf, cls_id = output[i]
+                    if not np.isfinite(conf) or conf < conf_threshold:
+                        continue
+                    if not (np.isfinite(x1_lb) and np.isfinite(y1_lb) and np.isfinite(x2_lb) and np.isfinite(y2_lb)):
+                        continue
+                    if x1_lb >= x2_lb or y1_lb >= y2_lb:
+                        continue
+                    cls_name = class_map.get(int(cls_id), str(int(cls_id)))
+                    if cls_name not in self.config['target_classes']:
+                        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))
+
+                    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'):
+                # RKNN
+                img = canvas[..., ::-1].astype(np.float32) / 255.0
+                blob = img[None, ...]
+                outputs = self.rknn.inference(inputs=[blob])
+            else:
+                # ONNX
+                img = canvas[..., ::-1].astype(np.float32) / 255.0
+                img = img.transpose(2, 0, 1)
+                blob = img[None, ...]
+                outputs = self.session.run(None, {self.input_name: blob})
+
+            # 根据输出数量判断格式: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:
+                output = outputs[0]
+                if len(output.shape) == 3:
+                    output = output[0]
+
+                num_boxes = output.shape[1]
+                candidates = []
+
+                # YOLO11 单输出格式 (9, 8400): bbox(4) + obj(1) + cls(4)
+                if output.shape[0] == 9:
+                    for i in range(num_boxes):
+                        obj = float(output[4, i])
+                        cls_logits = output[5:9, i]
+                        best_class = int(np.argmax(cls_logits))
+                        confidence = 1.0 / (1.0 + np.exp(-float(cls_logits[best_class])))
+                        if confidence < conf_threshold:
+                            continue
+                        x_center = float(output[0, i])
+                        y_center = float(output[1, i])
+                        w = float(output[2, i])
+                        h = float(output[3, i])
+                        x1 = int(((x_center - w / 2) - pad_w) / scale)
+                        y1 = int(((y_center - h / 2) - pad_h) / scale)
+                        x2 = int(((x_center + w / 2) - pad_w) / scale)
+                        y2 = int(((y_center + h / 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))
+                        if x2 - x1 < 10 or y2 - y1 < 10:
+                            continue
+                        cls_name = class_map.get(best_class, str(best_class))
+                        if cls_name not in self.config['target_classes']:
+                            continue
+                        candidates.append(DetectedObject(
+                            class_name=cls_name,
+                            confidence=confidence,
+                            bbox=(x1, y1, x2 - x1, y2 - y1),
+                            center=((x1 + x2) // 2, (y1 + y2) // 2)
+                        ))
+                else:
+                    # 标准 (84, 8400) 格式(ONNX 或新 RKNN)
+                    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 = 1.0 / (1.0 + np.exp(-float(class_probs[best_class])))
+
+                        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 = 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
+
+                        cls_name = class_map.get(best_class, str(best_class))
+                        if cls_name not in self.config['target_classes']:
+                            continue
+
+                        candidates.append(DetectedObject(
+                            class_name=cls_name,
+                            confidence=confidence,
+                            bbox=(x1, y1, x2 - x1, y2 - y1),
+                            center=((x1 + x2) // 2, (y1 + y2) // 2)
+                        ))
+
+                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
+
+    def _detect_rknn_tiled(self, frame: np.ndarray) -> List[DetectedObject]:
+        """分区域检测 - 将宽幅全景图分成多个重叠区域分别检测,提高远处目标识别率"""
+        results = []
+        h0, w0 = frame.shape[:2]
+        need_nms = not self.is_end2end
+        conf_threshold = self.config['confidence_threshold']
+        class_map = self.config.get('class_map', {0: 'person'})
+
+        # 分3个重叠区域
+        overlap = int(h0 * 0.2)
+        regions = [
+            (0, w0 // 3 + overlap),
+            (w0 // 3 - overlap // 2, 2 * w0 // 3 + overlap // 2),
+            (2 * w0 // 3 - overlap, w0),
+        ]
+
+        seen_centers = []
+
+        for x_start, x_end in regions:
+            crop = frame[:, x_start:x_end]
+            ch, cw = crop.shape[:2]
+
+            # end2end 模型:letterbox + NCHW
+            if self.is_end2end:
+                canvas, scale, pad_w, pad_h, ch, cw = self._letterbox(crop)
+                img = canvas[..., ::-1].astype(np.float32) / 255.0
+                if hasattr(self, 'rknn'):
+                    outputs = self.rknn.inference(inputs=[img[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_lb, y1_lb, x2_lb, y2_lb, 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
+                    # 从 letterbox 空间映射回 crop 坐标
+                    _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)
+                    dets.append([_x1, _y1, _x2, _y2, float(conf), int(cls_id)])
+            else:
+                canvas, scale, pad_w, pad_h, ch, cw = self._letterbox(crop)
+
+                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, ...]})
+
+                if len(outputs) == 9:
+                    dets = self._decode_yolo11_outputs(
+                        outputs, (640, 640), scale, pad_w, pad_h, ch, cw,
+                        conf_threshold=conf_threshold
+                    )
+                else:
+                    output = outputs[0]
+                    if len(output.shape) == 3:
+                        output = output[0]
+                    dets = []
+                    # YOLO11 单输出格式 (9, 8400): bbox(4) + obj(1) + cls(4)
+                    if output.shape[0] == 9:
+                        for i in range(output.shape[1]):
+                            obj = float(output[4, i])
+                            cls_logits = output[5:9, i]
+                            best_class = int(np.argmax(cls_logits))
+                            confidence = 1.0 / (1.0 + np.exp(-float(cls_logits[best_class])))
+                            if confidence < conf_threshold:
+                                continue
+                            x_center = float(output[0, i])
+                            y_center = float(output[1, i])
+                            w = float(output[2, i])
+                            h = float(output[3, i])
+                            x1 = x_center - w / 2
+                            y1 = y_center - h / 2
+                            x2 = x_center + w / 2
+                            y2 = y_center + h / 2
+                            dets.append([x1, y1, x2, y2, confidence, best_class])
+                    else:
+                        # 标准 (84, 8400) 格式
+                        for i in range(output.shape[1]):
+                            class_probs = output[4:, i]
+                            best_class = int(np.argmax(class_probs))
+                            confidence = 1.0 / (1.0 + np.exp(-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])
+
+                    # 从 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()
+
+            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
+
+                # 转换到原图坐标(加上区域偏移)
+                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))
+                x2 = max(0, min(w0, x2))
+                y2 = max(0, min(h0, y2))
+
+                if x2 - x1 < 10 or y2 - y1 < 10:
+                    continue
+
+                # 去重:同一目标可能被相邻区域重复检测
+                cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
+                if any(abs(cx - sx) < 50 and abs(cy - sy) < 50 for sx, sy in seen_centers):
+                    continue
+                seen_centers.append((cx, cy))
+
+                obj = DetectedObject(
+                    class_name=cls_name,
+                    confidence=float(confidence),
+                    bbox=(x1, y1, x2 - x1, y2 - y1),
+                    center=(cx, cy)
+                )
+                results.append(obj)
+
+        if need_nms and results:
+            results = nms(results, iou_threshold=0.45)
+
+        return results
+    
+    def detect(self, frame: np.ndarray) -> List[DetectedObject]:
+        """检测物体返回所有类别结果"""
+        if frame is None:
+            return []
+    
+        if hasattr(self, 'rknn') and self.rknn is not None:
+            results = self._detect_rknn(frame)
+            if results:
+                self._log_detections("RKNN", results, frame)
+                self._save_detection_image(frame, results)
+            return results
+        elif hasattr(self, 'session') and self.session is not None:
+            results = self._detect_rknn(frame)
+            if results:
+                self._log_detections("ONNX", results, frame)
+                self._save_detection_image(frame, results)
+            return results
+        elif self.model is not None:
+            results = self._detect_yolo(frame)
+            if results:
+                self._log_detections("YOLO", results, frame)
+                self._save_detection_image(frame, results)
+            return results
+        else:
+            logger.error("[YOLO] 没有可用的检测模型")
+            return []
+    
+    def _log_detections(self, model_type: str, results: List[DetectedObject], frame: np.ndarray):
+        if not results:
+            return
+        class_counts = {}
+        for r in results:
+            class_counts[r.class_name] = class_counts.get(r.class_name, 0) + 1
+        h, w = frame.shape[:2]
+        logger.info(f"[YOLO] {model_type}: {len(results)}个目标 {class_counts} (帧尺寸={w}x{h})")
+    
+    def _detect_yolo(self, frame: np.ndarray) -> List[DetectedObject]:
+        """使用 YOLO 模型检测"""
+        results = []
+        
+        try:
+            detections = self.model(
+                frame, 
+                device=self.device, 
+                verbose=False,
+                conf=self.config['confidence_threshold']
+            )
+            
+            for det in detections:
+                boxes = det.boxes
+                if boxes is None:
+                    continue
+                    
+                for i in range(len(boxes)):
+                    cls_id = int(boxes.cls[i])
+                    cls_name = det.names[cls_id]
+                    
+                    if cls_name not in self.config['target_classes']:
+                        continue
+                    
+                    conf = float(boxes.conf[i])
+                    
+                    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 + width // 2
+                    center_y = y1 + height // 2
+                    
+                    obj = DetectedObject(
+                        class_name=cls_name,
+                        confidence=conf,
+                        bbox=(x1, y1, width, height),
+                        center=(center_x, center_y)
+                    )
+                    results.append(obj)
+                    
+        except Exception as e:
+            logger.error(f"YOLO11检测错误: {e}")
+        
+        return results
+    
+    def detect_with_keypoints(self, frame: np.ndarray) -> List[DetectedObject]:
+        """
+        使用YOLO11-pose检测人体并返回关键点
+        Args:
+            frame: 输入图像
+        Returns:
+            带关键点的检测结果列表
+        """
+        return self.detect(frame)
+    
+    def detect_persons(self, frame: np.ndarray) -> List[DetectedObject]:
+        """检测人体(支持中英文类别名)"""
+        all_detections = self.detect(frame)
+        person_classes = {'person', '人'}
+        return [obj for obj in all_detections if obj.class_name in person_classes]
+    
+    def release(self):
+        """释放模型资源"""
+        if hasattr(self, 'rknn') and self.rknn:
+            self.rknn.release()
+            self.rknn = None
+        self.model = None
+        self.session = None

+ 33 - 0
deploy/src/dual_camera_system/requirements.txt

@@ -0,0 +1,33 @@
+# 视频处理与图像
+opencv-python>=4.8.0
+opencv-contrib-python>=4.8.0
+numpy>=1.24.0
+Pillow>=10.0.0
+
+# YOLO 检测 (ultralytics)
+ultralytics>=8.0.0
+
+# HTTP 请求
+requests>=2.28.0
+
+# OSS/S3 兼容存储 (MinIO, AWS S3, 阿里云 OSS)
+boto3>=1.26.0
+
+# HTTP 客户端
+urllib3>=2.0.0
+
+# 日志
+colorlog>=6.7.0
+
+# FastAPI web framework
+fastapi>=0.110.0
+uvicorn[standard]>=0.27.0
+python-multipart>=0.0.9
+httpx>=0.27.0
+
+# ============================================
+# 可选依赖
+# ============================================
+
+# RKNN 加速 (仅 Orange Pi 等 ARM 设备需要)
+# rknn-toolkit2>=1.5.0

+ 121 - 0
deploy/src/dual_camera_system/service.sh

@@ -0,0 +1,121 @@
+#!/bin/bash
+# DSH 双摄系统服务管理脚本
+# 用法: ./service.sh start|stop|restart|status
+
+SERVICE_NAME="dsh-dual-camera"
+PROJECT_DIR="/home/admin/dsh/dual_camera_system"
+PID_FILE="/tmp/${SERVICE_NAME}.pid"
+LOG_FILE="${PROJECT_DIR}/logs/${SERVICE_NAME}.log"
+
+# 激活 conda 环境
+source /home/admin/miniconda3/etc/profile.d/conda.sh
+conda activate rknn
+
+# 确保日志目录存在
+mkdir -p "${PROJECT_DIR}/logs"
+
+# 创建日志文件(如果不存在)
+touch "$LOG_FILE"
+
+start() {
+    if [ -f "$PID_FILE" ]; then
+        PID=$(cat "$PID_FILE")
+        if ps -p "$PID" > /dev/null 2>&1; then
+            echo "服务已在运行中 (PID: $PID)"
+            return 1
+        fi
+        rm -f "$PID_FILE"
+    fi
+
+    echo "启动 DSH 双摄系统服务..."
+    cd "$PROJECT_DIR"
+
+    nohup python main.py --host 0.0.0.0 --port 8000 > "$LOG_FILE" 2>&1 &
+    echo $! > "$PID_FILE"
+
+    sleep 2
+    if ps -p $(cat "$PID_FILE") > /dev/null 2>&1; then
+        echo "服务已启动 (PID: $(cat $PID_FILE))"
+    else
+        echo "服务启动失败,请检查日志: $LOG_FILE"
+        rm -f "$PID_FILE"
+        return 1
+    fi
+}
+
+stop() {
+    if [ ! -f "$PID_FILE" ]; then
+        echo "服务未运行"
+        return 0
+    fi
+
+    PID=$(cat "$PID_FILE")
+    if ! ps -p "$PID" > /dev/null 2>&1; then
+        echo "服务未运行 ( stale PID file: $PID )"
+        rm -f "$PID_FILE"
+        return 0
+    fi
+
+    echo "停止服务 (PID: $PID)..."
+    kill "$PID"
+
+    # 等待进程结束
+    for i in {1..10}; do
+        if ! ps -p "$PID" > /dev/null 2>&1; then
+            echo "服务已停止"
+            rm -f "$PID_FILE"
+            return 0
+        fi
+        sleep 1
+    done
+
+    # 强制杀死
+    echo "强制终止进程..."
+    kill -9 "$PID" 2>/dev/null
+    rm -f "$PID_FILE"
+    echo "服务已停止"
+}
+
+restart() {
+    stop
+    sleep 2
+    start
+}
+
+status() {
+    if [ ! -f "$PID_FILE" ]; then
+        echo "服务状态: 未运行"
+        return 0
+    fi
+
+    PID=$(cat "$PID_FILE")
+    if ps -p "$PID" > /dev/null 2>&1; then
+        echo "服务状态: 运行中 (PID: $PID)"
+        return 0
+    else
+        echo "服务状态: 未运行 (stale PID file)"
+        rm -f "$PID_FILE"
+        return 1
+    fi
+}
+
+case "$1" in
+    start)
+        start
+        ;;
+    stop)
+        stop
+        ;;
+    restart)
+        restart
+        ;;
+    status)
+        status
+        ;;
+    *)
+        echo "用法: $0 {start|stop|restart|status}"
+        exit 1
+        ;;
+esac
+
+exit 0

+ 605 - 0
deploy/src/dual_camera_system/third_party_pusher.py

@@ -0,0 +1,605 @@
+"""
+第三方平台推送模块
+将批次信息推送到第三方平台接口
+"""
+
+import os
+import time
+import json
+import logging
+import threading
+import queue
+
+import cv2
+import requests
+from typing import Optional, Dict, Any, List, Callable
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+
+logger = logging.getLogger(__name__)
+
+
+def _normalize_timestamp(ts: float) -> float:
+    """统一时间戳为秒。CaptureUploader 使用毫秒,其他可能使用秒。"""
+    if ts > 1e12:
+        return ts / 1000.0
+    return ts
+
+
+def _convert_to_legacy_batch_info(new_info: Dict[str, Any]) -> Dict[str, Any]:
+    """
+    把新版 CaptureUploader 生成的 batch_info 转成业务平台要求的 PairedImageSaver 老格式。
+
+    参考字段:
+    - batch_id / device_id / project_id / timestamp / datetime
+    - total_persons / ptz_images_count
+    - panorama: local_path / local_path_original / oss_url / oss_url_original
+    - persons: person_index / position(x,y 归一化) / bbox / confidence /
+               ptz_position / ptz_bbox / ptz_image_saved /
+               ptz_image_path / ptz_image_original_path /
+               ptz_oss_url / ptz_oss_url_original
+    - upload_status
+    """
+    normalized_ts = _normalize_timestamp(new_info.get("timestamp", time.time()))
+
+    urls = new_info.get("image_urls") or {}
+    image_paths = new_info.get("image_paths") or []
+    camera_type = new_info.get("camera_type", "panorama")
+    is_ptz = camera_type == "ptz"
+
+    # image_paths 约定:[original, marked]
+    original_path = image_paths[0] if len(image_paths) > 0 else None
+    marked_path = image_paths[1] if len(image_paths) > 1 else original_path
+
+    oss_url_original = urls.get("original") or None
+    oss_url_marked = urls.get("marked") or oss_url_original
+
+    # 读取原图尺寸,用于把人体中心坐标归一化到 0~1
+    img_w, img_h = 0, 0
+    if original_path and os.path.exists(original_path):
+        try:
+            img = cv2.imread(original_path)
+            if img is not None:
+                img_h, img_w = img.shape[:2]
+        except Exception:
+            pass
+
+    ptz_position = new_info.get("ptz_position") or {}
+
+    persons = []
+    for i, det in enumerate(new_info.get("detections") or []):
+        bbox = det.get("bbox", [0, 0, 0, 0])
+        x1, y1, x2, y2 = int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3])
+        cx = (x1 + x2) / 2.0
+        cy = (y1 + y2) / 2.0
+
+        position = {
+            "x": round(cx / img_w, 4) if img_w else round(cx, 4),
+            "y": round(cy / img_h, 4) if img_h else round(cy, 4),
+        }
+
+        person = {
+            "person_index": i,
+            "position": position,
+            "bbox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2},
+            "confidence": float(det.get("confidence", 0.0)),
+        }
+
+        # PTZ 流检测时,把同一张 PTZ 图作为每个人的特写图复用
+        # 始终包含 ptz_position 字段,第三方平台要求必须有 pan/tilt/zoom 数值
+        if is_ptz:
+            # 使用实际检测时的 PTZ 位置(若无实际位置,用默认值 0/0/1)
+            ptz_pan = ptz_position.get("pan") if isinstance(ptz_position, dict) else 0
+            ptz_tilt = ptz_position.get("tilt") if isinstance(ptz_position, dict) else 0
+            ptz_zoom = ptz_position.get("zoom") if isinstance(ptz_position, dict) else 1
+            person["ptz_position"] = {
+                "pan": ptz_pan if ptz_pan is not None else 0,
+                "tilt": ptz_tilt if ptz_tilt is not None else 0,
+                "zoom": ptz_zoom if ptz_zoom is not None else 1,
+            }
+            person["ptz_bbox"] = {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
+            person["ptz_image_saved"] = bool(marked_path and os.path.exists(marked_path))
+            person["ptz_image_path"] = marked_path
+            person["ptz_image_original_path"] = original_path
+            person["ptz_oss_url"] = oss_url_marked
+            person["ptz_oss_url_original"] = oss_url_original
+        else:
+            # 全景相机检测时,复用当前检测框作为 ptz_bbox
+            person["ptz_position"] = {
+                "pan": 0, "tilt": 0, "zoom": 1,
+            }
+            person["ptz_bbox"] = {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
+            person["ptz_image_saved"] = bool(marked_path and os.path.exists(marked_path))
+            person["ptz_image_path"] = marked_path
+            person["ptz_image_original_path"] = original_path
+            person["ptz_oss_url"] = oss_url_marked
+            person["ptz_oss_url_original"] = oss_url_original
+
+        persons.append(person)
+
+    ptz_uploaded = is_ptz and bool(oss_url_marked)
+
+    legacy = {
+        "batch_id": new_info.get("batch_id", ""),
+        "device_id": new_info.get("device_id", ""),
+        "project_id": new_info.get("project_id", ""),
+        "timestamp": normalized_ts,
+        "datetime": datetime.fromtimestamp(normalized_ts).isoformat(),
+        "total_persons": len(persons),
+        "ptz_images_count": len(persons),
+        "panorama": {
+            "local_path": marked_path,
+            "local_path_original": original_path,
+            "oss_url": oss_url_marked,
+            "oss_url_original": oss_url_original,
+        },
+        "persons": persons,
+        "upload_status": {
+            "panorama_uploaded": bool(oss_url_marked or oss_url_original),
+            "panorama_original_uploaded": bool(oss_url_original),
+            "all_ptz_uploaded": ptz_uploaded,
+        },
+    }
+    return legacy
+
+
+@dataclass
+class BatchReport:
+    """批次上报数据"""
+    batch_id: str
+    device_id: str
+    project_id: str
+    timestamp: float
+    batch_info: Dict[str, Any]  # batch_info.json 的完整内容
+    local_path: Optional[str] = None  # batch_info.json 本地路径
+
+
+class ThirdPartyPusher:
+    """
+    第三方平台推送器
+    负责将批次信息推送到配置的第三方平台接口
+    """
+    
+    def __init__(self, config: Dict[str, Any] = None):
+        """
+        初始化第三方平台推送器
+        
+        Args:
+            config: 第三方平台配置字典
+        """
+        from config import THIRD_PARTY_CONFIG, DEVICE_CONFIG
+        
+        self.config = config or THIRD_PARTY_CONFIG
+        self.device_config = DEVICE_CONFIG
+        
+        # 功能开关
+        self.enabled = self.config.get('enabled', False)
+        
+        # 平台配置
+        self.platform_type = self.config.get('platform_type', 'custom')
+        self.base_url = self.config.get('base_url', '')
+        self.api_version = self.config.get('api_version', 'v1')
+        
+        # 认证配置
+        self.auth_type = self.config.get('auth_type', 'none')
+        self.api_key = self.config.get('api_key', '')
+        self.api_secret = self.config.get('api_secret', '')
+        self.oauth2_config = self.config.get('oauth2', {})
+        
+        # 接口路径
+        self.endpoints = self.config.get('endpoints', {})
+        self.batch_report_url = self.endpoints.get('batch_report', '/api/batch/report')
+        self.heartbeat_url = self.endpoints.get('heartbeat', '/api/device/heartbeat')
+        
+        # 推送控制
+        self.push_interval = self.config.get('push_interval', 1.0)
+        self.retry_count = self.config.get('retry_count', 3)
+        self.retry_delay = self.config.get('retry_delay', 2.0)
+        self.timeout = self.config.get('timeout', 10)
+        self.data_format = self.config.get('data_format', 'json')
+        self.include_images = self.config.get('include_images', False)
+        
+        # OAuth2 Token
+        self._access_token = None
+        self._token_expires_at = 0
+        
+        # 上报队列
+        self.report_queue = queue.Queue()
+        
+        # 工作线程
+        self.running = False
+        self.worker_thread = None
+        
+        # 统计
+        self.stats = {
+            'total_reports': 0,
+            'success_reports': 0,
+            'failed_reports': 0,
+        }
+        self.stats_lock = threading.Lock()
+        
+        # 回调
+        self.on_report_success: Optional[Callable] = None
+        self.on_report_failed: Optional[Callable] = None
+        
+        # 最后上报时间
+        self.last_report_time = 0
+        
+        if self.enabled:
+            logger.info(f"[第三方平台] 推送器初始化完成: {self.base_url}")
+    
+    def start(self):
+        """启动推送器"""
+        if not self.enabled:
+            logger.info("[第三方平台] 推送器未启用")
+            return
+        
+        if self.running:
+            return
+        
+        self.running = True
+        self.worker_thread = threading.Thread(target=self._worker_loop, daemon=True)
+        self.worker_thread.start()
+        logger.info("[第三方平台] 推送器已启动")
+    
+    def stop(self):
+        """停止推送器"""
+        self.running = False
+        if self.worker_thread:
+            self.worker_thread.join(timeout=5)
+        logger.info("[第三方平台] 推送器已停止")
+    
+    def _worker_loop(self):
+        """工作线程循环"""
+        while self.running:
+            try:
+                report = self.report_queue.get(timeout=1.0)
+                self._process_report(report)
+            except queue.Empty:
+                continue
+            except Exception as e:
+                logger.error(f"[第三方平台] 处理上报错误: {e}")
+    
+    def _get_auth_headers(self) -> Dict[str, str]:
+        """获取认证请求头(当前第三方接口不需要自定义 header,返回空避免 422)"""
+        return {}
+    
+    def _get_oauth2_token(self) -> Optional[str]:
+        """获取 OAuth2 Token"""
+        # 检查现有 token 是否有效
+        if self._access_token and time.time() < self._token_expires_at - 60:
+            return self._access_token
+        
+        # 重新获取 token
+        token_url = self.oauth2_config.get('token_url', '')
+        client_id = self.oauth2_config.get('client_id', '')
+        client_secret = self.oauth2_config.get('client_secret', '')
+        scope = self.oauth2_config.get('scope', '')
+        
+        if not all([token_url, client_id, client_secret]):
+            logger.error("[第三方平台] OAuth2 配置不完整")
+            return None
+        
+        try:
+            data = {
+                'grant_type': 'client_credentials',
+                'client_id': client_id,
+                'client_secret': client_secret,
+            }
+            if scope:
+                data['scope'] = scope
+            
+            response = requests.post(token_url, data=data, timeout=self.timeout)
+            
+            if response.status_code == 200:
+                result = response.json()
+                self._access_token = result.get('access_token')
+                expires_in = result.get('expires_in', 3600)
+                self._token_expires_at = time.time() + expires_in
+                logger.info("[第三方平台] OAuth2 Token 获取成功")
+                return self._access_token
+            else:
+                logger.error(f"[第三方平台] OAuth2 Token 获取失败: {response.status_code}")
+                return None
+                
+        except Exception as e:
+            logger.error(f"[第三方平台] OAuth2 Token 请求异常: {e}")
+            return None
+    
+    def _process_report(self, report: BatchReport):
+        """处理单个上报任务"""
+        # 检查推送间隔
+        current_time = time.time()
+        time_since_last = current_time - self.last_report_time
+        if time_since_last < self.push_interval:
+            time.sleep(self.push_interval - time_since_last)
+        
+        success = self._send_batch_report(report)
+        
+        with self.stats_lock:
+            self.stats['total_reports'] += 1
+            if success:
+                self.stats['success_reports'] += 1
+            else:
+                self.stats['failed_reports'] += 1
+        
+        self.last_report_time = time.time()
+        
+        # 触发回调
+        if success and self.on_report_success:
+            try:
+                self.on_report_success(report)
+            except Exception as e:
+                logger.error(f"[第三方平台] 成功回调执行错误: {e}")
+        elif not success and self.on_report_failed:
+            try:
+                self.on_report_failed(report)
+            except Exception as e:
+                logger.error(f"[第三方平台] 失败回调执行错误: {e}")
+    
+    def _send_batch_report(self, report: BatchReport) -> bool:
+        """
+        发送批次上报请求
+        
+        Args:
+            report: 批次上报数据
+            
+        Returns:
+            bool: 是否成功
+        """
+        if not self.base_url:
+            logger.error("[第三方平台] 未配置 base_url")
+            return False
+        
+        url = f"{self.base_url}{self.batch_report_url}"
+        
+        # 构建请求数据
+        payload = self._build_payload(report)
+        
+        headers = self._get_auth_headers()
+        
+        for attempt in range(self.retry_count):
+            try:
+                if self.data_format == 'json':
+                    response = requests.post(
+                        url,
+                        json=payload,
+                        headers=headers,
+                        timeout=self.timeout,
+                        verify=False
+                    )
+                else:
+                    response = requests.post(
+                        url,
+                        data=payload,
+                        headers=headers,
+                        timeout=self.timeout,
+                        verify=False
+                    )
+                
+                if response.status_code == 200:
+                    result = response.json()
+                    status = result.get('status', '')
+                    message = result.get('message', '')
+                    if (result.get('code') == 200 or
+                        result.get('success') == True or
+                        status in ('pending', 'success', 'accepted') or
+                        '请求已接收' in message or
+                        message == 'accepted'):
+                        logger.info(f"[第三方平台] 批次上报成功: {report.batch_id}, task_id={result.get('task_id')}")
+                        return True
+                    else:
+                        logger.warning(f"[第三方平台] 批次上报失败: {result.get('msg', '未知错误')}")
+                        try:
+                            logger.warning(f"[第三方平台] 响应内容: {str(result)[:500]}")
+                        except Exception:
+                            pass
+                else:
+                    logger.warning(f"[第三方平台] 批次上报失败: HTTP {response.status_code}")
+                    try:
+                        logger.warning(f"[第三方平台] 响应内容: {response.text[:500]}")
+                    except Exception:
+                        pass
+
+                if attempt < self.retry_count - 1:
+                    time.sleep(self.retry_delay)
+                    
+            except requests.exceptions.Timeout:
+                logger.warning(f"[第三方平台] 请求超时 (尝试 {attempt + 1}/{self.retry_count})")
+                if attempt < self.retry_count - 1:
+                    time.sleep(self.retry_delay)
+            except Exception as e:
+                logger.error(f"[第三方平台] 请求异常 (尝试 {attempt + 1}/{self.retry_count}): {e}")
+                if attempt < self.retry_count - 1:
+                    time.sleep(self.retry_delay)
+        
+        logger.error(f"[第三方平台] 批次上报最终失败: {report.batch_id}")
+        return False
+    
+    def _build_payload(self, report: BatchReport) -> Dict[str, Any]:
+        """
+        构建上报请求体
+
+        Args:
+            report: 批次上报数据
+
+        Returns:
+            Dict: 请求体字典
+        """
+        batch_info = report.batch_info
+
+        # 根据平台类型调整格式
+        normalized_ts = _normalize_timestamp(report.timestamp)
+        if self.platform_type == 'jtjai':
+            # 优先取 OSS URL,否则用本地路径
+            urls = batch_info.get('image_urls') or {}
+            image_url = urls.get('original') or urls.get('marked') or (batch_info.get('image_paths') or [None])[0]
+            payload = {
+                'createTime': datetime.fromtimestamp(normalized_ts).strftime("%Y-%m-%d %H:%M:%S"),
+                'addr': f"设备{report.device_id}批次上报",
+                'ext1': json.dumps([image_url]),
+                'ext2': json.dumps({
+                    'batchId': report.batch_id,
+                    'deviceId': report.device_id,
+                    'projectId': report.project_id,
+                    'totalPersons': len(batch_info.get('detections', [])),
+                    'ptzImagesCount': 1 if batch_info.get('camera_type') == 'ptz' else 0,
+                    'persons': batch_info.get('detections', []),
+                    'imageUrls': urls,
+                })
+            }
+        else:
+            # custom / 其他平台:把新版 batch_info 转回老字段名后上报,
+            # 兼容原人体分析平台对 panorama / total_persons / persons 的解析。
+            payload = _convert_to_legacy_batch_info(batch_info)
+            # 统一时间戳单位为秒,避免第三方解析错误
+            payload['timestamp'] = normalized_ts
+
+        return payload
+    
+    def report_batch(self, batch_info: Dict[str, Any], local_path: Optional[str] = None):
+        """
+        上报批次信息
+        
+        Args:
+            batch_info: batch_info.json 的字典内容
+            local_path: batch_info.json 的本地文件路径(可选)
+        """
+        if not self.enabled:
+            return
+        
+        # 接受所有相机类型的检测上报(panorama 或 ptz)
+        # 业务流程:检测到人 → 上传 OSS → 推送第三方平台
+        
+        report = BatchReport(
+            batch_id=batch_info.get('batch_id', ''),
+            device_id=batch_info.get('device_id', ''),
+            project_id=batch_info.get('project_id', ''),
+            timestamp=batch_info.get('timestamp', time.time()),
+            batch_info=batch_info,
+            local_path=local_path
+        )
+        
+        self.report_queue.put(report)
+    
+    def report_batch_sync(self, batch_info: Dict[str, Any], 
+                          local_path: Optional[str] = None) -> bool:
+        """
+        同步上报批次信息
+        
+        Args:
+            batch_info: batch_info.json 的字典内容
+            local_path: batch_info.json 的本地文件路径(可选)
+            
+        Returns:
+            bool: 是否成功
+        """
+        if not self.enabled:
+            return False
+        
+        report = BatchReport(
+            batch_id=batch_info.get('batch_id', ''),
+            device_id=batch_info.get('device_id', ''),
+            project_id=batch_info.get('project_id', ''),
+            timestamp=batch_info.get('timestamp', time.time()),
+            batch_info=batch_info,
+            local_path=local_path
+        )
+        
+        return self._send_batch_report(report)
+    
+    def send_heartbeat(self) -> bool:
+        """
+        发送心跳
+        
+        Returns:
+            bool: 是否成功
+        """
+        if not self.enabled or not self.heartbeat_url:
+            return False
+        
+        url = f"{self.base_url}{self.heartbeat_url}"
+        
+        payload = {
+            'deviceId': self.device_config.get('device_id', ''),
+            'projectId': self.device_config.get('project_id', ''),
+            'timestamp': time.time(),
+            'status': 'online',
+        }
+        
+        headers = self._get_auth_headers()
+        
+        try:
+            response = requests.post(
+                url,
+                json=payload,
+                headers=headers,
+                timeout=self.timeout,
+                verify=False
+            )
+            
+            if response.status_code == 200:
+                logger.debug("[第三方平台] 心跳发送成功")
+                return True
+            else:
+                logger.warning(f"[第三方平台] 心跳发送失败: HTTP {response.status_code}")
+                return False
+                
+        except Exception as e:
+            logger.error(f"[第三方平台] 心跳发送异常: {e}")
+            return False
+    
+    def set_callbacks(self, on_success: Callable = None, on_failed: Callable = None):
+        """
+        设置回调函数
+        
+        Args:
+            on_success: 上报成功回调
+            on_failed: 上报失败回调
+        """
+        self.on_report_success = on_success
+        self.on_report_failed = on_failed
+    
+    def get_stats(self) -> Dict[str, int]:
+        """获取统计信息"""
+        with self.stats_lock:
+            return self.stats.copy()
+    
+    def is_enabled(self) -> bool:
+        """检查是否启用"""
+        return self.enabled
+
+
+# 全局单例
+_third_party_pusher_instance: Optional[ThirdPartyPusher] = None
+_third_party_pusher_lock = threading.Lock()
+
+
+def get_third_party_pusher(config: Dict[str, Any] = None) -> ThirdPartyPusher:
+    """
+    获取第三方平台推送器实例(单例模式,线程安全)
+
+    Args:
+        config: 第三方平台配置
+
+    Returns:
+        ThirdPartyPusher 实例
+    """
+    global _third_party_pusher_instance
+
+    if _third_party_pusher_instance is None:
+        with _third_party_pusher_lock:
+            if _third_party_pusher_instance is None:
+                _third_party_pusher_instance = ThirdPartyPusher(config)
+
+    return _third_party_pusher_instance
+
+
+def reset_third_party_pusher():
+    """重置第三方平台推送器实例"""
+    global _third_party_pusher_instance
+    with _third_party_pusher_lock:
+        if _third_party_pusher_instance is not None:
+            _third_party_pusher_instance.stop()
+        _third_party_pusher_instance = None

+ 186 - 0
deploy/src/dual_camera_system/video_lock.py

@@ -0,0 +1,186 @@
+"""
+视频捕获锁模块
+
+提供线程安全的 VideoCapture 操作,防止 FFmpeg 多线程解码崩溃。
+
+改进:从全局锁改为每摄像头独立锁 + 超时机制。
+- 每路摄像头使用独立的锁,不再互相阻塞
+- 超时读帧:如果某路摄像头卡住,不会无限阻塞其他路
+- 健康检查:记录每路流的状态(最近帧时间、连续错误数等)
+"""
+
+import threading
+import time
+from typing import Optional, Tuple, Dict
+from dataclasses import dataclass, field
+
+
+@dataclass
+class StreamHealth:
+    """流健康状态"""
+    camera_id: str = ""
+    last_frame_time: float = 0.0       # 最近成功读帧时间
+    consecutive_errors: int = 0         # 连续错误数
+    total_frames: int = 0               # 总帧数
+    total_errors: int = 0               # 总错误数
+    is_healthy: bool = True             # 是否健康
+    avg_read_time_ms: float = 0.0      # 平均读帧耗时(ms)
+
+
+class CameraLockManager:
+    """
+    每摄像头独立锁管理器
+    
+    替代全局锁,每路摄像头使用独立的锁,互不阻塞。
+    FFmpeg 内部的 async_lock 问题仅在多个 VideoCapture 实例
+    并发调用 read() 时触发。独立锁允许同一路的读操作串行化,
+    而不同路之间完全并行。
+    """
+    
+    def __init__(self):
+        self._locks: Dict[str, threading.Lock] = {}
+        self._health: Dict[str, StreamHealth] = {}
+        self._global_lock = threading.Lock()  # 仅用于管理锁的创建
+    
+    def get_lock(self, camera_id: str) -> threading.Lock:
+        """获取或创建指定摄像头的独立锁"""
+        with self._global_lock:
+            if camera_id not in self._locks:
+                self._locks[camera_id] = threading.Lock()
+                self._health[camera_id] = StreamHealth(camera_id=camera_id)
+            return self._locks[camera_id]
+    
+    def get_health(self, camera_id: str) -> StreamHealth:
+        """获取摄像头流健康状态"""
+        with self._global_lock:
+            return self._health.get(camera_id, StreamHealth(camera_id=camera_id))
+    
+    def update_health(self, camera_id: str, success: bool, read_time_ms: float = 0.0):
+        """更新流健康状态"""
+        with self._global_lock:
+            health = self._health.get(camera_id)
+            if health is None:
+                health = StreamHealth(camera_id=camera_id)
+                self._health[camera_id] = health
+            
+            if success:
+                health.last_frame_time = time.time()
+                health.consecutive_errors = 0
+                health.total_frames += 1
+                health.is_healthy = True
+                # 指数移动平均计算读帧耗时
+                alpha = 0.1
+                health.avg_read_time_ms = alpha * read_time_ms + (1 - alpha) * health.avg_read_time_ms
+            else:
+                health.consecutive_errors += 1
+                health.total_errors += 1
+                if health.consecutive_errors > 50:
+                    health.is_healthy = False
+
+
+# 全局锁管理器实例
+_manager = CameraLockManager()
+
+
+def safe_read(cap, camera_id: str = "default", timeout: float = 5.0) -> Tuple[bool, Optional[object]]:
+    """
+    线程安全的 VideoCapture.read(),使用每摄像头独立锁。
+    
+    Args:
+        cap: cv2.VideoCapture 实例
+        camera_id: 摄像头标识,用于区分不同路的锁
+        timeout: 读帧超时(秒),超时返回 (False, None)
+    
+    Returns:
+        (ret, frame) 与 cap.read() 相同格式
+    """
+    lock = _manager.get_lock(camera_id)
+    acquired = lock.acquire(timeout=timeout)
+    if not acquired:
+        _manager.update_health(camera_id, False)
+        return (False, None)
+    
+    try:
+        start_time = time.time()
+        ret, frame = cap.read()
+        read_time_ms = (time.time() - start_time) * 1000
+        _manager.update_health(camera_id, ret, read_time_ms)
+        
+        if not ret or frame is None:
+            return (False, None)
+        return (ret, frame)
+    except Exception:
+        _manager.update_health(camera_id, False)
+        return (False, None)
+    finally:
+        lock.release()
+
+
+def safe_is_opened(cap, camera_id: str = "default") -> bool:
+    """
+    线程安全的 VideoCapture.isOpened() 检查
+    """
+    lock = _manager.get_lock(camera_id)
+    acquired = lock.acquire(timeout=2.0)
+    if not acquired:
+        return False
+    try:
+        return cap.isOpened()
+    except Exception:
+        return False
+    finally:
+        lock.release()
+
+
+def get_stream_health(camera_id: str) -> StreamHealth:
+    """获取指定摄像头流的健康状态"""
+    return _manager.get_health(camera_id)
+
+
+def get_all_health() -> Dict[str, StreamHealth]:
+    """获取所有摄像头流的健康状态"""
+    with _manager._global_lock:
+        return dict(_manager._health)
+
+
+# ============================================================
+# 向后兼容:保留全局锁接口,但内部使用更安全的超时机制
+# ============================================================
+
+# 全局锁(向后兼容,用于不指定 camera_id 的场景)
+_compatibility_lock = threading.Lock()
+
+
+def safe_read_global(cap) -> Tuple[bool, Optional[object]]:
+    """
+    全局锁版本的 safe_read(向后兼容)
+    新代码应使用 safe_read(cap, camera_id) 代替
+    """
+    acquired = _compatibility_lock.acquire(timeout=5.0)
+    if not acquired:
+        return (False, None)
+    try:
+        ret, frame = cap.read()
+        if not ret or frame is None:
+            return (False, None)
+        return (ret, frame)
+    except Exception:
+        return (False, None)
+    finally:
+        _compatibility_lock.release()
+
+
+def safe_is_opened_global(cap) -> bool:
+    """
+    全局锁版本的 safe_is_opened(向后兼容)
+    新代码应使用 safe_is_opened(cap, camera_id) 代替
+    """
+    acquired = _compatibility_lock.acquire(timeout=2.0)
+    if not acquired:
+        return False
+    try:
+        return cap.isOpened()
+    except Exception:
+        return False
+    finally:
+        _compatibility_lock.release()