Selaa lähdekoodia

feat: add calibration workflow and optimize PTZ tracking

- add run_calibrate.sh service script
- add PTZ verification and detection test scripts
- add calibration scanner and ORB rematch tools
- add unit test for polling tracker pan flip fix
- optimize PTZ config and camera group calibration parameters
- fix double pan flip issue when using calibrated transform
- update main.py to support force calibration flag
- refactor detector preprocessing to use letterbox resize
wenhongquan 11 tuntia sitten
vanhempi
commit
4091f57025
29 muutettua tiedostoa jossa 864 lisäystä ja 82 poistoa
  1. BIN
      dual_camera_system/__pycache__/calibration.cpython-310.pyc
  2. BIN
      dual_camera_system/__pycache__/camera_group.cpython-310.pyc
  3. BIN
      dual_camera_system/__pycache__/coordinator.cpython-310.pyc
  4. BIN
      dual_camera_system/__pycache__/main.cpython-310.pyc
  5. BIN
      dual_camera_system/__pycache__/multi_group_system.cpython-310.pyc
  6. BIN
      dual_camera_system/__pycache__/panorama_camera.cpython-310.pyc
  7. BIN
      dual_camera_system/__pycache__/polling_tracker.cpython-310.pyc
  8. 82 36
      dual_camera_system/calibration.py
  9. 21 7
      dual_camera_system/camera_group.py
  10. BIN
      dual_camera_system/config/__pycache__/camera.cpython-310.pyc
  11. BIN
      dual_camera_system/config/__pycache__/detection.cpython-310.pyc
  12. 21 3
      dual_camera_system/config/camera.py
  13. 25 10
      dual_camera_system/config/ptz.py
  14. 4 8
      dual_camera_system/coordinator.py
  15. 2 1
      dual_camera_system/main.py
  16. 5 2
      dual_camera_system/multi_group_system.py
  17. 14 12
      dual_camera_system/panorama_camera.py
  18. 2 2
      dual_camera_system/polling_tracker.py
  19. 3 1
      dual_camera_system/ptz_camera.py
  20. BIN
      dual_camera_system/scripts/__pycache__/calibration_scanner.cpython-310.pyc
  21. BIN
      dual_camera_system/scripts/__pycache__/re_match_orb.cpython-310.pyc
  22. 377 0
      dual_camera_system/scripts/calibration_scanner.py
  23. 180 0
      dual_camera_system/scripts/re_match_orb.py
  24. BIN
      dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310-pytest-9.0.2.pyc
  25. BIN
      dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310.pyc
  26. 51 0
      dual_camera_system/tests/test_polling_tracker.py
  27. 5 0
      run_calibrate.sh
  28. 40 0
      test_detection.py
  29. 32 0
      verify_ptz.py

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


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


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


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


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


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


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


+ 82 - 36
dual_camera_system/calibration.py

@@ -72,7 +72,10 @@ class OverlapRange:
     panorama_center_y: float
 
 
-MIN_MATCH_THRESHOLD = 8
+MIN_MATCH_THRESHOLD = 10          # 最少匹配点数
+LOWE_RATIO = 0.70                  # Lowe's ratio test 阈值,越小越严格
+RANSAC_THRESHOLD = 4.0             # RANSAC 单应矩阵内点阈值(像素)
+MIN_INLIERS = 5                    # 最少几何内点数
 
 
 class OverlapDiscovery:
@@ -130,18 +133,36 @@ class OverlapDiscovery:
             for match_pair in matches:
                 if len(match_pair) == 2:
                     m, n = match_pair
-                    if m.distance < 0.75 * n.distance:
+                    if m.distance < LOWE_RATIO * n.distance:
                         good_matches.append(m)
 
             if len(good_matches) < MIN_MATCH_THRESHOLD:
                 return (False, len(good_matches), 0.0, 0.0)
 
+            # 几何验证:用 RANSAC 单应矩阵剔除空间不一致的匹配
+            ptz_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches])
             pan_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches])
-            # 还原到原始图像坐标系
-            center_x = np.mean(pan_pts[:, 0]) / pan_scale
-            center_y = np.mean(pan_pts[:, 1]) / pan_scale
 
-            return (True, len(good_matches), center_x, center_y)
+            try:
+                _, mask = cv2.findHomography(ptz_pts, pan_pts, cv2.RANSAC, RANSAC_THRESHOLD)
+                inlier_mask = mask.ravel().astype(bool)
+                inlier_count = int(np.sum(inlier_mask))
+            except Exception:
+                inlier_count = 0
+                inlier_mask = np.zeros(len(good_matches), dtype=bool)
+
+            if inlier_count < MIN_INLIERS:
+                logger.debug(f"几何验证失败: {inlier_count}/{len(good_matches)} 个内点")
+                return (False, inlier_count, 0.0, 0.0)
+
+            # 只使用几何一致的内点计算中心
+            inlier_pan_pts = pan_pts[inlier_mask]
+            center_x = np.mean(inlier_pan_pts[:, 0]) / pan_scale
+            center_y = np.mean(inlier_pan_pts[:, 1]) / pan_scale
+
+            logger.debug(f"特征匹配: 原始={len(good_matches)}, 内点={inlier_count}, "
+                        f"中心=({center_x:.1f}, {center_y:.1f})")
+            return (True, inlier_count, center_x, center_y)
 
         except Exception as e:
             logger.error(f"特征匹配异常: {e}")
@@ -439,18 +460,33 @@ class VisualCalibrationDetector:
             for match_pair in matches:
                 if len(match_pair) == 2:
                     m, n = match_pair
-                    if m.distance < 0.75 * n.distance:
+                    if m.distance < LOWE_RATIO * n.distance:
                         good_matches.append(m)
 
-            if len(good_matches) < 4:
+            if len(good_matches) < MIN_MATCH_THRESHOLD:
                 return None
 
+            # 几何验证:用 RANSAC 单应矩阵剔除空间不一致的匹配
+            ptz_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches])
             pan_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches])
-            center_x = np.mean(pan_pts[:, 0])
-            center_y = np.mean(pan_pts[:, 1])
+
+            try:
+                _, mask = cv2.findHomography(ptz_pts, pan_pts, cv2.RANSAC, RANSAC_THRESHOLD)
+                inlier_mask = mask.ravel().astype(bool)
+                inlier_count = int(np.sum(inlier_mask))
+            except Exception:
+                return None
+
+            if inlier_count < MIN_INLIERS:
+                return None
+
+            inlier_pan_pts = pan_pts[inlier_mask]
+            center_x = np.mean(inlier_pan_pts[:, 0])
+            center_y = np.mean(inlier_pan_pts[:, 1])
 
             h, w = pan_gray.shape
-            logger.debug(f"特征匹配: 匹配点={len(good_matches)}, 中心=({center_x:.1f}, {center_y:.1f})")
+            logger.debug(f"特征匹配: 匹配点={len(good_matches)}, 内点={inlier_count}, "
+                        f"中心=({center_x:.1f}, {center_y:.1f})")
 
             return (center_x / w, center_y / h)
 
@@ -520,28 +556,31 @@ class CameraCalibrator:
         self.pan_lookup: List[Tuple[float, float]] = []  # [(x_ratio, pan), ...] sorted by x_ratio
         self.tilt_lookup: List[Tuple[float, float]] = []  # [(y_ratio, tilt), ...] sorted by y_ratio
 
-        # tilt偏移补偿(度),正值=向下补偿,从PTZ_CONFIG读取
-        from config import PTZ_CONFIG
-        self.tilt_offset_deg = PTZ_CONFIG.get('tilt_offset', 0)
-        self.pan_offset_deg = PTZ_CONFIG.get('pan_offset', 0)
-        self.pan_edge_offset = PTZ_CONFIG.get('pan_edge_offset', 0)
-        self.pan_curve_power = PTZ_CONFIG.get('pan_curve_power', 1.0)
+        # tilt偏移补偿(度),正值=向下补偿,优先从传入的球机配置读取
+        ptz_config = getattr(ptz_camera, 'ptz_config', None)
+        if ptz_config is None:
+            from config import PTZ_CONFIG
+            ptz_config = PTZ_CONFIG
+        self.tilt_offset_deg = ptz_config.get('tilt_offset', 0)
+        self.pan_offset_deg = ptz_config.get('pan_offset', 0)
+        self.pan_edge_offset = ptz_config.get('pan_edge_offset', 0)
+        self.pan_curve_power = ptz_config.get('pan_curve_power', 1.0)
         # tilt线性映射(替代不稳定的查找表)
-        self.tilt_linear_enabled = PTZ_CONFIG.get('tilt_linear_enabled', False)
-        self.tilt_y0 = PTZ_CONFIG.get('tilt_y0', 0)
-        self.tilt_y1 = PTZ_CONFIG.get('tilt_y1', 45)
-        self.tilt_curve_power = PTZ_CONFIG.get('tilt_curve_power', 1.0)
+        self.tilt_linear_enabled = ptz_config.get('tilt_linear_enabled', False)
+        self.tilt_y0 = ptz_config.get('tilt_y0', 0)
+        self.tilt_y1 = ptz_config.get('tilt_y1', 45)
+        self.tilt_curve_power = ptz_config.get('tilt_curve_power', 1.0)
 
         # 校准配置
         self.stabilize_time = 1.0
         self.use_motion_detection = True
         self.use_feature_matching = True
 
-        # 重叠发现配置
-        self.overlap_pan_range = (0, 360)
-        self.overlap_tilt_range = (-20, 50)
-        self.overlap_pan_step = 20
-        self.overlap_tilt_step = 15
+        # 重叠发现配置(可从 ptz_config 覆盖,避免在无效方向浪费扫描时间)
+        self.overlap_pan_range = ptz_config.get('overlap_pan_range', (0, 360))
+        self.overlap_tilt_range = ptz_config.get('overlap_tilt_range', (-20, 50))
+        self.overlap_pan_step = ptz_config.get('overlap_pan_step', 20)
+        self.overlap_tilt_step = ptz_config.get('overlap_tilt_step', 15)
         self.max_overlap_ranges = 3
         self.min_positions_per_range = 3
 
@@ -801,11 +840,14 @@ class CameraCalibrator:
             return
 
         try:
-            # 获取默认位置配置
-            from config import PTZ_CONFIG
-            default_pan = PTZ_CONFIG.get('default_pan', 0)
-            default_tilt = PTZ_CONFIG.get('default_tilt', 0)
-            default_zoom = PTZ_CONFIG.get('default_zoom', 1)
+            # 获取默认位置配置,优先从传入的球机配置读取
+            ptz_config = getattr(self.ptz, 'ptz_config', None)
+            if ptz_config is None:
+                from config import PTZ_CONFIG
+                ptz_config = PTZ_CONFIG
+            default_pan = ptz_config.get('default_pan', 0)
+            default_tilt = ptz_config.get('default_tilt', 0)
+            default_zoom = ptz_config.get('default_zoom', 1)
 
             logger.info(f"球机复位到位置: pan={default_pan}, tilt={default_tilt}, zoom={default_zoom}")
             self.ptz.goto_exact_position(default_pan, default_tilt, default_zoom)
@@ -1134,6 +1176,9 @@ class CameraCalibrator:
             pan_correction = self.pan_edge_offset * y_scale * math.copysign(abs(dx) ** self.pan_curve_power, dx)
             pan += pan_correction
 
+        # 将pan归一化到[0, 360),便于发送给球机
+        pan = pan % 360
+
         # tilt:优先使用曲线映射(查找表tilt数据不稳定),后备查找表
         if self.tilt_linear_enabled:
             tilt = self.tilt_y0 + (self.tilt_y1 - self.tilt_y0) * (y_ratio ** self.tilt_curve_power)
@@ -1142,7 +1187,7 @@ class CameraCalibrator:
         else:
             tilt = self.tilt_offset + self.tilt_scale_x * x_ratio + self.tilt_scale_y * y_ratio
 
-        return (pan % 360, tilt)
+        return (pan, tilt)
 
     def _interp_lookup(self, lookup: List[Tuple[float, float]], ratio: float) -> float:
         """分段线性插值"""
@@ -1382,7 +1427,7 @@ class CameraCalibrator:
         self, entries: List[Tuple[float, float, int]],
         indices: List[int]
     ) -> List[Tuple[float, float, int]]:
-        """将子序列的pan角度展开并归一化到[0, 360)"""
+        """将子序列的pan角度展开为连续值(不强制归一化到[0,360),便于插值)"""
         result = []
         prev_unwrapped = None
         for idx in indices:
@@ -1399,7 +1444,8 @@ class CameraCalibrator:
                     diff = pan - prev_unwrapped
                 unwrapped = pan
             prev_unwrapped = unwrapped
-            result.append((x, unwrapped % 360, w))
+            # 保持连续值,transform 返回前再归一化到 [0,360)
+            result.append((x, unwrapped, w))
         return result
 
     def inverse_transform(self, pan: float, tilt: float) -> Tuple[float, float]:
@@ -1491,7 +1537,7 @@ class CameraCalibrator:
 
         try:
             import json
-            ptz_config = _get_ptz_config()
+            ptz_config = getattr(self.ptz, 'ptz_config', None) or _get_ptz_config()
             data = {
                 'pan_offset': self.pan_offset,
                 'pan_scale_x': self.pan_scale_x,
@@ -1571,7 +1617,7 @@ class CameraCalibrator:
             )
             
             # 检查安装方向配置是否匹配
-            ptz_config = _get_ptz_config()
+            ptz_config = getattr(self.ptz, 'ptz_config', None) or _get_ptz_config()
             current_mount = ptz_config.get('mount_type', 'wall')
             saved_mount = data.get('mount_type', 'wall')
             if current_mount != saved_mount:

+ 21 - 7
dual_camera_system/camera_group.py

@@ -96,12 +96,14 @@ class CameraGroup:
         
         logger.info(f"[{self.group_id}] 摄像头组实例创建: {self.name}")
     
-    def initialize(self, skip_calibration: bool = False) -> bool:
+    def initialize(self, skip_calibration: bool = False,
+                   force_calibration: bool = False) -> bool:
         """
         初始化组内所有组件
         
         Args:
             skip_calibration: 是否跳过校准
+            force_calibration: 是否强制重新校准
             
         Returns:
             是否成功
@@ -182,7 +184,7 @@ class CameraGroup:
         elif skip_calibration:
             logger.info(f"[{self.group_id}] 自动校准已跳过")
         else:
-            if not self._auto_calibrate():
+            if not self._auto_calibrate(force=force_calibration):
                 logger.error(f"[{self.group_id}] 自动校准失败!")
                 return False
         
@@ -232,13 +234,25 @@ class CameraGroup:
         )
 
         # 配置校准参数
+        # 优先使用球机自身配置中的扫描范围,未配置时回退到 CALIBRATION_CONFIG 默认值
         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', (-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)
+        ptz_overlap = getattr(self.ptz_camera, 'ptz_config', {}) or {}
+        self.calibrator.overlap_pan_range = ptz_overlap.get(
+            'overlap_pan_range', overlap_cfg.get('pan_range', (0, 360))
+        )
+        self.calibrator.overlap_tilt_range = ptz_overlap.get(
+            'overlap_tilt_range', overlap_cfg.get('tilt_range', (-20, 40))
+        )
+        self.calibrator.overlap_pan_step = ptz_overlap.get(
+            'overlap_pan_step', overlap_cfg.get('pan_step', 20)
+        )
+        self.calibrator.overlap_tilt_step = ptz_overlap.get(
+            'overlap_tilt_step', overlap_cfg.get('tilt_step', 15)
+        )
+        self.calibrator.stabilize_time = ptz_overlap.get(
+            'overlap_stabilize_time', overlap_cfg.get('stabilize_time', 2.0)
+        )
 
         # 创建校准管理器(传入校准文件路径)
         self.calibration_manager = CalibrationManager(

BIN
dual_camera_system/config/__pycache__/camera.cpython-310.pyc


BIN
dual_camera_system/config/__pycache__/detection.cpython-310.pyc


+ 21 - 3
dual_camera_system/config/camera.py

@@ -75,11 +75,29 @@ CAMERA_GROUPS = [
             'channel': 0,
             'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
             # 球机安装方向:ceiling=吸顶/吊装(镜头朝下), wall=壁装/立杆(镜头水平)
-            'mount_type': 'ceiling',
+            # 测试环境球机已在设备端设置为 ceiling 模式,代码层按 wall 避免双重翻转
+            'mount_type': 'wall',
             # pan_flip: 球机与全景朝向相反时设为 True
-            'pan_flip': True,
-            # tilt_flip: 俯仰方向相反时设为 True(ceiling 会自动翻转 tilt,通常无需再设)
+            # 测试环境球机竖装且设备端已做倒影,与枪机同向,无需翻转 pan
+            'pan_flip': False,
+            # tilt_flip: 俯仰方向相反时设为 True(设备端 ceiling 模式已处理 tilt,代码层不翻转)
             'tilt_flip': False,
+            # 视野映射:全景 x=0 -> pan=-90, x=1 -> pan=+90
+            'pan_range': (-90, 90),
+            'pan_center': 0,
+            # tilt:负值为向下看。竖装+倒影后,y=0(画面上方/远处)向上看,y=1 向下看
+            # 人要被显示器挡住,需要适当向上看(tilt 正值)
+            'tilt_range': (-5, 20),
+            'tilt_center': 7,
+            'tilt_linear_enabled': True,
+            'tilt_y0': 12,
+            'tilt_y1': -5,
+            'tilt_curve_power': 0.8,
+            # 自动校准扫描范围:完整 360° 粗扫 + 每步由下朝上细扫,建立全景→PTZ 映射
+            'overlap_pan_range': (0, 360),
+            'overlap_tilt_range': (-35, 45),
+            'overlap_pan_step': 20,
+            'overlap_tilt_step': 10,
         },
         'calibration_file': '/home/admin/dsh/calibration_group1.json',
         'paired_image_dir': '/home/admin/dsh/paired_images_group1',

+ 25 - 10
dual_camera_system/config/ptz.py

@@ -4,7 +4,7 @@ PTZ控制配置
 
 # PTZ控制配置
 PTZ_CONFIG = {
-    'default_zoom': 8,               # 默认变焦倍数(提高以获得更清晰的人脸/身体图像
+    'default_zoom': 5,               # 默认变焦倍数(视野更宽,便于先看到人
     'max_zoom': 20,                  # 最大变焦倍数
     'move_speed': 4,                 # 移动速度 (1-8)
     'coordinate_offset': (0, 0),     # 坐标偏移校准(仅用于线性映射后备)
@@ -15,20 +15,22 @@ PTZ_CONFIG = {
 
     # tilt线性映射参数(覆盖校准查找表的tilt,因为查找表tilt数据不稳定)
     'tilt_linear_enabled': True,     # 是否使用线性tilt映射替代查找表
-    'tilt_y0': 15,                   # y_ratio=0时对应的tilt角度
-    'tilt_y1': 55,                   # y_ratio=1时对应的tilt角度
+    'tilt_y0': 12,                   # y_ratio=0时对应的tilt角度
+    'tilt_y1': -5,                   # y_ratio=1时对应的tilt角度
     'tilt_curve_power': 0.8,         # tilt曲线指数,>0.5中间区域tilt更大
 
     # 视野角度配置 (根据实际摄像头参数设置)
-    'pan_range': (0, 180),           # 水平视野范围 (度) - 全景相机通常覆盖180度
-    'tilt_range': (-45, 45),         # 垂直视野范围 (度) - 垂直方向覆盖角度
-    'pan_center': 90,                # 水平中心角度 (画面中心对应的PTZ角度)
-    'tilt_center': 0,                # 垂直中心角度
+    # 默认值按水平安装、全景覆盖前方 180° 设置:
+    # x=0 -> pan=-90, x=0.5 -> pan=0, x=1 -> pan=+90
+    'pan_range': (-90, 90),          # 水平视野范围 (度)
+    'tilt_range': (-5, 20),          # 垂直视野范围 (度)
+    'pan_center': 0,                 # 水平中心角度 (画面中心对应的PTZ角度)
+    'tilt_center': 7,                # 垂直中心角度
 
     # 球机安装方向配置
-    # mount_type: 'ceiling' - 吸顶/吊装(镜头朝上), 'wall' - 壁装/立杆(镜头朝下
-    # 吊装镜头朝下未翻转时,不触发tilt反转
-    'mount_type': 'wall',                # 吊装朝下未翻转,按壁装处理
+    # mount_type: 'ceiling' - 吸顶/吊装(镜头朝下), 'wall' - 壁装/立杆(镜头水平/朝下看
+    # 测试环境设备端已设 ceiling 模式,代码层按 wall 处理
+    'mount_type': 'wall',
 
     # 方向修正(根据实际安装方向调整)
     # pan_flip: 如果球机与全景朝向相反(球机看后面),设为True
@@ -36,6 +38,19 @@ PTZ_CONFIG = {
     'pan_flip': False,
     'tilt_flip': False,
 
+    # tilt 线性映射参数(用于 CameraCalibrator.transform)
+    # 设备端 ceiling 已处理,代码层按 wall:y=0 略向上,y=1 略向下
+    'tilt_linear_enabled': True,
+    'tilt_y0': 12,
+    'tilt_y1': -5,
+    'tilt_curve_power': 0.8,
+
+    # 自动校准扫描范围(完整 360° 粗扫 + 每步由下朝上细扫)
+    'overlap_pan_range': (0, 360),     # 完整水平扫描
+    'overlap_tilt_range': (-35, 45),   # 更广的 tilt 范围,覆盖竖装+倒影场景
+    'overlap_pan_step': 20,
+    'overlap_tilt_step': 10,
+
     # 球机端人体检测与自动对焦配置
     'enable_ptz_detection': True,    # 是否启用球机端人体检测
     'auto_zoom': {

+ 4 - 8
dual_camera_system/coordinator.py

@@ -705,9 +705,8 @@ class Coordinator:
                 
                 if should_move:
                     if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
+                        # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
                         pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-                        if self.ptz.ptz_config.get('pan_flip', False):
-                            pan = (pan + 180) % 360
                         zoom = self.ptz.ptz_config.get('default_zoom', 8)
                         self.ptz.goto_exact_position(pan, tilt, zoom)
                     else:
@@ -769,9 +768,8 @@ class Coordinator:
         """
         if self.enable_ptz_tracking and self.enable_ptz_camera:
             if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
+                # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
                 pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-                if self.ptz.ptz_config.get('pan_flip', False):
-                    pan = (pan + 180) % 360
                 self.ptz.goto_exact_position(pan, tilt, zoom or self.ptz.ptz_config.get('default_zoom', 8))
             else:
                 self.ptz.move_to_target(x_ratio, y_ratio, zoom)
@@ -1488,9 +1486,8 @@ class AsyncCoordinator(Coordinator):
         person_index = cmd.person_index
         
         if cmd.use_calibration and self.calibrator and self.calibrator.is_calibrated():
+            # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
             pan, tilt = self.calibrator.transform(cmd.x_ratio, cmd.y_ratio)
-            if self.ptz.ptz_config.get('pan_flip', False):
-                pan = (pan + 180) % 360
             zoom = self.ptz.ptz_config.get('default_zoom', 8)
         else:
             pan, tilt, zoom = self.ptz.calculate_ptz_position(cmd.x_ratio, cmd.y_ratio)
@@ -1999,9 +1996,8 @@ class SequentialCoordinator(AsyncCoordinator):
         
         # 计算PTZ角度
         if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
+            # 校准器返回的是可直接发送给球机的真实 PTZ 角度,不再应用 pan_flip
             pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-            if self.ptz.ptz_config.get('pan_flip', False):
-                pan = (pan + 180) % 360
             zoom = self.ptz.ptz_config.get('default_zoom', 8)
             coord_type = "校准坐标"
         else:

+ 2 - 1
dual_camera_system/main.py

@@ -173,7 +173,8 @@ def run_multi_group_mode(args):
 
     try:
         # 初始化
-        if not system.initialize(skip_calibration=args.skip_calibration):
+        if not system.initialize(skip_calibration=args.skip_calibration,
+                                 force_calibration=args.force_calibration):
             print("\n系统初始化失败!")
             return 1
 

+ 5 - 2
dual_camera_system/multi_group_system.py

@@ -58,12 +58,14 @@ class MultiGroupSystem:
         
         logger.info("[MultiGroupSystem] 多组摄像头系统实例创建")
     
-    def initialize(self, skip_calibration: bool = False) -> bool:
+    def initialize(self, skip_calibration: bool = False,
+                   force_calibration: bool = False) -> bool:
         """
         初始化系统(SDK + 检测器 + 所有组)
         
         Args:
             skip_calibration: 是否跳过校准
+            force_calibration: 是否强制重新校准
             
         Returns:
             是否成功
@@ -126,7 +128,8 @@ class MultiGroupSystem:
                 shared_config=shared_config
             )
             
-            if group.initialize(skip_calibration=skip_calibration):
+            if group.initialize(skip_calibration=skip_calibration,
+                                force_calibration=force_calibration):
                 self.groups.append(group)
                 success_count += 1
                 logger.info(f"[MultiGroupSystem] 组 {group_id} 初始化成功")

+ 14 - 12
dual_camera_system/panorama_camera.py

@@ -829,36 +829,38 @@ class ObjectDetector:
             class_map = self.config.get('class_map', {0: 'person'})
 
             # -------------------------------------------------------
-            # end2end 模型(内置NMS):resize + NCHW 预处理
-            # 输出格式 (N, 6) = (x1,y1,x2,y2,conf,cls) 在 640x640 空间
+            # end2end 模型(内置NMS):letterbox + NHWC 预处理
+            # 输出格式 (N, max_det, 6) = (x1,y1,x2,y2,conf,cls) 在 letterbox 空间
             # -------------------------------------------------------
             if self.is_end2end:
-                img = cv2.resize(frame, (640, 640))
-                img = img.astype(np.float32) / 255.0
+                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'):
-                    blob = img.transpose(2, 0, 1)[None, ...]  # NCHW
                     outputs = self.rknn.inference(inputs=[blob])
                 else:
-                    blob = img.transpose(2, 0, 1)[None, ...]
-                    outputs = self.session.run(None, {self.input_name: blob})
+                    # 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, y1, x2, y2, conf, cls_id = output[i]
+                    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
 
-                    x1 = int(x1 * w0 / 640)
-                    y1 = int(y1 * h0 / 640)
-                    x2 = int(x2 * w0 / 640)
-                    y2 = int(y2 * h0 / 640)
+                    # 从 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))

+ 2 - 2
dual_camera_system/polling_tracker.py

@@ -348,10 +348,10 @@ class PollingTrackingCoordinator:
         y_ratio = target.center[1] / frame_h
 
         if self.calibrator and self.calibrator.is_calibrated():
+            # 校准器已通过真实扫描建立全景坐标到球机 PTZ 角度的映射,
+            # 返回的角度可直接发送给球机,不应再应用 pan_flip。
             pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
             ptz_config = getattr(self.ptz, "ptz_config", {})
-            if ptz_config.get("pan_flip"):
-                pan = (pan + 180) % 360
             zoom = ptz_config.get("default_zoom", 8)
         else:
             pan, tilt, zoom = self.ptz.calculate_ptz_position(x_ratio, y_ratio)

+ 3 - 1
dual_camera_system/ptz_camera.py

@@ -48,7 +48,9 @@ class PTZCamera:
             for key in ['mount_type', 'pan_flip', 'tilt_flip', 'coordinate_offset',
                         'tilt_offset', 'pan_offset', 'pan_edge_offset', 'pan_curve_power',
                         'tilt_linear_enabled', 'tilt_y0', 'tilt_y1', 'tilt_curve_power',
-                        'pan_range', 'tilt_range', 'pan_center', 'tilt_center']:
+                        'pan_range', 'tilt_range', 'pan_center', 'tilt_center',
+                        'overlap_pan_range', 'overlap_tilt_range',
+                        'overlap_pan_step', 'overlap_tilt_step']:
                 if key in camera_config:
                     self.ptz_config[key] = camera_config[key]
         

BIN
dual_camera_system/scripts/__pycache__/calibration_scanner.cpython-310.pyc


BIN
dual_camera_system/scripts/__pycache__/re_match_orb.cpython-310.pyc


+ 377 - 0
dual_camera_system/scripts/calibration_scanner.py

@@ -0,0 +1,377 @@
+#!/usr/bin/env python3
+"""
+独立校准扫描脚本
+按用户方案执行:
+1. 球机水平 360°、步长 20° 转一圈
+2. 每个水平位置由下朝上扫描 tilt
+3. 每个位置拍一张球机图,文件名带 pan_tilt
+4. 同时拍一张全景图
+5. 基于这些图做特征匹配 / 人工确认,生成映射表 JSON
+
+用法:
+    cd /home/admin/dsh/dual_camera_system
+    conda activate rknn
+    python scripts/calibration_scanner.py --output /home/admin/dsh/calibration_scan
+"""
+
+import os
+import sys
+import json
+import time
+import argparse
+import logging
+from pathlib import Path
+from datetime import datetime
+from typing import List, Tuple, Optional
+
+# 必须在导入 cv2 之前设置
+os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
+
+import cv2
+import numpy as np
+
+# 把项目根目录加入路径
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from config import CAMERA_GROUPS, SDK_PATH
+from config.camera import parse_resolution
+from dahua_sdk import DahuaSDK
+from ptz_camera import PTZCamera
+from panorama_camera import PanoramaCamera
+
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+def ensure_dir(path: Path):
+    path.mkdir(parents=True, exist_ok=True)
+    return path
+
+
+def clear_frame_buffer(cap, count: int = 5, interval: float = 0.05):
+    """丢弃缓存中的旧帧,获取最新帧"""
+    for _ in range(count):
+        cap.grab()
+        time.sleep(interval)
+    return cap.read()
+
+
+def save_image(path: Path, img: np.ndarray):
+    cv2.imwrite(str(path), img, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
+
+
+def match_to_panorama(
+    ptz_img: np.ndarray,
+    panorama_img: np.ndarray,
+    min_matches: int = 10,
+    ratio_thresh: float = 0.75,
+) -> Tuple[Optional[Tuple[float, float]], Optional[np.ndarray]]:
+    """
+    将球机图与全景图做 SIFT 特征匹配,返回全景中的归一化位置 (x_ratio, y_ratio)
+    以及可视化匹配图
+    """
+    if ptz_img is None or panorama_img is None:
+        return None, None
+
+    # 转换为灰度图
+    gray_p = cv2.cvtColor(ptz_img, cv2.COLOR_BGR2GRAY)
+    gray_g = cv2.cvtColor(panorama_img, cv2.COLOR_BGR2GRAY)
+
+    # 使用 SIFT
+    sift = cv2.SIFT_create(nfeatures=500)
+    kp_p, des_p = sift.detectAndCompute(gray_p, None)
+    kp_g, des_g = sift.detectAndCompute(gray_g, None)
+
+    if des_p is None or des_g is None or len(kp_p) < 4 or len(kp_g) < 4:
+        return None, None
+
+    # FLANN 匹配
+    index_params = dict(algorithm=1, trees=5)
+    search_params = dict(checks=50)
+    flann = cv2.FlannBasedMatcher(index_params, search_params)
+    matches = flann.knnMatch(des_p, des_g, k=2)
+
+    good = []
+    for m_n in matches:
+        if len(m_n) == 2:
+            m, n = m_n
+            if m.distance < ratio_thresh * n.distance:
+                good.append(m)
+
+    if len(good) < min_matches:
+        return None, None
+
+    # 提取匹配点坐标
+    pts_p = np.float32([kp_p[m.queryIdx].pt for m in good])
+    pts_g = np.float32([kp_g[m.trainIdx].pt for m in good])
+
+    # 用 RANSAC 过滤异常点
+    H, mask = cv2.findHomography(pts_p, pts_g, cv2.RANSAC, 5.0)
+    if mask is None:
+        return None, None
+
+    inlier_g = pts_g[mask.ravel() == 1]
+    if len(inlier_g) < min_matches:
+        return None, None
+
+    # 计算全景位置中心
+    center_x = float(np.median(inlier_g[:, 0]))
+    center_y = float(np.median(inlier_g[:, 1]))
+
+    h, w = panorama_img.shape[:2]
+    x_ratio = np.clip(center_x / w, 0.0, 1.0)
+    y_ratio = np.clip(center_y / h, 0.0, 1.0)
+
+    # 绘制匹配可视化
+    vis = cv2.drawMatches(
+        ptz_img, kp_p, panorama_img, kp_g,
+        [good[i] for i in range(len(good)) if mask[i]],
+        None, flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS
+    )
+
+    return (x_ratio, y_ratio), vis
+
+
+def run_scan(
+    output_dir: Path,
+    pan_step: int = 20,
+    tilt_range: Tuple[int, int] = (-35, 45),
+    tilt_step: int = 10,
+    zoom: int = 1,
+    stabilize_time: float = 1.0,
+):
+    """执行完整扫描"""
+
+    ensure_dir(output_dir)
+    ptz_dir = ensure_dir(output_dir / 'ptz_images')
+    match_dir = ensure_dir(output_dir / 'matches')
+
+    # 加载第一组配置
+    groups = [g for g in CAMERA_GROUPS if g.get('enabled', False)]
+    if not groups:
+        logger.error('没有启用的摄像头组')
+        return
+    group = groups[0]
+    pano_cfg = group['panorama']
+    ptz_cfg = group['ptz']
+
+    # 初始化 SDK
+    sdk_lib = os.path.join(SDK_PATH['lib_path'], SDK_PATH['netsdk'])
+    sdk = DahuaSDK(sdk_lib)
+    if not sdk.initialized or not sdk.init():
+        logger.error('大华 SDK 初始化失败')
+        return
+
+    # 连接枪机
+    logger.info('连接全景摄像头...')
+    panorama = PanoramaCamera(sdk, pano_cfg)
+    if not panorama.connect():
+        logger.error('全景摄像头连接失败')
+        sdk.cleanup()
+        return
+    if not panorama.start_stream_rtsp():
+        logger.error('全景 RTSP 启动失败')
+        panorama.disconnect()
+        sdk.cleanup()
+        return
+
+    # 连接球机
+    logger.info('连接 PTZ 球机...')
+    ptz = PTZCamera(sdk, ptz_cfg)
+    if not ptz.connect():
+        logger.error('PTZ 球机连接失败')
+        panorama.disconnect()
+        sdk.cleanup()
+        return
+    if not ptz.start_stream_rtsp():
+        logger.warning('PTZ RTSP 启动失败,但仍可控制云台')
+
+    # 等待视频流稳定
+    logger.info('等待视频流稳定 5 秒...')
+    time.sleep(5)
+
+    # 获取并保存全景图
+    logger.info('保存全景图...')
+    pano_frame = None
+    for _ in range(10):
+        pano_frame = panorama.get_frame()
+        if pano_frame is not None:
+            break
+        time.sleep(0.5)
+
+    if pano_frame is None:
+        logger.error('无法获取全景图')
+        ptz.disconnect()
+        panorama.disconnect()
+        sdk.cleanup()
+        return
+
+    pano_path = output_dir / 'panorama.jpg'
+    save_image(pano_path, pano_frame)
+    logger.info(f'全景图已保存: {pano_path}')
+
+    h, w = pano_frame.shape[:2]
+    logger.info(f'全景图尺寸: {w}x{h}')
+
+    # 构建扫描位置列表
+    scan_positions: List[Tuple[int, int]] = []
+    pan = 0
+    while pan < 360:
+        tilt = tilt_range[0]
+        while tilt <= tilt_range[1]:
+            scan_positions.append((pan, tilt))
+            tilt += tilt_step
+        pan += pan_step
+
+    logger.info(f'扫描位置总数: {len(scan_positions)}')
+
+    # 扫描并保存图片
+    mapping_records = []
+    failed_positions = []
+
+    for idx, (pan, tilt) in enumerate(scan_positions, 1):
+        logger.info(f'[{idx}/{len(scan_positions)}] pan={pan}°, tilt={tilt}°')
+
+        # 移动球机
+        if not ptz.goto_exact_position(float(pan), float(tilt), zoom):
+            logger.warning(f'  移动到 pan={pan}, tilt={tilt} 失败')
+            failed_positions.append((pan, tilt))
+            continue
+
+        # 等待稳定
+        time.sleep(stabilize_time)
+
+        # 获取清晰帧
+        frame = None
+        for _ in range(5):
+            frame = ptz.get_frame()
+            if frame is not None:
+                break
+            time.sleep(0.2)
+
+        if frame is None:
+            logger.warning(f'  获取帧失败: pan={pan}, tilt={tilt}')
+            failed_positions.append((pan, tilt))
+            continue
+
+        # 保存球机图
+        filename = f'ptz_p{pan:03d}_t{tilt:+03d}.jpg'
+        img_path = ptz_dir / filename
+        save_image(img_path, frame)
+
+        # 尝试与全景图匹配
+        pos, vis = match_to_panorama(frame, pano_frame)
+        record = {
+            'pan': pan,
+            'tilt': tilt,
+            'zoom': zoom,
+            'filename': filename,
+            'matched': pos is not None,
+        }
+        if pos:
+            record['x_ratio'] = round(pos[0], 4)
+            record['y_ratio'] = round(pos[1], 4)
+            record['panorama_x'] = int(pos[0] * w)
+            record['panorama_y'] = int(pos[1] * h)
+            logger.info(f'  匹配成功: 全景位置=({pos[0]:.3f}, {pos[1]:.3f})')
+
+            # 保存可视化匹配图
+            if vis is not None:
+                vis_path = match_dir / f'match_{filename}'
+                save_image(vis_path, vis)
+        else:
+            logger.info('  未匹配到全景区域')
+
+        mapping_records.append(record)
+
+    # 生成映射表
+    mapping = {
+        'created_at': datetime.now().isoformat(),
+        'panorama_size': {'width': w, 'height': h},
+        'pan_step': pan_step,
+        'tilt_range': tilt_range,
+        'tilt_step': tilt_step,
+        'zoom': zoom,
+        'total_positions': len(scan_positions),
+        'failed_positions': failed_positions,
+        'records': mapping_records,
+    }
+
+    mapping_path = output_dir / 'mapping_raw.json'
+    with open(mapping_path, 'w', encoding='utf-8') as f:
+        json.dump(mapping, f, indent=2, ensure_ascii=False)
+    logger.info(f'原始映射表已保存: {mapping_path}')
+
+    # 生成可直接用于校准器的查找表(仅包含有匹配的点)
+    valid_records = [r for r in mapping_records if r.get('matched')]
+    pan_lookup = sorted([[r['x_ratio'], float(r['pan'])] for r in valid_records], key=lambda x: x[0])
+    tilt_lookup = sorted([[r['y_ratio'], float(r['tilt'])] for r in valid_records], key=lambda x: x[0])
+
+    lookup = {
+        'created_at': datetime.now().isoformat(),
+        'pan_lookup': pan_lookup,
+        'tilt_lookup': tilt_lookup,
+        'valid_count': len(valid_records),
+    }
+    lookup_path = output_dir / 'lookup_table.json'
+    with open(lookup_path, 'w', encoding='utf-8') as f:
+        json.dump(lookup, f, indent=2, ensure_ascii=False)
+    logger.info(f'查找表已保存: {lookup_path} (有效点 {len(valid_records)}/{len(scan_positions)})')
+
+    # 生成人工复核用 CSV
+    csv_path = output_dir / 'mapping_for_review.csv'
+    with open(csv_path, 'w', encoding='utf-8') as f:
+        f.write('filename,pan,tilt,x_ratio,y_ratio,panorama_x,panorama_y,matched,review_x,review_y\n')
+        for r in mapping_records:
+            f.write(
+                f"{r['filename']},{r['pan']},{r['tilt']},"
+                f"{r.get('x_ratio', '')},{r.get('y_ratio', '')},"
+                f"{r.get('panorama_x', '')},{r.get('panorama_y', '')},"
+                f"{r['matched']},,\n"
+            )
+    logger.info(f'人工复核 CSV 已保存: {csv_path}')
+
+    # 回到初始位置
+    ptz.goto_exact_position(0.0, 0.0, 1)
+
+    # 清理
+    ptz.disconnect()
+    panorama.disconnect()
+    sdk.cleanup()
+    logger.info('扫描完成')
+
+
+def main():
+    parser = argparse.ArgumentParser(description='PTZ 校准扫描工具')
+    parser.add_argument('--output', type=str, default='/home/admin/dsh/calibration_scan',
+                        help='扫描结果输出目录')
+    parser.add_argument('--pan-step', type=int, default=20,
+                        help='水平扫描步长(度)')
+    parser.add_argument('--tilt-min', type=int, default=-35,
+                        help='最小 tilt(度)')
+    parser.add_argument('--tilt-max', type=int, default=45,
+                        help='最大 tilt(度)')
+    parser.add_argument('--tilt-step', type=int, default=10,
+                        help='tilt 扫描步长(度)')
+    parser.add_argument('--zoom', type=int, default=1,
+                        help='扫描时使用 zoom')
+    parser.add_argument('--stabilize', type=float, default=1.0,
+                        help='PTZ 到位后等待时间(秒)')
+
+    args = parser.parse_args()
+
+    run_scan(
+        output_dir=Path(args.output),
+        pan_step=args.pan_step,
+        tilt_range=(args.tilt_min, args.tilt_max),
+        tilt_step=args.tilt_step,
+        zoom=args.zoom,
+        stabilize_time=args.stabilize,
+    )
+
+
+if __name__ == '__main__':
+    main()

+ 180 - 0
dual_camera_system/scripts/re_match_orb.py

@@ -0,0 +1,180 @@
+#!/usr/bin/env python3
+"""
+用 ORB 重新匹配已扫描的球机图与全景图,生成更可靠的映射表。
+
+用法:
+    python scripts/re_match_orb.py --scan-dir /home/admin/dsh/calibration_scan
+"""
+
+import os
+import sys
+import json
+import argparse
+import logging
+from pathlib import Path
+from typing import List, Tuple, Optional
+
+import cv2
+import numpy as np
+
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+def match_orb(
+    ptz_img: np.ndarray,
+    panorama_img: np.ndarray,
+    min_matches: int = 8,
+    ratio_thresh: float = 0.8,
+) -> Tuple[Optional[Tuple[float, float]], Optional[np.ndarray]]:
+    """ORB 特征匹配"""
+    if ptz_img is None or panorama_img is None:
+        return None, None
+
+    gray_p = cv2.cvtColor(ptz_img, cv2.COLOR_BGR2GRAY)
+    gray_g = cv2.cvtColor(panorama_img, cv2.COLOR_BGR2GRAY)
+
+    orb = cv2.ORB_create(nfeatures=1000)
+    kp_p, des_p = orb.detectAndCompute(gray_p, None)
+    kp_g, des_g = orb.detectAndCompute(gray_g, None)
+
+    if des_p is None or des_g is None or len(kp_p) < 4 or len(kp_g) < 4:
+        return None, None
+
+    bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
+    matches = bf.knnMatch(des_p, des_g, k=2)
+
+    good = []
+    for m_n in matches:
+        if len(m_n) == 2:
+            m, n = m_n
+            if m.distance < ratio_thresh * n.distance:
+                good.append(m)
+
+    if len(good) < min_matches:
+        return None, None
+
+    pts_p = np.float32([kp_p[m.queryIdx].pt for m in good])
+    pts_g = np.float32([kp_g[m.trainIdx].pt for m in good])
+
+    H, mask = cv2.findHomography(pts_p, pts_g, cv2.RANSAC, 5.0)
+    if mask is None:
+        return None, None
+
+    inlier_g = pts_g[mask.ravel() == 1]
+    if len(inlier_g) < min_matches:
+        return None, None
+
+    center_x = float(np.median(inlier_g[:, 0]))
+    center_y = float(np.median(inlier_g[:, 1]))
+
+    h, w = panorama_img.shape[:2]
+    x_ratio = np.clip(center_x / w, 0.0, 1.0)
+    y_ratio = np.clip(center_y / h, 0.0, 1.0)
+
+    vis = cv2.drawMatches(
+        ptz_img, kp_p, panorama_img, kp_g,
+        [good[i] for i in range(len(good)) if mask[i]],
+        None, flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS
+    )
+
+    return (x_ratio, y_ratio), vis
+
+
+def run_rematch(scan_dir: Path):
+    ptz_dir = scan_dir / 'ptz_images'
+    pano_path = scan_dir / 'panorama.jpg'
+    out_dir = scan_dir / 'rematch_orb'
+    out_dir.mkdir(exist_ok=True)
+
+    panorama = cv2.imread(str(pano_path))
+    if panorama is None:
+        logger.error(f'无法读取全景图: {pano_path}')
+        return
+
+    h, w = panorama.shape[:2]
+
+    records = []
+    for img_path in sorted(ptz_dir.glob('ptz_p*_t*.jpg')):
+        # 解析文件名中的 pan/tilt
+        stem = img_path.stem  # e.g. ptz_p080_t-05
+        parts = stem.replace('ptz_p', '').split('_t')
+        pan = int(parts[0])
+        tilt = int(parts[1])
+
+        ptz_img = cv2.imread(str(img_path))
+        if ptz_img is None:
+            continue
+
+        pos, vis = match_orb(ptz_img, panorama)
+        record = {
+            'filename': img_path.name,
+            'pan': pan,
+            'tilt': tilt,
+            'matched': pos is not None,
+        }
+        if pos:
+            record['x_ratio'] = round(pos[0], 4)
+            record['y_ratio'] = round(pos[1], 4)
+            record['panorama_x'] = int(pos[0] * w)
+            record['panorama_y'] = int(pos[1] * h)
+            vis_path = out_dir / f'match_{img_path.name}'
+            cv2.imwrite(str(vis_path), vis, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
+            logger.info(f'{img_path.name} -> ({pos[0]:.3f}, {pos[1]:.3f})')
+        else:
+            logger.info(f'{img_path.name} -> 未匹配')
+
+        records.append(record)
+
+    # 保存映射表
+    mapping_path = scan_dir / 'mapping_raw_orb.json'
+    with open(mapping_path, 'w', encoding='utf-8') as f:
+        json.dump({
+            'method': 'ORB',
+            'panorama_size': {'width': w, 'height': h},
+            'records': records,
+        }, f, indent=2, ensure_ascii=False)
+    logger.info(f'原始映射已保存: {mapping_path}')
+
+    # 生成查找表
+    valid = [r for r in records if r.get('matched')]
+    pan_lookup = sorted([[r['x_ratio'], float(r['pan'])] for r in valid], key=lambda x: x[0])
+    tilt_lookup = sorted([[r['y_ratio'], float(r['tilt'])] for r in valid], key=lambda x: x[0])
+
+    lookup_path = scan_dir / 'lookup_table_orb.json'
+    with open(lookup_path, 'w', encoding='utf-8') as f:
+        json.dump({
+            'method': 'ORB',
+            'pan_lookup': pan_lookup,
+            'tilt_lookup': tilt_lookup,
+            'valid_count': len(valid),
+        }, f, indent=2, ensure_ascii=False)
+    logger.info(f'ORB 查找表已保存: {lookup_path} (有效 {len(valid)}/{len(records)})')
+
+    # 更新 CSV
+    csv_path = scan_dir / 'mapping_for_review_orb.csv'
+    with open(csv_path, 'w', encoding='utf-8') as f:
+        f.write('filename,pan,tilt,x_ratio,y_ratio,panorama_x,panorama_y,matched,review_x,review_y\n')
+        for r in records:
+            f.write(
+                f"{r['filename']},{r['pan']},{r['tilt']},"
+                f"{r.get('x_ratio', '')},{r.get('y_ratio', '')},"
+                f"{r.get('panorama_x', '')},{r.get('panorama_y', '')},"
+                f"{r['matched']},,\n"
+            )
+    logger.info(f'复核 CSV 已保存: {csv_path}')
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--scan-dir', type=str, default='/home/admin/dsh/calibration_scan',
+                        help='扫描结果目录')
+    args = parser.parse_args()
+    run_rematch(Path(args.scan_dir))
+
+
+if __name__ == '__main__':
+    main()

BIN
dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310-pytest-9.0.2.pyc


BIN
dual_camera_system/tests/__pycache__/test_polling_tracker.cpython-310.pyc


+ 51 - 0
dual_camera_system/tests/test_polling_tracker.py

@@ -87,6 +87,20 @@ class FakeTracker:
         self.released = True
 
 
+class FakeCalibrator:
+    """模拟已校准的标定器,返回固定的 PTZ 角度。"""
+
+    def __init__(self, pan=180.0, tilt=45.0):
+        self._pan = pan
+        self._tilt = tilt
+
+    def transform(self, x_ratio, y_ratio):
+        return (self._pan, self._tilt)
+
+    def is_calibrated(self):
+        return True
+
+
 class FakeEventPusher:
     def __init__(self):
         self.uploads = []
@@ -285,3 +299,40 @@ def test_ptz_worker_capture_flow():
     # 超过最大抓拍数后应返回 None
     record2 = coord._capture_one(target)
     assert record2 is None
+
+
+def test_capture_with_pan_flip_does_not_double_flip():
+    """
+    验证:当存在校准器且 ptz_config 启用 pan_flip 时,
+    不应在校准结果上再次应用 pan_flip。
+
+    场景:球机实际朝向与枪机相反(pan_flip=True)。
+    校准器通过真实扫描得到全景中心对应的球机角度为 180°,
+    该角度应直接发送给球机。若再次翻转,球机会被转到背面(0°)。
+    """
+    pan = FakePanorama()
+    ptz = FakePTZ()
+    ptz.ptz_config["pan_flip"] = True
+    tracker = FakeTracker([
+        TrackedPerson(track_id=1, bbox=(10, 20, 30, 40), center=(320, 240), confidence=0.9),
+    ])
+    calibrator = FakeCalibrator(pan=180.0, tilt=45.0)
+
+    coord = PollingTrackingCoordinator(
+        pan, ptz, tracker,
+        config={"ptz_stabilize_time": 0.01, "ptz_command_cooldown": 0.0},
+        calibrator=calibrator,
+    )
+
+    frame = pan.get_frame()
+    coord._update_active_targets(tracker.update(frame), frame.shape)
+
+    target = coord.active_targets[1]
+    record = coord._capture_one(target)
+
+    assert record is not None
+    assert len(ptz.commands) == 1
+    sent_pan, sent_tilt, sent_zoom = ptz.commands[0]
+    # 校准器返回的 180° 应直接发送,不能因 pan_flip 而被翻为 0°
+    assert sent_pan == 180.0
+    assert sent_tilt == 45.0

+ 5 - 0
run_calibrate.sh

@@ -0,0 +1,5 @@
+#!/bin/bash
+# 启动单次校准任务,防止重复运行
+cd /home/admin/dsh/dual_camera_system || exit 1
+source /home/admin/miniconda3/bin/activate rknn
+exec python -u main.py --force-calibration > /home/admin/dsh/calibrate_new2.log 2>&1

+ 40 - 0
test_detection.py

@@ -0,0 +1,40 @@
+import sys
+sys.path.insert(0, '/home/admin/dsh/dual_camera_system')
+import cv2
+from panorama_camera import ObjectDetector
+from config.detection import DETECTION_CONFIG
+import time
+
+print('Loading detector...')
+detector = ObjectDetector(
+    model_path=DETECTION_CONFIG['model_path'],
+    use_gpu=DETECTION_CONFIG['use_gpu'],
+    model_type=DETECTION_CONFIG['model_type']
+)
+print('Detector loaded, type:', detector.model_type)
+
+# Load test image
+frame = cv2.imread('/home/admin/dsh/panorama_now.jpg')
+if frame is None:
+    print('Failed to load image')
+    sys.exit(1)
+print('Image shape:', frame.shape)
+
+# Run detection
+t0 = time.time()
+dets = detector.detect(frame)
+t1 = time.time()
+print(f'Detection took {t1-t0:.3f}s, found {len(dets)} objects')
+
+for d in dets:
+    print(f'  {d.class_name}: conf={d.confidence:.3f}, bbox={d.bbox}, center={d.center}')
+
+# Save marked image
+marked = frame.copy()
+for d in dets:
+    x, y, w, h = d.bbox
+    cv2.rectangle(marked, (x, y), (x+w, y+h), (0, 255, 0), 2)
+    cv2.putText(marked, f'{d.class_name} {d.confidence:.2f}', (x, y-5),
+                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
+cv2.imwrite('/home/admin/dsh/detection_test.jpg', marked)
+print('Saved marked image to /home/admin/dsh/detection_test.jpg')

+ 32 - 0
verify_ptz.py

@@ -0,0 +1,32 @@
+import sys
+import cv2
+import time
+
+sys.path.insert(0, '/home/admin/dsh/dual_camera_system')
+from dual_camera_system.dahua_sdk import DahuaSDK
+from dual_camera_system.ptz_camera import PTZCamera
+from config.camera import CAMERA_GROUPS, SDK_PATH
+
+ptz_cfg = CAMERA_GROUPS[0]['ptz']
+lib_path = SDK_PATH['lib_path'] + '/' + SDK_PATH['netsdk']
+sdk = DahuaSDK(lib_path)
+print('SDK init:', sdk.init())
+ptz = PTZCamera(sdk, ptz_cfg)
+print('Connect:', ptz.connect())
+time.sleep(1)
+
+for x_ratio, y_ratio in [(0.5, 0.5), (0.8, 0.6), (0.2, 0.5)]:
+    pan, tilt, zoom = ptz.calculate_ptz_position(x_ratio, y_ratio)
+    print(f'({x_ratio},{y_ratio}) -> pan={pan:.1f} tilt={tilt:.1f} zoom={zoom}')
+    ptz.goto_exact_position(pan, tilt, zoom)
+    time.sleep(5)
+    cap = cv2.VideoCapture(ptz_cfg['rtsp_url'], cv2.CAP_FFMPEG)
+    ret, frame = cap.read()
+    cap.release()
+    if ret and frame is not None:
+        fname = f'/home/admin/dsh/verify_{x_ratio}_{y_ratio}.jpg'
+        cv2.imwrite(fname, frame)
+        print('Saved', fname, frame.shape)
+
+ptz.disconnect()
+sdk.cleanup()