Ver Fonte

refactor: 完善双摄像头系统功能与配置

1. 优化FFmpeg解码参数,添加rtsp_transport;tcp配置
2. 更新云台与相机安装配置,修正翻转参数
3. 启用第三方平台推送并配置上报地址
4. 添加校准前后暂停/恢复检测逻辑
5. 新增批次快照清理与第三方上报功能
6. 调整重叠发现的俯仰角范围
wenhongquan há 3 semanas atrás
pai
commit
e06de718d4

+ 16 - 1
.claude/settings.local.json

@@ -1,7 +1,22 @@
 {
   "permissions": {
     "allow": [
-      "Bash(ssh:*)"
+      "Bash(ssh:*)",
+      "Bash(curl -s -m 10 http://58.213.48.54:9999/api/v1/human-analysis/)",
+      "Bash(curl -s -m 10 http://58.213.48.54:9999/)",
+      "Bash(curl -s -m 10 -X OPTIONS http://58.213.48.54:9999/api/v1/human-analysis/ -H \"Access-Control-Request-Method: POST\")",
+      "Bash(curl -s -m 10 http://58.213.48.54:9999/docs)",
+      "Bash(curl -s -m 10 http://58.213.48.54:9999/openapi.json)",
+      "Bash(curl -s -m 10 http://58.213.48.54:9999/redoc)",
+      "Bash(curl -s -m 5 -X GET \"http://58.213.48.54:9999/api/v1/human-analysis/\")",
+      "Bash(curl -s -m 5 -X OPTIONS \"http://58.213.48.54:9999/api/v1/human-analysis/\" -H \"Content-Type: application/json\")",
+      "Bash(curl *)",
+      "Bash(sshpass *)",
+      "Bash(python -c \"import py_compile; py_compile.compile\\('calibration.py', doraise=True\\)\")",
+      "Bash(python -c \"import py_compile; py_compile.compile\\('main.py', doraise=True\\)\")",
+      "Bash(python -c \"import py_compile; py_compile.compile\\('main.py', doraise=True\\); py_compile.compile\\('coordinator.py', doraise=True\\); py_compile.compile\\('calibration.py', doraise=True\\)\")",
+      "Bash(scp *)",
+      "Bash(python3 *)"
     ]
   }
 }

BIN
dual_camera_system/__pycache__/calibration.cpython-310.pyc


BIN
dual_camera_system/__pycache__/coordinator.cpython-310.pyc


BIN
dual_camera_system/__pycache__/main.cpython-310.pyc


Diff do ficheiro suprimidas por serem muito extensas
+ 728 - 209
dual_camera_system/calibration.py


+ 1 - 1
dual_camera_system/camera_group.py

@@ -240,7 +240,7 @@ class CameraGroup:
         from config import CALIBRATION_CONFIG
         overlap_cfg = CALIBRATION_CONFIG.get('overlap_discovery', {})
         self.calibrator.overlap_pan_range = overlap_cfg.get('pan_range', (0, 360))
-        self.calibrator.overlap_tilt_range = overlap_cfg.get('tilt_range', (-30, 30))
+        self.calibrator.overlap_tilt_range = overlap_cfg.get('tilt_range', (-20, 40))
         self.calibrator.overlap_pan_step = overlap_cfg.get('pan_step', 20)
         self.calibrator.overlap_tilt_step = overlap_cfg.get('tilt_step', 15)
         self.calibrator.stabilize_time = overlap_cfg.get('stabilize_time', 2.0)

+ 2 - 2
dual_camera_system/config/camera.py

@@ -60,8 +60,8 @@ CAMERA_GROUPS = [
             'username': 'admin',
             'password': 'Aa1234567',
             'channel': 0,
-            'pan_flip': True,      # pan方向翻转
-            'ceiling_mount': True, # 吸顶安装
+            'pan_flip': False,     # pan方向不翻转
+            'ceiling_mount': False, # 吊装朝下未翻转
         },
         'calibration_file': '/home/admin/dsh/calibration_group1.json',
         'paired_image_dir': '/home/admin/dsh/paired_images_group1',

+ 1 - 1
dual_camera_system/config/coordinator.py

@@ -48,7 +48,7 @@ CALIBRATION_CONFIG = {
     'calibration_file': '/home/admin/dsh/calibration.json',
     'overlap_discovery': {
         'pan_range': (0, 360),
-        'tilt_range': (-30, 30),
+        'tilt_range': (-20, 50),
         'pan_step': 20,
         'tilt_step': 15,
         'min_match_threshold': 8,

+ 5 - 5
dual_camera_system/config/device.py

@@ -23,13 +23,13 @@ DEVICE_CONFIG = {
 
 # 第三方平台接口配置
 THIRD_PARTY_CONFIG = {
-    'enabled': False,  # 是否启用第三方平台推送
-    
+    'enabled': True,  # 启用第三方平台推送
+
     # 平台类型: 'custom', 'jtjai', 'huawei', 'aliyun'
     'platform_type': 'custom',
-    
+
     # 接口基础配置
-    'base_url': '',  # 如: https://api.example.com
+    'base_url': 'http://58.213.48.54:9999',  # 人体分析平台
     'api_version': 'v1',
     
     # 认证配置
@@ -48,7 +48,7 @@ THIRD_PARTY_CONFIG = {
     # 接口路径配置
     'endpoints': {
         # 批次信息上报接口(接收 batch_info.json)
-        'batch_report': '/api/batch/report',
+        'batch_report': '/api/v1/human-analysis',
         
         # 心跳接口
         'heartbeat': '/api/device/heartbeat',

+ 8 - 7
dual_camera_system/config/ptz.py

@@ -7,7 +7,8 @@ PTZ_CONFIG = {
     'default_zoom': 8,               # 默认变焦倍数(提高以获得更清晰的人脸/身体图像)
     'max_zoom': 20,                  # 最大变焦倍数
     'move_speed': 4,                 # 移动速度 (1-8)
-    'coordinate_offset': (0, 0),     # 坐标偏移校准
+    'coordinate_offset': (0, 0),     # 坐标偏移校准(仅用于线性映射后备)
+    'tilt_offset': 10,               # tilt偏移(度),正值=向下补偿,负值=向上补偿
 
     # 视野角度配置 (根据实际摄像头参数设置)
     'pan_range': (0, 180),           # 水平视野范围 (度) - 全景相机通常覆盖180度
@@ -17,14 +18,14 @@ PTZ_CONFIG = {
 
     # 球机安装方向配置
     # mount_type: 'ceiling' - 吸顶/吊装(镜头朝上), 'wall' - 壁装/立杆(镜头朝下)
-    # 吸顶安装时,俯仰角(tilt)方向与壁装相反,需要反转tilt计算
-    'mount_type': 'ceiling',            # 'ceiling' 或 'wall'
+    # 吊装镜头朝下未翻转时,不触发tilt反转
+    'mount_type': 'wall',                # 吊装朝下未翻转,按壁装处理
 
-    # 方向修正(根据mount_type自动设置,也可手动覆盖
+    # 方向修正(根据实际安装方向调整
     # pan_flip: 如果球机与全景朝向相反(球机看后面),设为True
-    # tilt_flip: 如果俯仰方向相反,设为True(吸顶安装通常需要True)
-    'pan_flip': True,
-    'tilt_flip': False,              # 由 mount_type='ceiling' 时自动生效
+    # tilt_flip: 如果俯仰方向相反,设为True
+    'pan_flip': False,
+    'tilt_flip': False,
 
     # 球机端人体检测与自动对焦配置
     'enable_ptz_detection': True,    # 是否启用球机端人体检测

+ 61 - 15
dual_camera_system/coordinator.py

@@ -258,6 +258,9 @@ class Coordinator:
         
         # 控制标志
         self.running = False
+        self._paused = False
+        self._paused_event = threading.Event()
+        self._paused_event.set()  # 默认非暂停状态
         self.coordinator_thread = None
         
         # OCR频率控制
@@ -290,6 +293,22 @@ class Coordinator:
     def set_calibrator(self, calibrator):
         """设置校准器"""
         self.calibrator = calibrator
+
+    def pause_detection(self):
+        """暂停检测(校准时使用,线程不退出,仅跳过检测逻辑)"""
+        self._paused = True
+        self._paused_event.clear()
+        logger.info("[协调器] 检测已暂停")
+
+    def resume_detection(self):
+        """恢复检测(校准完成后恢复)"""
+        self._paused = False
+        self._paused_event.set()
+        logger.info("[协调器] 检测已恢复")
+
+    def is_paused(self) -> bool:
+        """检测是否暂停"""
+        return self._paused
     
     def _transform_position(self, x_ratio: float, y_ratio: float) -> Tuple[float, float, int]:
         """
@@ -303,6 +322,8 @@ class Coordinator:
         if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
             # 使用校准结果进行转换
             pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
+            # 应用tilt偏移补偿(校准系统性偏差)
+            tilt += PTZ_CONFIG.get('tilt_offset', 0)
             zoom = 8  # 默认变倍
         else:
             # 使用默认估算
@@ -393,6 +414,9 @@ class Coordinator:
     
     def _coordinator_worker(self):
         """联动工作线程"""
+        # 暂停时阻塞等待恢复,不消耗CPU
+        self._paused_event.wait()
+
         last_detection_time = 0
         # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
         detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
@@ -417,8 +441,8 @@ class Coordinator:
                 
                 frame_size = (frame.shape[1], frame.shape[0])
                 
-                # 周期性检测
-                if current_time - last_detection_time >= detection_interval:
+                # 周期性检测(暂停时跳过)
+                if not self._paused and current_time - last_detection_time >= detection_interval:
                     last_detection_time = current_time
                     
                     # 检测人体
@@ -435,8 +459,9 @@ class Coordinator:
                     if detections:
                         self._process_detections(detections, frame, frame_size)
                 
-                # 处理当前跟踪目标
-                self._process_current_target(frame, frame_size)
+                # 处理当前跟踪目标(暂停时跳过PTZ控制)
+                if not self._paused:
+                    self._process_current_target(frame, frame_size)
                 
                 # 清理过期目标
                 self._cleanup_expired_targets()
@@ -946,6 +971,9 @@ class AsyncCoordinator(Coordinator):
     
     def _detection_worker(self):
         """检测线程:持续读帧 + YOLO推理 + 发送PTZ命令 + 打印检测日志"""
+        # 暂停时阻塞等待恢复
+        self._paused_event.wait()
+
         last_detection_time = 0
         # 从 DETECTION_CONFIG 获取检测帧率,默认每秒2帧
         detection_fps = self.config.get('detection_fps', DETECTION_CONFIG.get('detection_fps', 2))
@@ -1004,8 +1032,8 @@ class AsyncCoordinator(Coordinator):
                     frame_count = 0
                     last_log_time = current_time
                 
-                # 周期性检测(约1次/秒
-                if current_time - last_detection_time >= detection_interval:
+                # 周期性检测(暂停时跳过检测和PTZ命令
+                if not self._paused and current_time - last_detection_time >= detection_interval:
                     last_detection_time = current_time
                     detection_run_count += 1
                     
@@ -1291,11 +1319,16 @@ class AsyncCoordinator(Coordinator):
         """PTZ控制线程:从队列接收命令并控制球机"""
         while self.running:
             try:
+                # 暂停时等待恢复
+                if self._paused:
+                    self._paused_event.wait()
+                    continue
+
                 try:
                     cmd = self._ptz_queue.get(timeout=0.1)
                 except queue.Empty:
                     continue
-                
+
                 # 执行PTZ命令(batch_id 和 person_index 已在命令中)
                 self._execute_ptz_command(cmd)
                 
@@ -1769,8 +1802,8 @@ class SequentialCoordinator(AsyncCoordinator):
                             logger.debug(f"[顺序模式] 清空上一轮跟踪目标: {len(self.tracking_targets)} 个")
                             self.tracking_targets.clear()
                     
-                    # 空闲状态:周期性检测
-                    if current_time - last_detection_time >= detection_interval:
+                    # 空闲状态:周期性检测(暂停时跳过)
+                    if not self._paused and current_time - last_detection_time >= detection_interval:
                         last_detection_time = current_time
                         detection_run_count += 1
                         
@@ -1853,9 +1886,15 @@ class SequentialCoordinator(AsyncCoordinator):
     def _ptz_worker(self):
         """PTZ控制线程:顺序模式下的PTZ控制逻辑"""
         logger.info("[PTZ线程] 顺序模式PTZ控制线程启动")
-        
+
         while self.running:
             try:
+                # 暂停时等待恢复
+                if self._paused:
+                    self._paused_event.wait()
+                    time.sleep(0.05)
+                    continue
+
                 state = self._get_capture_state()
                 
                 if state == 'capturing':
@@ -2078,17 +2117,17 @@ class SequentialCoordinator(AsyncCoordinator):
             self.tracking_targets.clear()
             logger.info("[顺序模式] 已清空跟踪目标列表")
     
-    def _save_local_snapshot(self, frame: np.ndarray, index: int, 
+    def _save_local_snapshot(self, frame: np.ndarray, index: int,
                               pan: float, tilt: float, zoom: int):
-        """保存本地快照"""
+        """保存本地快照,返回文件路径"""
         try:
             import os
             from datetime import datetime
-            
+
             # 创建保存目录
             save_dir = '/home/admin/dsh/captures'
             os.makedirs(save_dir, exist_ok=True)
-            
+
             # 生成文件名 - 使用 PNG 无损格式
             timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
             filename = f"capture_{timestamp}_person{index}_p{int(pan)}_t{int(tilt)}_z{zoom}.png"
@@ -2097,9 +2136,16 @@ class SequentialCoordinator(AsyncCoordinator):
             # 保存图片 - 使用 PNG 无损格式
             cv2.imwrite(filepath, frame)
             logger.info(f"[顺序模式] 快照已保存: {filepath}")
-            
+
+            # 记录到配对保存器,批次完成时删除
+            if self._paired_saver is not None and self._current_batch_id:
+                self._paired_saver.add_capture_path(self._current_batch_id, filepath)
+
+            return filepath
+
         except Exception as e:
             logger.error(f"[顺序模式] 保存快照失败: {e}")
+            return None
     
     def set_capture_config(self, **kwargs):
         """设置抓拍配置"""

+ 49 - 6
dual_camera_system/main.py

@@ -10,7 +10,7 @@
 # 必须在import cv2之前设置,否则FFmpeg多线程解码会导致
 # "Assertion fctx->async_lock failed at pthread_frame.c:167" 崩溃
 import os
-os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'threads;1'
+os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
 
 import sys
 import time
@@ -179,6 +179,9 @@ class DualCameraSystem:
         
         # 运行标志
         self.running = False
+
+        # 检测暂停状态(校准时暂停检测)
+        self._detection_was_running = False
     
     def initialize(self, skip_calibration: bool = False) -> bool:
         """
@@ -291,18 +294,53 @@ class DualCameraSystem:
         
         return True
     
+    def _pause_detection(self):
+        """暂停检测线程(校准时使用,避免检测与校准争抢PTZ控制权)"""
+        if self.coordinator is None:
+            return
+
+        if hasattr(self.coordinator, 'pause_detection') and self.coordinator.running:
+            self._detection_was_running = True
+            self.coordinator.pause_detection()
+            logger.info("已暂停检测,校准期间不执行检测")
+        else:
+            self._detection_was_running = False
+
+    def _resume_detection(self):
+        """恢复检测线程(校准完成后恢复)"""
+        if self.coordinator is None:
+            return
+
+        if self._detection_was_running and hasattr(self.coordinator, 'resume_detection'):
+            self.coordinator.resume_detection()
+            logger.info("已恢复检测")
+
     def _auto_calibrate(self, force: bool = False) -> bool:
         """
         执行自动校准
-        
+
+        校准期间会暂停检测线程,避免检测与校准争抢PTZ控制权。
+        校准完成(或失败)后恢复检测。
+
         Args:
             force: 是否强制重新校准(不使用已有数据)
-            
+
         Returns:
             是否成功
         """
+        # 校准前暂停检测,避免检测线程与校准争抢PTZ
+        self._pause_detection()
+
+        try:
+            return self._do_auto_calibrate(force)
+        finally:
+            # 校准完成(无论成功失败)后恢复检测
+            self._resume_detection()
+
+    def _do_auto_calibrate(self, force: bool = False) -> bool:
+        """自动校准的实际执行逻辑"""
         from calibration import CameraCalibrator, CalibrationManager
-        
+
         logger.info("=" * 50)
         logger.info("开始自动校准...")
         logger.info("=" * 50)
@@ -369,7 +407,7 @@ class DualCameraSystem:
         # 配置重叠发现参数
         overlap_cfg = CALIBRATION_CONFIG.get('overlap_discovery', {})
         self.calibrator.overlap_pan_range = overlap_cfg.get('pan_range', (0, 360))
-        self.calibrator.overlap_tilt_range = overlap_cfg.get('tilt_range', (-30, 30))
+        self.calibrator.overlap_tilt_range = overlap_cfg.get('tilt_range', (-20, 50))
         self.calibrator.overlap_pan_step = overlap_cfg.get('pan_step', 20)
         self.calibrator.overlap_tilt_step = overlap_cfg.get('tilt_step', 15)
         self.calibrator.stabilize_time = overlap_cfg.get('stabilize_time', 2.0)
@@ -431,7 +469,12 @@ class DualCameraSystem:
         logger.info(f"有效校准点: {len(result.points)}")
         logger.info(f"RMS误差: {result.rms_error:.4f} 度")
         logger.info("=" * 50)
-        
+
+        # 将校准器传递给联动控制器,使其使用校准查找表而非线性映射
+        if self.coordinator and self.calibrator and self.calibrator.is_calibrated():
+            self.coordinator.set_calibrator(self.calibrator)
+            logger.info("校准器已传递给联动控制器,跟踪将使用校准查找表")
+
         return True
     
     def _capture_ptz_frame(self) -> Optional[np.ndarray]:

+ 73 - 3
dual_camera_system/paired_image_saver.py

@@ -50,6 +50,7 @@ class DetectionBatch:
     completed: bool = False
     device_id: str = ''  # 设备编号
     project_id: str = ''  # 项目编号
+    capture_paths: List[str] = field(default_factory=list)  # captures 快照路径
 
 
 class PairedImageSaver:
@@ -148,11 +149,13 @@ class PairedImageSaver:
         self._batch_lock = threading.Lock()
         self._last_batch_time = 0.0
 
+        # 保存任务队列(必须在线程启动前初始化)
+        self._save_queue = []
+        self._save_queue_lock = threading.Lock()
+
         # 后台线程池(用于异步保存图片和上传OSS,不阻塞主识别线程)
         self._save_thread_pool = threading.Thread(target=self._save_worker, daemon=True)
         self._save_thread_pool.start()
-        self._save_queue = []  # 保存任务队列
-        self._save_queue_lock = threading.Lock()
 
         # 上传状态追踪
         self._upload_status: Dict[str, Dict] = {}  # batch_id -> {panorama: bool, ptz: Dict}
@@ -295,7 +298,7 @@ class PairedImageSaver:
             logger.error(f"[配对保存] 异步保存球机图失败: {e}")
 
     def _async_finalize_batch(self, task: Dict):
-        """异步完成批次(生成batch_info.json)"""
+        """异步完成批次(生成batch_info.json + 上报第三方)"""
         batch = task['batch']
         try:
             batch.completed = True
@@ -327,6 +330,12 @@ class PairedImageSaver:
             txt_path = batch_dir / "batch_info.txt"
             self._save_batch_info_txt(batch, txt_path)
 
+            # 上报第三方平台
+            self._report_to_third_party(batch_info)
+
+            # 删除 captures 目录下已上传的快照文件
+            self._cleanup_captures(batch)
+
             # 标记上传完成
             if batch.batch_id in self._upload_status:
                 self._upload_status[batch.batch_id]['completed'] = True
@@ -807,6 +816,67 @@ class PairedImageSaver:
         # 清理旧批次
         self._cleanup_old_batches()
     
+    def add_capture_path(self, batch_id: str, capture_path: str):
+        """记录 captures 快照路径,批次完成时删除"""
+        with self._batch_lock:
+            if self._current_batch and self._current_batch.batch_id == batch_id:
+                self._current_batch.capture_paths.append(capture_path)
+
+    def _cleanup_captures(self, batch: DetectionBatch):
+        """删除 captures 目录下已上传的快照文件"""
+        if not batch.capture_paths:
+            return
+        deleted = 0
+        for path in batch.capture_paths:
+            try:
+                p = Path(path)
+                if p.exists():
+                    p.unlink()
+                    deleted += 1
+            except Exception as e:
+                logger.warning(f"[配对保存] 删除快照失败: {path}, {e}")
+        if deleted > 0:
+            logger.info(f"[配对保存] 已清理 {deleted} 个 captures 快照")
+
+    def _report_to_third_party(self, batch_info: Dict):
+        """上报批次信息到第三方平台"""
+        try:
+            from config import THIRD_PARTY_CONFIG
+        except ImportError:
+            logger.warning("[第三方] 配置模块不可用,跳过上报")
+            return
+
+        if not THIRD_PARTY_CONFIG.get('enabled', False):
+            return
+
+        base_url = THIRD_PARTY_CONFIG.get('base_url', '')
+        endpoint = THIRD_PARTY_CONFIG.get('endpoints', {}).get('batch_report', '')
+        if not base_url or not endpoint:
+            logger.warning("[第三方] 接口地址未配置,跳过上报")
+            return
+
+        url = f"{base_url.rstrip('/')}/{endpoint.lstrip('/')}"
+        retry_count = THIRD_PARTY_CONFIG.get('retry_count', 3)
+        retry_delay = THIRD_PARTY_CONFIG.get('retry_delay', 2.0)
+        timeout = THIRD_PARTY_CONFIG.get('timeout', 10)
+
+        for attempt in range(1, retry_count + 1):
+            try:
+                import requests
+                resp = requests.post(url, json=batch_info, timeout=timeout)
+                if resp.status_code in (200, 201):
+                    logger.info(f"[第三方] 批次上报成功: batch_id={batch_info.get('batch_id')}, status={resp.status_code}")
+                    return
+                else:
+                    logger.warning(f"[第三方] 批次上报失败: status={resp.status_code}, body={resp.text[:200]}")
+            except Exception as e:
+                logger.warning(f"[第三方] 批次上报异常(第{attempt}次): {e}")
+
+            if attempt < retry_count:
+                time.sleep(retry_delay)
+
+        logger.error(f"[第三方] 批次上报最终失败: batch_id={batch_info.get('batch_id')}")
+
     def _build_batch_info_json(self, batch: DetectionBatch) -> Dict:
         """
         构建 batch_info.json 数据结构

+ 1 - 1
dual_camera_system/panorama_camera.py

@@ -6,7 +6,7 @@
 import os
 # 必须在导入cv2之前设置,防止FFmpeg多线程解码崩溃
 # pthread_frame.c:167 async_lock assertion
-os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'threads;1'
+os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
 
 import cv2
 import numpy as np

+ 1 - 1
dual_camera_system/ptz_camera.py

@@ -5,7 +5,7 @@
 
 import os
 # 必须在导入cv2之前设置,防止FFmpeg多线程解码崩溃
-os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'threads;1'
+os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
 
 import math
 import time

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff