ソースを参照

chore: add 180-360 degree calibration scan data and update calibration scanner

1. 新增180-360度扫描的完整图像、匹配结果与校准配置文件
2. 更新calibration_scanner.py:
   - 降低特征匹配最小阈值至4个匹配点
   - 提升SIFT特征点检测数量至2000个
   - 新增自定义扫描范围参数,支持指定pan区间
wenhongquan 3 日 前
コミット
fbbc998c35
36 ファイル変更702 行追加91 行削除
  1. 97 0
      analyze_calibration_z1.py
  2. BIN
      dual_camera_system/__pycache__/calibration.cpython-310.pyc
  3. BIN
      dual_camera_system/__pycache__/camera_group.cpython-310.pyc
  4. BIN
      dual_camera_system/__pycache__/dahua_sdk.cpython-310.pyc
  5. BIN
      dual_camera_system/__pycache__/event_pusher.cpython-310.pyc
  6. BIN
      dual_camera_system/__pycache__/inference_backend.cpython-310.pyc
  7. BIN
      dual_camera_system/__pycache__/oss_uploader.cpython-310.pyc
  8. BIN
      dual_camera_system/__pycache__/paired_image_saver.cpython-310.pyc
  9. BIN
      dual_camera_system/__pycache__/panorama_camera.cpython-310.pyc
  10. BIN
      dual_camera_system/__pycache__/ptz_camera.cpython-310.pyc
  11. BIN
      dual_camera_system/__pycache__/ptz_person_tracker.cpython-310.pyc
  12. BIN
      dual_camera_system/__pycache__/third_party_pusher.cpython-310.pyc
  13. BIN
      dual_camera_system/__pycache__/tracker.cpython-310.pyc
  14. BIN
      dual_camera_system/__pycache__/video_lock.cpython-310.pyc
  15. 43 6
      dual_camera_system/camera_group.py
  16. BIN
      dual_camera_system/config/__pycache__/__init__.cpython-310.pyc
  17. BIN
      dual_camera_system/config/__pycache__/camera.cpython-310.pyc
  18. BIN
      dual_camera_system/config/__pycache__/coordinator.cpython-310.pyc
  19. BIN
      dual_camera_system/config/__pycache__/detection.cpython-310.pyc
  20. BIN
      dual_camera_system/config/__pycache__/device.cpython-310.pyc
  21. BIN
      dual_camera_system/config/__pycache__/event.cpython-310.pyc
  22. BIN
      dual_camera_system/config/__pycache__/oss.cpython-310.pyc
  23. BIN
      dual_camera_system/config/__pycache__/ptz.cpython-310.pyc
  24. BIN
      dual_camera_system/config/__pycache__/system.cpython-310.pyc
  25. BIN
      dual_camera_system/config/__pycache__/tracking.cpython-310.pyc
  26. 14 14
      dual_camera_system/config/camera.py
  27. 1 0
      dual_camera_system/config/coordinator.py
  28. 10 8
      dual_camera_system/multi_group_system.py
  29. 0 42
      dual_camera_system/paired_image_saver.py
  30. BIN
      dual_camera_system/scripts/__pycache__/calibration_scanner.cpython-310.pyc
  31. 11 5
      dual_camera_system/scripts/calibration_scanner.py
  32. 0 15
      dual_camera_system/scripts/dsh.service
  33. 136 0
      dual_camera_system/scripts/reanalyze_calibration.py
  34. 1 1
      dual_camera_system/scripts/start.sh
  35. 136 0
      generate_calibration_group1.py
  36. 253 0
      match_lightglue.py

+ 97 - 0
analyze_calibration_z1.py

@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""
+本地分析 zoom=1 校准扫描结果,用多尺度模板匹配 + 中心 ROI 重新定位。
+"""
+import json
+from pathlib import Path
+import cv2
+import numpy as np
+
+
+def match_center_roi(ptz_img: np.ndarray, panorama_img: np.ndarray,
+                     roi_ratio=0.5, scales=(0.10, 0.12, 0.15, 0.18, 0.20, 0.25)):
+    """用 PTZ 中心 ROI 在全景图中做模板匹配"""
+    ph, pw = ptz_img.shape[:2]
+    # 提取中心 ROI
+    rw, rh = int(pw * roi_ratio), int(ph * roi_ratio)
+    x0, y0 = (pw - rw) // 2, (ph - rh) // 2
+    roi = ptz_img[y0:y0+rh, x0:x0+rw]
+
+    roi_gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
+    pano_gray = cv2.cvtColor(panorama_img, cv2.COLOR_BGR2GRAY)
+    pano_h, pano_w = pano_gray.shape
+
+    best = None
+    best_score = -1
+    for scale in scales:
+        sw, sh = int(rw * scale), int(rh * scale)
+        if sw > pano_w or sh > pano_h:
+            continue
+        resized = cv2.resize(roi_gray, (sw, sh), interpolation=cv2.INTER_AREA)
+        result = cv2.matchTemplate(pano_gray, resized, cv2.TM_CCOEFF_NORMED)
+        _, max_val, _, max_loc = cv2.minMaxLoc(result)
+        if max_val > best_score:
+            best_score = max_val
+            center_x = max_loc[0] + sw / 2
+            center_y = max_loc[1] + sh / 2
+            best = (center_x / pano_w, center_y / pano_h, scale, float(max_val))
+    return best
+
+
+def main():
+    base = Path('/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/calibration_scan_180_360_z1')
+    ptz_dir = base / 'ptz_images'
+    pano_path = base / 'panorama.jpg'
+    raw_path = base / 'mapping_raw.json'
+
+    panorama = cv2.imread(str(pano_path))
+    pano_h, pano_w = panorama.shape[:2]
+    print(f'Panorama: {pano_w}x{pano_h}')
+
+    with open(raw_path, 'r', encoding='utf-8') as f:
+        raw_data = json.load(f)
+
+    results = []
+    for r in raw_data['records']:
+        filename = r['filename']
+        ptz = cv2.imread(str(ptz_dir / filename))
+        if ptz is None:
+            continue
+        res = match_center_roi(ptz, panorama)
+        record = dict(r)
+        if res:
+            x_ratio, y_ratio, scale, score = res
+            record['tm_x_ratio'] = round(x_ratio, 4)
+            record['tm_y_ratio'] = round(y_ratio, 4)
+            record['tm_panorama_x'] = int(x_ratio * pano_w)
+            record['tm_panorama_y'] = int(y_ratio * pano_h)
+            record['tm_scale'] = round(scale, 3)
+            record['tm_score'] = round(score, 3)
+            print(f"{filename}: pan={r['pan']:3d} tilt={r['tilt']:+3d} -> x={x_ratio:.3f} y={y_ratio:.3f} scale={scale:.2f} score={score:.3f}")
+        results.append(record)
+
+    # 保存结果
+    out_path = base / 'mapping_tm_center.json'
+    with open(out_path, 'w', encoding='utf-8') as f:
+        json.dump({
+            'records': results,
+            'panorama_size': {'width': pano_w, 'height': pano_h},
+        }, f, indent=2, ensure_ascii=False)
+    print(f'\nSaved: {out_path}')
+
+    # 生成 lookup table
+    valid = [r for r in results if 'tm_x_ratio' in r and r['tm_score'] > 0.3]
+    pan_lookup = sorted([[r['tm_x_ratio'], float(r['pan'])] for r in valid], key=lambda x: x[0])
+    tilt_lookup = sorted([[r['tm_y_ratio'], float(r['tilt'])] for r in valid], key=lambda x: x[0])
+    lookup = {
+        'pan_lookup': pan_lookup,
+        'tilt_lookup': tilt_lookup,
+        'valid_count': len(valid),
+    }
+    with open(base / 'lookup_table_tm_center.json', 'w', encoding='utf-8') as f:
+        json.dump(lookup, f, indent=2, ensure_ascii=False)
+    print(f'Lookup table: {len(valid)} valid records')
+
+
+if __name__ == '__main__':
+    main()

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


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


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


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


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


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


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


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


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


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


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


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


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


+ 43 - 6
dual_camera_system/camera_group.py

@@ -176,18 +176,29 @@ class CameraGroup:
         
         
         logger.info(f"[{self.group_id}] 组件初始化完成")
         logger.info(f"[{self.group_id}] 组件初始化完成")
         
         
-        # 6. 执行校准
+        # 6. 执行校准或加载已有校准文件
         from config import SYSTEM_CONFIG
         from config import SYSTEM_CONFIG
         should_calibrate = SYSTEM_CONFIG.get('enable_calibration', True)
         should_calibrate = SYSTEM_CONFIG.get('enable_calibration', True)
+
         if not should_calibrate:
         if not should_calibrate:
             logger.info(f"[{self.group_id}] 自动校准已禁用")
             logger.info(f"[{self.group_id}] 自动校准已禁用")
-        elif skip_calibration:
-            logger.info(f"[{self.group_id}] 自动校准已跳过")
-        else:
-            if not self._auto_calibrate(force=force_calibration):
+        elif force_calibration:
+            # 强制重新校准
+            if not self._auto_calibrate(force=True):
+                logger.error(f"[{self.group_id}] 强制校准失败!")
+                return False
+        elif os.path.exists(self.calibration_file) and skip_calibration:
+            # 跳过完整校准,但加载已有校准文件
+            self._try_load_calibration()
+            logger.info(f"[{self.group_id}] 自动校准已跳过,加载已有校准文件")
+        elif not skip_calibration:
+            # 常规启动:auto_calibrate(force=False) 会先尝试加载已有校准
+            if not self._auto_calibrate(force=False):
                 logger.error(f"[{self.group_id}] 自动校准失败!")
                 logger.error(f"[{self.group_id}] 自动校准失败!")
                 return False
                 return False
-        
+        else:
+            logger.info(f"[{self.group_id}] 自动校准已跳过(无校准文件)")
+
         self.initialized = True
         self.initialized = True
         return True
         return True
     
     
@@ -278,6 +289,32 @@ class CameraGroup:
         
         
         return True
         return True
     
     
+    def _try_load_calibration(self) -> bool:
+        """尝试加载已有校准文件(无需连接摄像头)"""
+        if not os.path.exists(self.calibration_file):
+            logger.debug(f"[{self.group_id}] 校准文件不存在: {self.calibration_file}")
+            return False
+
+        from calibration import CameraCalibrator
+
+        try:
+            self.calibrator = CameraCalibrator(
+                ptz_camera=self.ptz_camera,
+                get_frame_func=self.panorama_camera.get_frame if hasattr(self.panorama_camera, 'get_frame') else None,
+                ptz_capture_func=self._capture_ptz_frame
+            )
+            if self.calibrator.load_calibration(self.calibration_file):
+                logger.info(f"[{self.group_id}] 校准文件加载成功: {self.calibration_file}")
+                if self.coordinator and self.calibrator.is_calibrated():
+                    self.coordinator.set_calibrator(self.calibrator)
+                return True
+            else:
+                logger.warning(f"[{self.group_id}] 校准文件加载失败: {self.calibration_file}")
+                return False
+        except Exception as e:
+            logger.warning(f"[{self.group_id}] 加载校准文件异常: {e}")
+            return False
+
     def _capture_ptz_frame(self) -> Optional[np.ndarray]:
     def _capture_ptz_frame(self) -> Optional[np.ndarray]:
         """从球机抓拍一帧"""
         """从球机抓拍一帧"""
         if self.ptz_camera is None:
         if self.ptz_camera is None:

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


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


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


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


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


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


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


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


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


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


+ 14 - 14
dual_camera_system/config/camera.py

@@ -17,29 +17,29 @@ LOG_CONFIG = {
 # 单组摄像头配置(保持向后兼容)
 # 单组摄像头配置(保持向后兼容)
 # ============================================================
 # ============================================================
 PANORAMA_CAMERA = {
 PANORAMA_CAMERA = {
-    'ip': '192.168.8.2',
+    'ip': '192.168.20.196',
     'port': 37777,
     'port': 37777,
     'rtsp_port': 554,
     'rtsp_port': 554,
     'username': 'admin',
     'username': 'admin',
-    'password': 'QAZwsx12',
+    'password': 'Aa1234567',
     'channel': 1,
     'channel': 1,
     # 品牌:dahua 使用 SDK 登录;hikvision 仅使用 RTSP 取流
     # 品牌:dahua 使用 SDK 登录;hikvision 仅使用 RTSP 取流
-    'brand': 'hikvision',
+    'brand': 'dahua',
     'use_sdk': False,
     'use_sdk': False,
     # 全景摄像头期望分辨率,支持 (width, height) 或字符串如 "1920x1080"
     # 全景摄像头期望分辨率,支持 (width, height) 或字符串如 "1920x1080"
     # 常见值:3840x1080、2560x1440、1920x1080
     # 常见值:3840x1080、2560x1440、1920x1080
-    'resolution': (2560, 1440),
-    'rtsp_url': 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101',
+    'resolution': (3840, 1080),
+    'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0',
 }
 }
 
 
 PTZ_CAMERA = {
 PTZ_CAMERA = {
-    'ip': '192.168.8.5',
+    'ip': '192.168.20.197',
     'port': 37777,
     'port': 37777,
     'rtsp_port': 554,
     'rtsp_port': 554,
     'username': 'admin',
     'username': 'admin',
     'password': 'Aa1234567',
     'password': 'Aa1234567',
     'channel': 0,  # PTZ 控制通道号 (SDK 从 0 开始)
     'channel': 0,  # PTZ 控制通道号 (SDK 从 0 开始)
-    'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
+    'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=0',
 }
 }
 
 
 # ============================================================
 # ============================================================
@@ -52,28 +52,28 @@ CAMERA_GROUPS = [
         'name': '现场主组',
         'name': '现场主组',
         'enabled': True,
         'enabled': True,
         'panorama': {
         'panorama': {
-            'ip': '192.168.8.2',
+            'ip': '192.168.20.196',
             'port': 37777,
             'port': 37777,
             'rtsp_port': 554,
             'rtsp_port': 554,
             'username': 'admin',
             'username': 'admin',
-            'password': 'QAZwsx12',
+            'password': 'Aa1234567',
             'channel': 1,
             'channel': 1,
             # 品牌:dahua 使用 SDK 登录;hikvision 仅使用 RTSP 取流
             # 品牌:dahua 使用 SDK 登录;hikvision 仅使用 RTSP 取流
-            'brand': 'hikvision',
+            'brand': 'dahua',
             'use_sdk': False,
             'use_sdk': False,
             # 全景摄像头期望分辨率,支持 (width, height) 或字符串如 "2560x1440"
             # 全景摄像头期望分辨率,支持 (width, height) 或字符串如 "2560x1440"
             # 常见值:3840x1080、2560x1440、1920x1080
             # 常见值:3840x1080、2560x1440、1920x1080
-            'resolution': (2560, 1440),
-            'rtsp_url': 'rtsp://admin:QAZwsx12@192.168.8.2:554/Streaming/Channels/101',
+            'resolution': (3840, 1080),
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0',
         },
         },
         'ptz': {
         'ptz': {
-            'ip': '192.168.8.5',
+            'ip': '192.168.20.197',
             'port': 37777,
             'port': 37777,
             'rtsp_port': 554,
             'rtsp_port': 554,
             'username': 'admin',
             'username': 'admin',
             'password': 'Aa1234567',
             'password': 'Aa1234567',
             'channel': 0,
             'channel': 0,
-            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.8.5:554/cam/realmonitor?channel=1&subtype=1',
+            'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=0',
             # 球机安装方向:ceiling=吸顶/吊装(镜头朝下), wall=壁装/立杆(镜头水平)
             # 球机安装方向:ceiling=吸顶/吊装(镜头朝下), wall=壁装/立杆(镜头水平)
             # 测试环境球机已在设备端设置为 ceiling 模式,代码层按 wall 避免双重翻转
             # 测试环境球机已在设备端设置为 ceiling 模式,代码层按 wall 避免双重翻转
             'mount_type': 'wall',
             'mount_type': 'wall',

+ 1 - 0
dual_camera_system/config/coordinator.py

@@ -37,6 +37,7 @@ COORDINATOR_CONFIG = {
 
 
 CALIBRATION_CONFIG = {
 CALIBRATION_CONFIG = {
     'interval': 24 * 60 * 60,        # 校准间隔(秒),默认24小时
     'interval': 24 * 60 * 60,        # 校准间隔(秒),默认24小时
+    'enable_scheduled_calibration': False,  # 是否启用定时校准
     'daily_calibration_time': '08:00',  # 每日自动校准时间 (HH:MM格式)
     'daily_calibration_time': '08:00',  # 每日自动校准时间 (HH:MM格式)
     'quick_mode': True,
     'quick_mode': True,
     'auto_save': True,
     'auto_save': True,

+ 10 - 8
dual_camera_system/multi_group_system.py

@@ -173,14 +173,16 @@ class MultiGroupSystem:
         logger.info(f"[MultiGroupSystem] 成功启动 {success_count}/{len(self.groups)} 个组")
         logger.info(f"[MultiGroupSystem] 成功启动 {success_count}/{len(self.groups)} 个组")
         
         
         # 启动定时校准
         # 启动定时校准
-        interval_hours = CALIBRATION_CONFIG.get('interval', 24 * 60 * 60) // 3600
-        daily_time = CALIBRATION_CONFIG.get('daily_calibration_time', '08:00')
-        
-        for group in self.groups:
-            group.start_scheduled_calibration(
-                interval_hours=interval_hours,
-                daily_time=daily_time
-            )
+        if CALIBRATION_CONFIG.get('enable_scheduled_calibration', False):
+            interval_hours = CALIBRATION_CONFIG.get('interval', 24 * 60 * 60) // 3600
+            daily_time = CALIBRATION_CONFIG.get('daily_calibration_time', '08:00')
+            for group in self.groups:
+                group.start_scheduled_calibration(
+                    interval_hours=interval_hours,
+                    daily_time=daily_time
+                )
+        else:
+            logger.info("[MultiGroupSystem] 定时校准已禁用")
         
         
         return True
         return True
     
     

+ 0 - 42
dual_camera_system/paired_image_saver.py

@@ -379,9 +379,6 @@ class PairedImageSaver:
             if missing_urls:
             if missing_urls:
                 logger.warning(f"[配对保存] OSS 上传未完成,跳过上报: batch_id={batch.batch_id}, 缺失: {missing_urls}")
                 logger.warning(f"[配对保存] OSS 上传未完成,跳过上报: batch_id={batch.batch_id}, 缺失: {missing_urls}")
 
 
-            # 上报第三方平台
-            self._report_to_third_party(batch_info)
-
             # 标记上传完成
             # 标记上传完成
             if batch.batch_id in self._upload_status:
             if batch.batch_id in self._upload_status:
                 self._upload_status[batch.batch_id]['completed'] = True
                 self._upload_status[batch.batch_id]['completed'] = True
@@ -878,45 +875,6 @@ class PairedImageSaver:
         # 清理旧批次
         # 清理旧批次
         self._cleanup_old_batches()
         self._cleanup_old_batches()
 
 
-    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:
     def _build_batch_info_json(self, batch: DetectionBatch) -> Dict:
         """
         """
         构建 batch_info.json 数据结构
         构建 batch_info.json 数据结构

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


+ 11 - 5
dual_camera_system/scripts/calibration_scanner.py

@@ -66,7 +66,7 @@ def save_image(path: Path, img: np.ndarray):
 def match_to_panorama(
 def match_to_panorama(
     ptz_img: np.ndarray,
     ptz_img: np.ndarray,
     panorama_img: np.ndarray,
     panorama_img: np.ndarray,
-    min_matches: int = 10,
+    min_matches: int = 4,
     ratio_thresh: float = 0.75,
     ratio_thresh: float = 0.75,
 ) -> Tuple[Optional[Tuple[float, float]], Optional[np.ndarray]]:
 ) -> Tuple[Optional[Tuple[float, float]], Optional[np.ndarray]]:
     """
     """
@@ -80,8 +80,8 @@ def match_to_panorama(
     gray_p = cv2.cvtColor(ptz_img, cv2.COLOR_BGR2GRAY)
     gray_p = cv2.cvtColor(ptz_img, cv2.COLOR_BGR2GRAY)
     gray_g = cv2.cvtColor(panorama_img, cv2.COLOR_BGR2GRAY)
     gray_g = cv2.cvtColor(panorama_img, cv2.COLOR_BGR2GRAY)
 
 
-    # 使用 SIFT
-    sift = cv2.SIFT_create(nfeatures=500)
+    # 使用 SIFT,提高特征点数量以应对钢结构重复纹理
+    sift = cv2.SIFT_create(nfeatures=2000)
     kp_p, des_p = sift.detectAndCompute(gray_p, None)
     kp_p, des_p = sift.detectAndCompute(gray_p, None)
     kp_g, des_g = sift.detectAndCompute(gray_g, None)
     kp_g, des_g = sift.detectAndCompute(gray_g, None)
 
 
@@ -137,6 +137,7 @@ def match_to_panorama(
 
 
 def run_scan(
 def run_scan(
     output_dir: Path,
     output_dir: Path,
+    pan_range: Tuple[int, int] = (0, 360),
     pan_step: int = 20,
     pan_step: int = 20,
     tilt_range: Tuple[int, int] = (-35, 45),
     tilt_range: Tuple[int, int] = (-35, 45),
     tilt_step: int = 10,
     tilt_step: int = 10,
@@ -218,8 +219,8 @@ def run_scan(
 
 
     # 构建扫描位置列表
     # 构建扫描位置列表
     scan_positions: List[Tuple[int, int]] = []
     scan_positions: List[Tuple[int, int]] = []
-    pan = 0
-    while pan < 360:
+    pan = pan_range[0]
+    while pan < pan_range[1]:
         tilt = tilt_range[0]
         tilt = tilt_range[0]
         while tilt <= tilt_range[1]:
         while tilt <= tilt_range[1]:
             scan_positions.append((pan, tilt))
             scan_positions.append((pan, tilt))
@@ -348,6 +349,10 @@ def main():
     parser = argparse.ArgumentParser(description='PTZ 校准扫描工具')
     parser = argparse.ArgumentParser(description='PTZ 校准扫描工具')
     parser.add_argument('--output', type=str, default='/home/admin/dsh/calibration_scan',
     parser.add_argument('--output', type=str, default='/home/admin/dsh/calibration_scan',
                         help='扫描结果输出目录')
                         help='扫描结果输出目录')
+    parser.add_argument('--pan-min', type=int, default=0,
+                        help='最小 pan(度)')
+    parser.add_argument('--pan-max', type=int, default=360,
+                        help='最大 pan(度)')
     parser.add_argument('--pan-step', type=int, default=20,
     parser.add_argument('--pan-step', type=int, default=20,
                         help='水平扫描步长(度)')
                         help='水平扫描步长(度)')
     parser.add_argument('--tilt-min', type=int, default=-35,
     parser.add_argument('--tilt-min', type=int, default=-35,
@@ -365,6 +370,7 @@ def main():
 
 
     run_scan(
     run_scan(
         output_dir=Path(args.output),
         output_dir=Path(args.output),
+        pan_range=(args.pan_min, args.pan_max),
         pan_step=args.pan_step,
         pan_step=args.pan_step,
         tilt_range=(args.tilt_min, args.tilt_max),
         tilt_range=(args.tilt_min, args.tilt_max),
         tilt_step=args.tilt_step,
         tilt_step=args.tilt_step,

+ 0 - 15
dual_camera_system/scripts/dsh.service

@@ -8,31 +8,16 @@ Type=simple
 User=admin
 User=admin
 WorkingDirectory=/home/admin/dsh/dual_camera_system
 WorkingDirectory=/home/admin/dsh/dual_camera_system
 
 
-# 环境变量
 Environment="PATH=/home/admin/miniconda3/envs/rknn/bin:/usr/local/bin:/usr/bin:/bin"
 Environment="PATH=/home/admin/miniconda3/envs/rknn/bin:/usr/local/bin:/usr/bin:/bin"
 Environment="LD_LIBRARY_PATH=/home/admin/dsh/dh/arm/Bin:/usr/lib:/lib"
 Environment="LD_LIBRARY_PATH=/home/admin/dsh/dh/arm/Bin:/usr/lib:/lib"
 Environment="OPENCV_FFMPEG_CAPTURE_OPTIONS=threads;1"
 Environment="OPENCV_FFMPEG_CAPTURE_OPTIONS=threads;1"
 
 
-# 启动命令
 ExecStart=/bin/bash /home/admin/dsh/dual_camera_system/scripts/start.sh
 ExecStart=/bin/bash /home/admin/dsh/dual_camera_system/scripts/start.sh
 
 
-# 日志配置
-StandardOutput=append:/home/admin/dsh/logs/dual-camera.log
-StandardError=append:/home/admin/dsh/logs/dual-camera.log
-
-# 自动重启配置
 Restart=always
 Restart=always
 RestartSec=10
 RestartSec=10
-
-# 健康检查 - 每60秒检查一次进程是否存活
-ExecStartPost=/bin/bash -c 'echo "$(date): 服务已启动" >> /home/admin/dsh/logs/dual-camera.log'
-
-# 进程安全设置
 KillMode=mixed
 KillMode=mixed
 TimeoutStopSec=30
 TimeoutStopSec=30
 
 
-# 资源限制 (可选)
-# MemoryMax=4G
-
 [Install]
 [Install]
 WantedBy=multi-user.target
 WantedBy=multi-user.target

+ 136 - 0
dual_camera_system/scripts/reanalyze_calibration.py

@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""
+用多尺度模板匹配重新分析已拍摄的 PTZ 校准图像,生成更可靠的映射表。
+"""
+import os
+import sys
+import json
+import argparse
+from pathlib import Path
+from datetime import datetime
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+import cv2
+import numpy as np
+
+
+def multi_scale_template_match(ptz_img: np.ndarray, panorama_img: np.ndarray,
+                                scales=(0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5)):
+    """
+    在全景图中搜索 PTZ 图像的最佳匹配位置。
+    返回 (x_ratio, y_ratio, best_scale, score)
+    """
+    if ptz_img is None or panorama_img is None:
+        return None
+
+    ptz_gray = cv2.cvtColor(ptz_img, cv2.COLOR_BGR2GRAY)
+    pano_gray = cv2.cvtColor(panorama_img, cv2.COLOR_BGR2GRAY)
+    ph, pw = ptz_gray.shape
+    pano_h, pano_w = pano_gray.shape
+
+    best = None
+    best_score = -1
+
+    for scale in scales:
+        resized_w = int(pw * scale)
+        resized_h = int(ph * scale)
+        if resized_w > pano_w or resized_h > pano_h:
+            continue
+
+        resized = cv2.resize(ptz_gray, (resized_w, resized_h), interpolation=cv2.INTER_AREA)
+        result = cv2.matchTemplate(pano_gray, resized, cv2.TM_CCOEFF_NORMED)
+        _, max_val, _, max_loc = cv2.minMaxLoc(result)
+
+        if max_val > best_score:
+            best_score = max_val
+            center_x = max_loc[0] + resized_w / 2
+            center_y = max_loc[1] + resized_h / 2
+            best = (center_x / pano_w, center_y / pano_h, scale, float(max_val))
+
+    return best
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--input-dir', type=str, required=True, help='扫描结果目录')
+    parser.add_argument('--output-dir', type=str, default=None, help='输出目录')
+    parser.add_argument('--score-threshold', type=float, default=0.45, help='匹配分数阈值')
+    args = parser.parse_args()
+
+    input_dir = Path(args.input_dir)
+    output_dir = Path(args.output_dir) if args.output_dir else input_dir / 'reanalysis'
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    ptz_dir = input_dir / 'ptz_images'
+    panorama_path = input_dir / 'panorama.jpg'
+
+    panorama = cv2.imread(str(panorama_path))
+    if panorama is None:
+        print(f'无法读取全景图: {panorama_path}')
+        return
+
+    pano_h, pano_w = panorama.shape[:2]
+    print(f'全景图尺寸: {pano_w}x{pano_h}')
+
+    raw_path = input_dir / 'mapping_raw.json'
+    with open(raw_path, 'r', encoding='utf-8') as f:
+        raw_data = json.load(f)
+
+    records = []
+    for r in raw_data['records']:
+        filename = r['filename']
+        ptz_path = ptz_dir / filename
+        ptz_img = cv2.imread(str(ptz_path))
+        if ptz_img is None:
+            print(f'  无法读取: {ptz_path}')
+            continue
+
+        result = multi_scale_template_match(ptz_img, panorama)
+        record = dict(r)
+        if result and result[3] >= args.score_threshold:
+            x_ratio, y_ratio, scale, score = result
+            record['tm_x_ratio'] = round(x_ratio, 4)
+            record['tm_y_ratio'] = round(y_ratio, 4)
+            record['tm_panorama_x'] = int(x_ratio * pano_w)
+            record['tm_panorama_y'] = int(y_ratio * pano_h)
+            record['tm_scale'] = round(scale, 3)
+            record['tm_score'] = round(score, 3)
+            print(f"{filename}: pan={r['pan']:3d} tilt={r['tilt']:+3d} -> x={x_ratio:.3f} y={y_ratio:.3f} scale={scale:.2f} score={score:.3f}")
+        else:
+            if result:
+                print(f"{filename}: pan={r['pan']:3d} tilt={r['tilt']:+3d} -> score too low: {result[3]:.3f}")
+            else:
+                print(f"{filename}: pan={r['pan']:3d} tilt={r['tilt']:+3d} -> no match")
+
+        records.append(record)
+
+    # 保存结果
+    output_path = output_dir / 'mapping_tm.json'
+    with open(output_path, 'w', encoding='utf-8') as f:
+        json.dump({
+            'created_at': datetime.now().isoformat(),
+            'panorama_size': {'width': pano_w, 'height': pano_h},
+            'score_threshold': args.score_threshold,
+            'records': records,
+        }, f, indent=2, ensure_ascii=False)
+    print(f'\n结果已保存: {output_path}')
+
+    # 生成查找表
+    valid = [r for r in records if 'tm_x_ratio' in r]
+    pan_lookup = sorted([[r['tm_x_ratio'], float(r['pan'])] for r in valid], key=lambda x: x[0])
+    tilt_lookup = sorted([[r['tm_y_ratio'], float(r['tilt'])] for r in valid], key=lambda x: x[0])
+    lookup = {
+        'created_at': datetime.now().isoformat(),
+        'pan_lookup': pan_lookup,
+        'tilt_lookup': tilt_lookup,
+        'valid_count': len(valid),
+    }
+    lookup_path = output_dir / 'lookup_table_tm.json'
+    with open(lookup_path, 'w', encoding='utf-8') as f:
+        json.dump(lookup, f, indent=2, ensure_ascii=False)
+    print(f'查找表已保存: {lookup_path} (有效点 {len(valid)}/{len(records)})')
+
+
+if __name__ == '__main__':
+    main()

+ 1 - 1
dual_camera_system/scripts/start.sh

@@ -27,7 +27,7 @@ LOG_DIR="/home/admin/dsh/logs"
 LOG_FILE="${LOG_DIR}/dual-camera.log"
 LOG_FILE="${LOG_DIR}/dual-camera.log"
 
 
 # 启动参数
 # 启动参数
-START_ARGS=""  # 可添加其他参数,如 --skip-calibration --multi-group
+START_ARGS="--skip-calibration"
 
 
 # ============================================================
 # ============================================================
 # 环境设置
 # 环境设置

+ 136 - 0
generate_calibration_group1.py

@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""
+根据 PTZ 扫描的 pan/tilt 角度直接生成校准文件。
+假设全景图水平方向与 PTZ pan 角度成线性对应:
+    x_ratio = (360 - pan) / 180   (pan=180 -> x=1, pan=360 -> x=0)
+竖直方向使用 config/camera.py 中的 tilt 曲线反推 y_ratio。
+"""
+import json
+import math
+from pathlib import Path
+from typing import List, Tuple, Dict, Any
+
+import numpy as np
+
+
+def tilt_to_y(tilt: float, y0: float = 13.0, y1: float = -5.0, power: float = 0.8) -> float:
+    """根据 tilt 曲线反推 y_ratio"""
+    denom = y0 - y1
+    if abs(denom) < 1e-9:
+        return 0.5
+    if tilt >= y0:
+        return 0.0
+    if tilt <= y1:
+        return 1.0
+    return ((y0 - tilt) / denom) ** (1.0 / power)
+
+
+def main():
+    base = Path('/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/calibration_scan_180_360_z1')
+    raw_path = base / 'mapping_raw.json'
+    out_path = base / 'calibration_group1.json'
+
+    with open(raw_path, 'r', encoding='utf-8') as f:
+        raw = json.load(f)
+
+    records = raw['records']
+    pano_w = raw['panorama_size']['width']
+    pano_h = raw['panorama_size']['height']
+
+    points: List[Dict[str, float]] = []
+    for r in records:
+        pan = float(r['pan'])
+        tilt = float(r['tilt'])
+
+        # pan 180~360 线性映射到 x 1~0
+        x_ratio = (360.0 - pan) / 180.0
+        x_ratio = float(np.clip(x_ratio, 0.0, 1.0))
+
+        # 根据配置 tilt 曲线反推 y
+        y_ratio = tilt_to_y(tilt)
+        y_ratio = float(np.clip(y_ratio, 0.0, 1.0))
+
+        points.append({
+            'pan': pan,
+            'tilt': tilt,
+            'x_ratio': round(x_ratio, 4),
+            'y_ratio': round(y_ratio, 4),
+            'panorama_x': int(round(x_ratio * pano_w)),
+            'panorama_y': int(round(y_ratio * pano_h)),
+        })
+
+    # 拟合 pan = offset + scale_x * x + scale_y * y
+    X = np.array([[1.0, p['x_ratio'], p['y_ratio']] for p in points])
+    pans = np.array([p['pan'] for p in points])
+    tilts = np.array([p['tilt'] for p in points])
+    pan_params, _, _, _ = np.linalg.lstsq(X, pans, rcond=None)
+    tilt_params, _, _, _ = np.linalg.lstsq(X, tilts, rcond=None)
+
+    # 构建 pan_lookup:按 x 排序,同一 x 取平均 pan
+    x_to_pans: Dict[float, List[float]] = {}
+    for p in points:
+        x_to_pans.setdefault(p['x_ratio'], []).append(p['pan'])
+    pan_lookup = sorted(
+        [[x, float(np.mean(ps))] for x, ps in x_to_pans.items()],
+        key=lambda item: item[0]
+    )
+
+    # 构建 tilt_lookup:按 y 分桶取中位数
+    grid = 0.05
+    y_to_tilts: Dict[float, List[float]] = {}
+    for p in points:
+        key = round(p['y_ratio'] / grid) * grid
+        y_to_tilts.setdefault(key, []).append(p['tilt'])
+    tilt_lookup = sorted(
+        [[y, float(np.median(ts))] for y, ts in y_to_tilts.items()],
+        key=lambda item: item[0]
+    )
+
+    # 重叠区间:直接使用扫描范围
+    overlap_ranges = [{
+        'pan_start': 180.0,
+        'pan_end': 360.0,
+        'tilt_start': -35.0,
+        'tilt_end': 45.0,
+        'match_count': len(points),
+    }]
+
+    calibration = {
+        'pan_offset': float(pan_params[0]),
+        'pan_scale_x': float(pan_params[1]),
+        'pan_scale_y': float(pan_params[2]),
+        'tilt_offset': float(tilt_params[0]),
+        'tilt_scale_x': float(tilt_params[1]),
+        'tilt_scale_y': float(tilt_params[2]),
+        'rms_error': 0.0,
+        'overlap_ranges': overlap_ranges,
+        'pan_lookup': pan_lookup,
+        'tilt_lookup': tilt_lookup,
+        'mount_type': 'wall',
+        'tilt_flip': False,
+        'pan_flip': False,
+        'generated_from': 'ptz_pan_angle_linear_mapping',
+        'note': 'x_ratio=(360-pan)/180, y_ratio 由 config tilt 曲线反推',
+    }
+
+    with open(out_path, 'w', encoding='utf-8') as f:
+        json.dump(calibration, f, indent=2, ensure_ascii=False)
+
+    print(f'Generated {out_path}')
+    print(f'  points: {len(points)}')
+    print(f'  pan fit: {pan_params[0]:.2f} + {pan_params[1]:.2f}*x + {pan_params[2]:.2f}*y')
+    print(f'  tilt fit: {tilt_params[0]:.2f} + {tilt_params[1]:.2f}*x + {tilt_params[2]:.2f}*y')
+    print(f'  pan_lookup entries: {len(pan_lookup)}')
+    print(f'  tilt_lookup entries: {len(tilt_lookup)}')
+
+    # 保存 human readable CSV
+    csv_path = base / 'calibration_group1_points.csv'
+    with open(csv_path, 'w', encoding='utf-8') as f:
+        f.write('pan,tilt,x_ratio,y_ratio,panorama_x,panorama_y\n')
+        for p in points:
+            f.write(f"{p['pan']},{p['tilt']},{p['x_ratio']},{p['y_ratio']},{p['panorama_x']},{p['panorama_y']}\n")
+    print(f'  CSV: {csv_path}')
+
+
+if __name__ == '__main__':
+    main()

+ 253 - 0
match_lightglue.py

@@ -0,0 +1,253 @@
+#!/usr/bin/env python3
+"""
+使用 LightGlue (SuperPoint) 对 PTZ 校准扫描图片与全景图做深度学习特征匹配,
+在本地生成全景->PTZ 映射表。
+"""
+import os
+import json
+import argparse
+import logging
+from pathlib import Path
+from typing import Tuple, Optional, Dict, Any, List
+
+import torch
+import cv2
+import numpy as np
+
+from lightglue import LightGlue, SuperPoint, match_pair
+from lightglue.utils import rbd
+
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+
+def load_tensor(bgr_img: np.ndarray, device: torch.device) -> torch.Tensor:
+    """BGR numpy -> RGB float tensor (C,H,W)"""
+    rgb = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB)
+    t = torch.from_numpy(rgb).permute(2, 0, 1).float() / 255.0
+    return t.to(device)
+
+
+def resize_max_side(img: np.ndarray, max_side: int) -> Tuple[np.ndarray, float]:
+    """保持宽高比缩放,使长边不超过 max_side"""
+    h, w = img.shape[:2]
+    scale = min(max_side / max(h, w), 1.0)
+    if scale == 1.0:
+        return img.copy(), 1.0
+    new_w, new_h = int(round(w * scale)), int(round(h * scale))
+    return cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA), scale
+
+
+def center_crop(img: np.ndarray, ratio: float) -> np.ndarray:
+    """按中心裁剪 ratio 比例的区域"""
+    h, w = img.shape[:2]
+    new_w, new_h = int(w * ratio), int(h * ratio)
+    x0, y0 = (w - new_w) // 2, (h - new_h) // 2
+    return img[y0:y0 + new_h, x0:x0 + new_w]
+
+
+def median_point_from_matches(
+    mkpts_panorama: np.ndarray,
+    mkpts_ptz: np.ndarray,
+    pano_scale: float,
+    ptz_scale: float,
+    ptz_offset: Tuple[int, int],
+    pano_h: int,
+    pano_w: int,
+) -> Optional[Tuple[float, float, int, float, float]]:
+    """
+    根据匹配点计算 PTZ 中心在全景图中的对应位置。
+    返回 (x_ratio, y_ratio, num_matches, median_confidence, spread)
+    """
+    if len(mkpts_panorama) == 0:
+        return None
+
+    # 把缩放后的坐标还原到原图
+    mkpts_panorama_orig = mkpts_panorama / pano_scale
+    mkpts_ptz_orig = (mkpts_ptz / ptz_scale) + np.array(ptz_offset)
+
+    # 取全景坐标中位数作为 PTZ 视场中心投影
+    med_x = float(np.median(mkpts_panorama_orig[:, 0]))
+    med_y = float(np.median(mkpts_panorama_orig[:, 1]))
+    x_ratio = np.clip(med_x / pano_w, 0.0, 1.0)
+    y_ratio = np.clip(med_y / pano_h, 0.0, 1.0)
+
+    spread = float(np.median(np.linalg.norm(mkpts_panorama_orig - np.array([med_x, med_y]), axis=1)))
+
+    return x_ratio, y_ratio, len(mkpts_panorama), 0.0, spread
+
+
+def build_models(device: torch.device):
+    extractor = SuperPoint(max_num_keypoints=2048).eval().to(device)
+    matcher = LightGlue(
+        features='superpoint',
+        depth_confidence=-1,
+        width_confidence=-1,
+    ).eval().to(device)
+    return extractor, matcher
+
+
+def main():
+    parser = argparse.ArgumentParser(description='LightGlue PTZ-panorama matching')
+    parser.add_argument('--base', type=str,
+                        default='/Users/wenhongquan/Desktop/阿里云同步/项目/dnn/德胜河 AI/dsh/calibration_scan_180_360_z1',
+                        help='扫描结果目录')
+    parser.add_argument('--pano-max-side', type=int, default=2048,
+                        help='全景图缩放后的长边像素')
+    parser.add_argument('--ptz-max-side', type=int, default=1280,
+                        help='PTZ 图缩放后的长边像素')
+    parser.add_argument('--ptz-crop-ratio', type=float, default=0.6,
+                        help='仅使用 PTZ 中心区域进行匹配')
+    parser.add_argument('--min-matches', type=int, default=20,
+                        help='最少匹配点数才认为有效')
+    parser.add_argument('--device', type=str, default='cpu', choices=['cpu', 'mps', 'cuda'],
+                        help='计算设备')
+    parser.add_argument('--output-suffix', type=str, default='lightglue',
+                        help='输出文件后缀')
+    args = parser.parse_args()
+
+    base = Path(args.base)
+    ptz_dir = base / 'ptz_images'
+    pano_path = base / 'panorama.jpg'
+    raw_path = base / 'mapping_raw.json'
+
+    device = torch.device(args.device)
+    logger.info(f'Using device: {device}')
+
+    logger.info('Loading models...')
+    extractor, matcher = build_models(device)
+
+    logger.info(f'Loading panorama: {pano_path}')
+    panorama_orig = cv2.imread(str(pano_path))
+    if panorama_orig is None:
+        logger.error('Failed to load panorama')
+        return
+    pano_h, pano_w = panorama_orig.shape[:2]
+    panorama_small, pano_scale = resize_max_side(panorama_orig, args.pano_max_side)
+    logger.info(f'Panorama resized: {panorama_small.shape[1]}x{panorama_small.shape[0]} (scale={pano_scale:.3f})')
+
+    logger.info('Extracting panorama features...')
+    pano_tensor = load_tensor(panorama_small, device)[None]
+    feats_pano = extractor({'image': pano_tensor})
+    logger.info(f"Panorama keypoints: {feats_pano['keypoints'].shape[1]}")
+
+    logger.info(f'Loading records from {raw_path}')
+    with open(raw_path, 'r', encoding='utf-8') as f:
+        raw_data = json.load(f)
+    records = raw_data['records']
+
+    results: List[Dict[str, Any]] = []
+    summary_points = []
+
+    for idx, rec in enumerate(records, 1):
+        filename = rec['filename']
+        pan = rec['pan']
+        tilt = rec['tilt']
+        ptz_path = ptz_dir / filename
+
+        ptz_orig = cv2.imread(str(ptz_path))
+        if ptz_orig is None:
+            logger.warning(f'[{idx}/{len(records)}] Cannot read {filename}')
+            rec_copy = dict(rec)
+            rec_copy['matched'] = False
+            results.append(rec_copy)
+            continue
+
+        # 中心裁剪 + 缩放
+        ptz_crop = center_crop(ptz_orig, args.ptz_crop_ratio)
+        ptz_offset = ((ptz_orig.shape[1] - ptz_crop.shape[1]) // 2,
+                      (ptz_orig.shape[0] - ptz_crop.shape[0]) // 2)
+        ptz_small, ptz_scale = resize_max_side(ptz_crop, args.ptz_max_side)
+
+        ptz_tensor = load_tensor(ptz_small, device)[None]
+
+        feats_ptz = extractor({'image': ptz_tensor})
+        matches = matcher({
+            'image0': feats_ptz,
+            'image1': feats_pano,
+        })
+
+        feats_ptz = rbd(feats_ptz)
+        feats_pano_single = rbd(feats_pano)
+        matches = rbd(matches)
+
+        m = matches['matches']
+        num_matches = len(m)
+
+        rec_copy = dict(rec)
+        rec_copy['lg_matches'] = num_matches
+
+        if num_matches >= args.min_matches:
+            kpts_ptz = feats_ptz['keypoints'][m[..., 0]].cpu().numpy()
+            kpts_pano = feats_pano_single['keypoints'][m[..., 1]].cpu().numpy()
+
+            med = median_point_from_matches(
+                kpts_pano, kpts_ptz,
+                pano_scale, ptz_scale, ptz_offset,
+                pano_h, pano_w,
+            )
+            if med:
+                x_ratio, y_ratio, n, conf, spread = med
+                rec_copy['lg_matched'] = True
+                rec_copy['lg_x_ratio'] = round(x_ratio, 4)
+                rec_copy['lg_y_ratio'] = round(y_ratio, 4)
+                rec_copy['lg_panorama_x'] = int(x_ratio * pano_w)
+                rec_copy['lg_panorama_y'] = int(y_ratio * pano_h)
+                rec_copy['lg_spread'] = round(spread, 2)
+                summary_points.append((pan, tilt, int(x_ratio * pano_w), int(y_ratio * pano_h)))
+                logger.info(
+                    f'[{idx}/{len(records)}] {filename} pan={pan} tilt={tilt:+3d} -> '
+                    f'({x_ratio:.3f}, {y_ratio:.3f}) matches={num_matches} spread={spread:.1f}'
+                )
+            else:
+                rec_copy['lg_matched'] = False
+                logger.info(f'[{idx}/{len(records)}] {filename} matches={num_matches} but no median')
+        else:
+            rec_copy['lg_matched'] = False
+            logger.info(f'[{idx}/{len(records)}] {filename} matches={num_matches} < {args.min_matches}')
+
+        results.append(rec_copy)
+
+    # 保存详细结果
+    out_json = base / f'mapping_{args.output_suffix}.json'
+    with open(out_json, 'w', encoding='utf-8') as f:
+        json.dump({
+            'records': results,
+            'panorama_size': {'width': pano_w, 'height': pano_h},
+            'params': vars(args),
+        }, f, indent=2, ensure_ascii=False)
+    logger.info(f'Saved detailed mapping: {out_json}')
+
+    # 生成 lookup table(仅有效点)
+    valid = [r for r in results if r.get('lg_matched')]
+    pan_lookup = sorted([[r['lg_x_ratio'], float(r['pan'])] for r in valid], key=lambda x: x[0])
+    tilt_lookup = sorted([[r['lg_y_ratio'], float(r['tilt'])] for r in valid], key=lambda x: x[0])
+    lookup = {
+        'created_at': raw_data.get('created_at'),
+        'pan_lookup': pan_lookup,
+        'tilt_lookup': tilt_lookup,
+        'valid_count': len(valid),
+    }
+    lookup_path = base / f'lookup_table_{args.output_suffix}.json'
+    with open(lookup_path, 'w', encoding='utf-8') as f:
+        json.dump(lookup, f, indent=2, ensure_ascii=False)
+    logger.info(f'Saved lookup table: {lookup_path} ({len(valid)}/{len(records)} valid)')
+
+    # 可视化概览:在全景图上标记每个有效位置
+    if summary_points:
+        vis = panorama_orig.copy()
+        for pan, tilt, cx, cy in summary_points:
+            cv2.circle(vis, (cx, cy), 12, (0, 0, 255), -1)
+            label = f'{pan},{tilt}'
+            cv2.putText(vis, label, (cx + 10, cy), cv2.FONT_HERSHEY_SIMPLEX,
+                        0.5, (0, 255, 0), 1)
+        vis_path = base / f'panorama_{args.output_suffix}_matches.jpg'
+        cv2.imwrite(str(vis_path), vis, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
+        logger.info(f'Saved overview: {vis_path}')
+
+
+if __name__ == '__main__':
+    main()