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

refactor(coordinator): 重构联动控制器核心逻辑以提升性能与稳定性

- 重新设计跨帧目标跟踪采用位置匹配替代清空更新,提升目标连续性和准确度
- 将检测与PTZ控制拆分为异步线程,避免阻塞,提高系统响应速度
- 增加检测结果去重逻辑,合并重叠人员检测框减少重复追踪
- 优化PTZ命令发送,增加冷却时间与阈值判断避免频繁无效操作
- 引入配对图片保存机制,支持检测结果与快照关联存储
- 新增事件驱动联动控制器,支持基于事件触发的精准跟踪
- 添加详细性能统计与运行状态日志,方便监控与调试系统运行情况
- 改善OCR识别流程及频率控制,提升识别效率并避免API滥用
- 优化校准器集成逻辑,支持基于校准结果进行精准角度转换
- 提供丰富回调接口,支持检测、跟踪及OCR识别结果的自定义处理
wenhongquan 6 дней назад
Родитель
Сommit
a388eccb05

+ 2 - 2
dual_camera_system/config/__init__.py

@@ -19,7 +19,7 @@ from .llm import LLM_CONFIG, LLM_SAFETY_CONFIG
 from .system import SYSTEM_CONFIG
 from .oss import S3_COMPATIBLE_CONFIG
 from .device import (
-    DEVICE_CONFIG, THIRD_PARTY_CONFIG, BATCH_REPORT_CONFIG
+    DEVICE_CONFIG, THIRD_PARTY_CONFIG, BATCH_REPORT_CONFIG, PAIRED_IMAGE_CONFIG
 )
 
 
@@ -48,5 +48,5 @@ __all__ = [
     # OSS
     'S3_COMPATIBLE_CONFIG',
     # 设备与第三方平台
-    'DEVICE_CONFIG', 'THIRD_PARTY_CONFIG', 'BATCH_REPORT_CONFIG',
+    'DEVICE_CONFIG', 'THIRD_PARTY_CONFIG', 'BATCH_REPORT_CONFIG', 'PAIRED_IMAGE_CONFIG',
 ]

+ 16 - 2
dual_camera_system/config/device.py

@@ -75,13 +75,27 @@ BATCH_REPORT_CONFIG = {
     # 上报时机
     'report_on_complete': True,   # 批次完成时上报
     'report_realtime': False,     # 实时上报(每保存一张图就上报)
-    
+
     # 上报内容
     'include_panorama_url': True,   # 包含全景图 OSS URL
     'include_ptz_urls': True,       # 包含球机图 OSS URLs
     'include_raw_images': False,    # 是否包含原始图片数据(Base64)
-    
+
     # 本地保留
     'keep_local_copy': True,        # 上报后是否保留本地副本
     'local_retention_days': 7,      # 本地保留天数
 }
+
+# 配对图片保存配置
+PAIRED_IMAGE_CONFIG = {
+    # 本地存储目录
+    'base_dir': '/home/admin/dsh/paired_images',
+
+    # 清理策略
+    'cleanup_enabled': True,           # 是否启用自动清理
+    'max_batches': 100,                 # 最大保留批次数量
+    'retention_days': 7,                # 保留天数(与 max_batches 互斥,优先按数量清理)
+
+    # 时间窗口(秒):同一窗口内的检测归为一批
+    'time_window': 5.0,
+}

+ 18 - 10
dual_camera_system/config/ptz.py

@@ -4,37 +4,45 @@ PTZ控制配置
 
 # PTZ控制配置
 PTZ_CONFIG = {
-    'default_zoom': 4,               # 默认变焦倍数(降低以避免人体超出画面
+    'default_zoom': 8,               # 默认变焦倍数(提高以获得更清晰的人脸/身体图像
     'max_zoom': 20,                  # 最大变焦倍数
     'move_speed': 4,                 # 移动速度 (1-8)
     'coordinate_offset': (0, 0),     # 坐标偏移校准
-    
+
     # 视野角度配置 (根据实际摄像头参数设置)
     'pan_range': (0, 180),           # 水平视野范围 (度) - 全景相机通常覆盖180度
     'tilt_range': (-45, 45),         # 垂直视野范围 (度) - 垂直方向覆盖角度
     'pan_center': 90,                # 水平中心角度 (画面中心对应的PTZ角度)
     'tilt_center': 0,                # 垂直中心角度
-    
+
     # 球机安装方向配置
     # mount_type: 'ceiling' - 吸顶/吊装(镜头朝上), 'wall' - 壁装/立杆(镜头朝下)
     # 吸顶安装时,俯仰角(tilt)方向与壁装相反,需要反转tilt计算
     'mount_type': 'ceiling',            # 'ceiling' 或 'wall'
-    
+
     # 方向修正(根据mount_type自动设置,也可手动覆盖)
     # pan_flip: 如果球机与全景朝向相反(球机看后面),设为True
     # tilt_flip: 如果俯仰方向相反,设为True(吸顶安装通常需要True)
     'pan_flip': True,
     'tilt_flip': False,              # 由 mount_type='ceiling' 时自动生效
-    
+
     # 球机端人体检测与自动对焦配置
     'enable_ptz_detection': True,    # 是否启用球机端人体检测
     'auto_zoom': {
         'enabled': True,             # 是否启用自动变焦
-        'target_size_ratio': 0.3,    # 目标人体占画面比例(降低以适应更大视野
-        'min_zoom': 2,               # 最小变倍(降低以获得更大视野
-        'max_zoom': 15,              # 最大变倍
+        'target_size_ratio': 0.2,    # 目标人体占画面比例(提高以获得更近的图像
+        'min_zoom': 6,               # 最小变倍(提高以获得更清晰的图像
+        'max_zoom': 20,              # 最大变倍
         'zoom_step': 2,              # 变焦调整步长
-        'center_threshold': 0.1,     # 居中阈值 (人体中心偏离画面中心的比例)
-        'max_adjust_attempts': 5,    # 最大调整次数(增加以提高精度)
+        'center_threshold': 0.15,    # 居中阈值 (人体中心偏离画面中心的比例)
+        'max_adjust_attempts': 3,    # 最大调整次数
+    },
+
+    # 抓拍优化配置
+    'capture': {
+        'stabilize_time': 3.0,       # PTZ到位后稳定等待时间(秒),增加以确保球机完全停止
+        'frame_wait_interval': 0.2, # 获取帧的等待间隔(秒)
+        'frame_max_attempts': 8,    # 获取帧的最大尝试次数
+        'min_clarity': 200,          # 最小清晰度阈值(拉普拉斯方差),提高以确保清晰
     },
 }

+ 42 - 31
dual_camera_system/coordinator.py

@@ -1506,59 +1506,62 @@ class AsyncCoordinator(Coordinator):
             logger.error(f"[AutoZoom] 自动对焦异常: {e}")
             return initial_zoom
     
-    def _get_clear_ptz_frame(self, max_attempts: int = 8, wait_interval: float = 0.15) -> Optional[np.ndarray]:
+    def _get_clear_ptz_frame(self, max_attempts: int = None, wait_interval: float = None) -> Optional[np.ndarray]:
         """
         获取清晰的球机画面
         尝试多次获取,丢弃模糊/过渡帧
-        
+
         Args:
-            max_attempts: 最大尝试次数(默认8次
-            wait_interval: 每次等待间隔(默认0.15秒
-            
+            max_attempts: 最大尝试次数(默认从配置读取
+            wait_interval: 每次等待间隔(默认从配置读取
+
         Returns:
             清晰的球机帧或 None
         """
+        # 使用配置值或默认值
+        cfg = self._frame_config
+        max_attempts = max_attempts or cfg.get('max_attempts', 8)
+        wait_interval = wait_interval or cfg.get('wait_interval', 0.2)
+        min_clarity = cfg.get('min_clarity', 200)
+
         best_frame = None
         best_score = -1
-        
-        # 【改进】先刷新缓冲区,丢弃旧帧
+
+        # 先刷新缓冲区,丢弃旧帧
         logger.debug("[帧获取] 刷新RTSP缓冲区...")
-        for _ in range(3):
+        for _ in range(5):
             self.ptz.get_frame()
             time.sleep(0.05)
-        
-        # 【关键修复】只尝试少量次数,尽快拿到可用帧
-        # 避免在获取帧期间球机被移动到其他位置
-        effective_attempts = min(max_attempts, 5)
-        
-        for i in range(effective_attempts):
+
+        # 尝试获取清晰帧
+        for i in range(max_attempts):
             frame = self.ptz.get_frame()
             if frame is not None:
                 # 立即复制帧,防止 RTSP 流更新导致帧被覆盖
                 frame_copy = frame.copy()
-                
+
                 # 使用拉普拉斯算子评估图像清晰度
                 gray = cv2.cvtColor(frame_copy, cv2.COLOR_BGR2GRAY)
                 laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
-                
-                logger.debug(f"[帧获取] 尝试 {i+1}/{effective_attempts}: 清晰度={laplacian_var:.1f}")
-                
+
+                logger.debug(f"[帧获取] 尝试 {i+1}/{max_attempts}: 清晰度={laplacian_var:.1f}")
+
                 if laplacian_var > best_score:
                     best_score = laplacian_var
                     best_frame = frame_copy
-                
+
                 # 如果清晰度足够高,直接返回
-                if laplacian_var > 150:
+                if laplacian_var > min_clarity:
                     logger.info(f"[帧获取] 获取清晰帧: 尝试 {i+1} 次, 清晰度={laplacian_var:.1f}")
                     return frame_copy
-            
+
             time.sleep(wait_interval)
-        
+
         if best_frame is not None:
             logger.info(f"[帧获取] 返回最佳帧: 清晰度={best_score:.1f}")
         else:
             logger.warning("[帧获取] 未能获取有效帧")
-        
+
         return best_frame
     
     def _mark_ptz_frame_with_detection(self, frame: np.ndarray, person_index: int) -> np.ndarray:
@@ -1681,16 +1684,24 @@ class SequentialCoordinator(AsyncCoordinator):
         # 抓拍完成事件(用于同步)
         self._capture_done_event = threading.Event()
         
-        # 配置参数
+        # 配置参数 - 从 PTZ_CONFIG 读取
+        ptz_capture_config = PTZ_CONFIG.get('capture', {})
         self._capture_config = {
-            'ptz_stabilize_time': 2.5,      # PTZ到位后稳定等待时间(秒)
+            'ptz_stabilize_time': ptz_capture_config.get('stabilize_time', 3.0),  # PTZ到位后稳定等待时间(秒)
             'capture_wait_time': 0.5,       # 抓拍等待时间
-            'auto_zoom_wait_time': 1.0,     # AutoZoom变焦后额外等待时间(秒)
+            'auto_zoom_wait_time': 1.5,     # AutoZoom变焦后额外等待时间(秒),增加以确保对焦完成
             'return_to_panorama': True,     # 完成后是否回到全景默认位置
             'default_pan': 0.0,             # 默认pan角度
             'default_tilt': 0.0,            # 默认tilt角度
             'default_zoom': 1,              # 默认zoom(广角)
         }
+
+        # 帧获取配置
+        self._frame_config = {
+            'wait_interval': ptz_capture_config.get('frame_wait_interval', 0.2),
+            'max_attempts': ptz_capture_config.get('frame_max_attempts', 8),
+            'min_clarity': ptz_capture_config.get('min_clarity', 200),
+        }
         
         # 覆盖父类的PTZ冷却时间(顺序模式下可以更短)
         self.PTZ_COMMAND_COOLDOWN = 0.1
@@ -2072,13 +2083,13 @@ class SequentialCoordinator(AsyncCoordinator):
             save_dir = '/home/admin/dsh/captures'
             os.makedirs(save_dir, exist_ok=True)
             
-            # 生成文件名
+            # 生成文件名 - 使用 PNG 无损格式
             timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
-            filename = f"capture_{timestamp}_person{index}_p{int(pan)}_t{int(tilt)}_z{zoom}.jpg"
+            filename = f"capture_{timestamp}_person{index}_p{int(pan)}_t{int(tilt)}_z{zoom}.png"
             filepath = os.path.join(save_dir, filename)
-            
-            # 保存图片
-            cv2.imwrite(filepath, frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
+
+            # 保存图片 - 使用 PNG 无损格式
+            cv2.imwrite(filepath, frame)
             logger.info(f"[顺序模式] 快照已保存: {filepath}")
             
         except Exception as e:

+ 278 - 187
dual_camera_system/paired_image_saver.py

@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
 
 @dataclass
 class PersonInfo:
-    """人员信息(轨迹追踪已禁用)"""
+    """人员信息"""
     person_index: int  # 人员序号(0-based)
     position: Tuple[float, float]  # (x_ratio, y_ratio)
     bbox: Tuple[int, int, int, int]  # (x1, y1, x2, y2)
@@ -28,8 +28,10 @@ class PersonInfo:
     ptz_position: Optional[Tuple[float, float, int]] = None  # (pan, tilt, zoom)
     ptz_bbox: Optional[Tuple[int, int, int, int]] = None  # 球机图中检测到的bbox (x1, y1, x2, y2)
     ptz_image_saved: bool = False
-    ptz_image_path: Optional[str] = None
-    ptz_oss_url: Optional[str] = None  # 球机图 OSS URL
+    ptz_image_path: Optional[str] = None  # 标记后的球机图路径
+    ptz_image_original_path: Optional[str] = None  # 未标记的球机图路径
+    ptz_oss_url: Optional[str] = None  # 标记后球机图 OSS URL
+    ptz_oss_url_original: Optional[str] = None  # 未标记球机图 OSS URL
 
 
 @dataclass
@@ -38,8 +40,10 @@ class DetectionBatch:
     batch_id: str
     timestamp: float
     panorama_image: Optional[object] = None  # numpy array
-    panorama_path: Optional[str] = None
-    panorama_oss_url: Optional[str] = None  # 全景图 OSS URL
+    panorama_path: Optional[str] = None  # 标记后的全景图路径
+    panorama_original_path: Optional[str] = None  # 未标记的全景图路径
+    panorama_oss_url: Optional[str] = None  # 标记后全景图 OSS URL
+    panorama_oss_url_original: Optional[str] = None  # 未标记全景图 OSS URL
     persons: List[PersonInfo] = field(default_factory=list)
     total_persons: int = 0
     ptz_images_count: int = 0
@@ -61,26 +65,40 @@ class PairedImageSaver:
     6. 生成 batch_info.json
     """
     
-    def __init__(self, base_dir: str = '/home/admin/dsh/paired_images',
-                 time_window: float = 5.0,  # 时间窗口(秒)
-                 max_batches: int = 100,
+    def __init__(self, base_dir: str = None,
+                 time_window: float = None,
+                 max_batches: int = None,
+                 cleanup_enabled: bool = None,
+                 retention_days: int = None,
                  enable_oss: bool = False,
                  oss_uploader = None,
                  device_config: Dict = None):
         """
         初始化
-        
+
         Args:
-            base_dir: 基础保存目录
+            base_dir: 基础保存目录(默认从配置读取)
             time_window: 批次时间窗口(秒),同一窗口内的检测归为一批
             max_batches: 最大保留批次数量
+            cleanup_enabled: 是否启用自动清理
+            retention_days: 保留天数
             enable_oss: 是否启用 OSS 上传
             oss_uploader: OSS 上传器实例
             device_config: 设备配置字典
         """
-        self.base_dir = Path(base_dir)
-        self.time_window = time_window
-        self.max_batches = max_batches
+        # 从配置模块读取配对图片保存配置
+        try:
+            from config import PAIRED_IMAGE_CONFIG
+            config = PAIRED_IMAGE_CONFIG
+        except ImportError:
+            config = {}
+
+        # 使用传入参数或配置默认值
+        self.base_dir = Path(base_dir or config.get('base_dir', '/home/admin/dsh/paired_images'))
+        self.time_window = time_window or config.get('time_window', 5.0)
+        self.max_batches = max_batches if max_batches is not None else config.get('max_batches', 100)
+        self.cleanup_enabled = cleanup_enabled if cleanup_enabled is not None else config.get('cleanup_enabled', True)
+        self.retention_days = retention_days if retention_days is not None else config.get('retention_days', 7)
         
         # 从配置模块读取 OSS 和设备配置(确保即使外部不传也能正确配置)
         try:
@@ -147,7 +165,8 @@ class PairedImageSaver:
         # 确保目录存在
         self._ensure_base_dir()
         
-        logger.info(f"[配对保存] 初始化完成: 目录={base_dir}, 时间窗口={time_window}s, OSS={enable_oss}")
+        logger.info(f"[配对保存] 初始化完成: 目录={self.base_dir}, 时间窗口={self.time_window}s, "
+                   f"最大批次={self.max_batches}, 清理={self.cleanup_enabled}, 保留天数={self.retention_days}, OSS={enable_oss}")
     
     def _ensure_base_dir(self):
         """确保基础目录存在"""
@@ -192,15 +211,16 @@ class PairedImageSaver:
             # 创建新批次
             batch_id = self._generate_batch_id()
             batch_dir = self._create_batch_dir(batch_id)
-            
-            # 保存全景图片
-            panorama_path = None
+
+            # 保存全景图片(原图和标记图)
+            panorama_original_path = None
+            panorama_marked_path = None
             if panorama_frame is not None:
-                panorama_path = self._save_panorama_image(
+                panorama_original_path, panorama_marked_path = self._save_panorama_image(
                     batch_dir, batch_id, panorama_frame, persons
                 )
-            
-            # 创建人员信息(轨迹追踪已禁用,使用序号代替track_id)
+
+            # 创建人员信息
             person_infos = []
             for i, p in enumerate(persons):
                 info = PersonInfo(
@@ -210,17 +230,18 @@ class PairedImageSaver:
                     confidence=p.get('confidence', 0.0)
                 )
                 person_infos.append(info)
-            
+
             # 获取设备信息
             device_id = self.device_config.get('device_id', 'UNKNOWN')
             project_id = self.device_config.get('project_id', 'UNKNOWN')
-            
+
             # 创建批次记录
             self._current_batch = DetectionBatch(
                 batch_id=batch_id,
                 timestamp=current_time,
                 panorama_image=panorama_frame,
-                panorama_path=panorama_path,
+                panorama_path=panorama_marked_path,  # 标记图作为主路径
+                panorama_original_path=panorama_original_path,  # 原图路径
                 persons=person_infos,
                 total_persons=len(persons),
                 device_id=device_id,
@@ -245,51 +266,61 @@ class PairedImageSaver:
                 f"[配对保存] 新批次创建: {batch_id}, "
                 f"人员={len(persons)}, 目录={batch_dir}"
             )
-            
-            # 上传全景图到 OSS
-            logger.info(f"[配对保存] 开始新批次: enable_oss={self.enable_oss}, uploader={self.oss_uploader}, path={panorama_path}")
-            if self.enable_oss and panorama_path and self.oss_uploader:
-                logger.info(f"[配对保存] 准备上传全景图到 OSS: {panorama_path}")
-                self._upload_panorama_to_oss(batch_id, panorama_path)
+
+            # 上传全景图到 OSS(原图和标记图)
+            logger.info(f"[配对保存] 开始新批次: enable_oss={self.enable_oss}, uploader={self.oss_uploader}")
+            if self.enable_oss and self.oss_uploader:
+                # 上传原图
+                if panorama_original_path:
+                    logger.info(f"[配对保存] 准备上传全景原图到 OSS: {panorama_original_path}")
+                    self._upload_panorama_to_oss(batch_id, panorama_original_path, image_type='panorama_original')
+                # 上传标记图
+                if panorama_marked_path:
+                    logger.info(f"[配对保存] 准备上传全景标记图到 OSS: {panorama_marked_path}")
+                    self._upload_panorama_to_oss(batch_id, panorama_marked_path, image_type='panorama')
             else:
                 logger.warning(f"[配对保存] OSS未启用或上传器不可用: enable_oss={self.enable_oss}, uploader={self.oss_uploader}")
             
             return batch_id
     
     def _save_panorama_image(self, batch_dir: Path, batch_id: str,
-                              frame, persons: List[Dict]) -> Optional[str]:
+                              frame, persons: List[Dict]) -> Tuple[Optional[str], Optional[str]]:
         """
-        保存全景标记图片
-        
+        保存全景原图和标记图片
+
         Args:
             batch_dir: 批次目录
             batch_id: 批次ID
             frame: 全景帧
             persons: 人员列表(已由调用方过滤,此处不再过滤)
-            
+
         Returns:
-            保存路径或 None
+            (原图路径, 标记图路径) 或 (None, None)
         """
         try:
+            # 保存原图(未标记)
+            original_filename = f"00_panorama_original_n{len(persons)}.png"
+            original_filepath = batch_dir / original_filename
+            cv2.imwrite(str(original_filepath), frame)
+
             # 复制图像避免修改原图
             marked_frame = frame.copy()
-            
+
             # 绘制每个人员的标记(使用连续的序号)
-            # 注意:persons 已由调用方(coordinator)过滤,置信度均 >= 阈值
             for i, person in enumerate(persons):
                 bbox = person.get('bbox', (0, 0, 0, 0))
                 x1, y1, x2, y2 = bbox
                 conf = person.get('confidence', 0.0)
-                
+
                 # 绘制边界框(绿色)
                 cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
-                
+
                 # 绘制序号标签(带置信度)
                 label = f"person_{i}({conf:.2f})"
                 (label_w, label_h), baseline = cv2.getTextSize(
                     label, cv2.FONT_HERSHEY_SIMPLEX, 0.8, 2
                 )
-                
+
                 # 标签背景
                 cv2.rectangle(
                     marked_frame,
@@ -298,7 +329,7 @@ class PairedImageSaver:
                     (0, 255, 0),
                     -1
                 )
-                
+
                 # 标签文字(黑色)
                 cv2.putText(
                     marked_frame, label,
@@ -306,26 +337,25 @@ class PairedImageSaver:
                     cv2.FONT_HERSHEY_SIMPLEX, 0.8,
                     (0, 0, 0), 2
                 )
-            
-            # 保存图片(使用人员数量)
-            filename = f"00_panorama_n{len(persons)}.png"
-            filepath = batch_dir / filename
-            # 保存全景图(PNG无损格式,不压缩)
-            cv2.imwrite(str(filepath), marked_frame)
-            
-            logger.info(f"[配对保存] 全景图已保存: {filepath},人员数量 {len(persons)}")
-            return str(filepath)
-            
+
+            # 保存标记图
+            marked_filename = f"00_panorama_marked_n{len(persons)}.png"
+            marked_filepath = batch_dir / marked_filename
+            cv2.imwrite(str(marked_filepath), marked_frame)
+
+            logger.info(f"[配对保存] 全景图已保存: 原图={original_filepath}, 标记图={marked_filepath}, 人员数量 {len(persons)}")
+            return str(original_filepath), str(marked_filepath)
+
         except Exception as e:
             logger.error(f"[配对保存] 保存全景图失败: {e}")
-            return None
+            return None, None
     
     def save_ptz_image(self, batch_id: str, person_index: int,
                        ptz_frame, ptz_position: Tuple[float, float, int],
                        ptz_bbox: Tuple[int, int, int, int] = None,
-                       person_info: Dict = None) -> Optional[str]:
+                       person_info: Dict = None) -> Tuple[Optional[str], Optional[str]]:
         """
-        保存球机聚焦图片
+        保存球机聚焦图片(原图和标记图)
 
         Args:
             batch_id: 批次ID
@@ -336,120 +366,112 @@ class PairedImageSaver:
             person_info: 额外人员信息
 
         Returns:
-            保存路径或 None
+            (原图路径, 标记图路径) 或 (None, None)
         """
-        # 【调试】记录传入的参数
         logger.info(f"[配对保存] save_ptz_image: batch={batch_id}, person={person_index}, "
                    f"PTZ=({ptz_position[0]:.1f}°, {ptz_position[1]:.1f}°, zoom={ptz_position[2]})")
 
         with self._batch_lock:
             if self._current_batch is None or self._current_batch.batch_id != batch_id:
-                logger.warning(f"[配对保存] 批次不存在或已过期: {batch_id}, current={self._current_batch.batch_id if self._current_batch else None}")
-                return None
+                logger.warning(f"[配对保存] 批次不存在或已过期: {batch_id}")
+                return None, None
 
             batch_dir = self.base_dir / f"batch_{batch_id}"
-            
+
             try:
-                # 复制图像
+                # 保存原图(未标记)
+                original_filename = f"01_ptz_person{person_index}_p{int(ptz_position[0])}_t{int(ptz_position[1])}_z{int(ptz_position[2])}_original.png"
+                original_filepath = batch_dir / original_filename
+
+                if ptz_frame is not None:
+                    cv2.imwrite(str(original_filepath), ptz_frame)
+
+                # 复制图像用于标记
                 marked_frame = ptz_frame.copy() if ptz_frame is not None else None
-                
+
                 if marked_frame is not None:
-                    # 在球机图上添加标记(如果有检测框)
+                    # 在球机图上添加标记
                     h, w = marked_frame.shape[:2]
-                    
-                    # 添加PTZ位置信息到图片
+
+                    # 添加PTZ位置信息
                     pan, tilt, zoom = ptz_position
                     info_text = f"PTZ: P={pan:.1f} T={tilt:.1f} Z={zoom}"
-                    cv2.putText(
-                        marked_frame, info_text,
-                        (10, 30),
-                        cv2.FONT_HERSHEY_SIMPLEX, 0.7,
-                        (0, 255, 0), 2
-                    )
-                    
+                    cv2.putText(marked_frame, info_text, (10, 30),
+                                cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
+
                     # 添加人员序号
                     person_text = f"person_{person_index}"
-                    cv2.putText(
-                        marked_frame, person_text,
-                        (10, 60),
-                        cv2.FONT_HERSHEY_SIMPLEX, 0.7,
-                        (0, 255, 0), 2
-                    )
-                    
-                    # 绘制PTZ检测到的bbox(红色)
+                    cv2.putText(marked_frame, person_text, (10, 60),
+                                cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
+
+                    # 绘制PTZ检测到的bbox
                     if ptz_bbox is not None:
                         x1, y1, x2, y2 = ptz_bbox
                         cv2.rectangle(marked_frame, (x1, y1), (x2, y2), (0, 0, 255), 2)
                         bbox_text = f"PTZ_BBox: ({x1},{y1},{x2},{y2})"
-                        cv2.putText(
-                            marked_frame, bbox_text,
-                            (10, 90),
-                            cv2.FONT_HERSHEY_SIMPLEX, 0.6,
-                            (0, 0, 255), 2
-                        )
-                
-                # 保存图片
-                filename = f"01_ptz_person{person_index}_p{int(ptz_position[0])}_t{int(ptz_position[1])}_z{int(ptz_position[2])}.jpg"
-                filepath = batch_dir / filename
-                
+                        cv2.putText(marked_frame, bbox_text, (10, 90),
+                                    cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
+
+                # 保存标记图
+                marked_filename = f"01_ptz_person{person_index}_p{int(ptz_position[0])}_t{int(ptz_position[1])}_z{int(ptz_position[2])}_marked.png"
+                marked_filepath = batch_dir / marked_filename
+
                 if marked_frame is not None:
-                    cv2.imwrite(str(filepath), marked_frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
-                
+                    cv2.imwrite(str(marked_filepath), marked_frame)
+
+                logger.info(f"[配对保存] 球机图已保存: 原图={original_filepath}, 标记图={marked_filepath}")
+
                 # 更新批次信息
-                # 【关键修复】只有当人员索引有效时才更新和计数
                 if person_index < len(self._current_batch.persons):
                     self._current_batch.persons[person_index].ptz_position = ptz_position
                     self._current_batch.persons[person_index].ptz_bbox = ptz_bbox
                     self._current_batch.persons[person_index].ptz_image_saved = True
-                    self._current_batch.persons[person_index].ptz_image_path = str(filepath)
-                    
+                    self._current_batch.persons[person_index].ptz_image_path = str(marked_filepath)
+                    self._current_batch.persons[person_index].ptz_image_original_path = str(original_filepath)
+
                     self._current_batch.ptz_images_count += 1
-                    
+
                     with self._stats_lock:
                         self._stats['total_ptz_images'] += 1
-                    
-                    logger.info(f"[配对保存] 球机图已保存: {filepath}, BBox={ptz_bbox}")
 
-                    # 上传球机图到 OSS
-                    logger.info(f"[配对保存] 准备上传球机图到 OSS: enable_oss={self.enable_oss}, uploader={self.oss_uploader}")
+                    # 上传原图和标记图到 OSS
                     if self.enable_oss and self.oss_uploader:
-                        self._upload_ptz_to_oss(batch_id, person_index, str(filepath))
-                else:
-                    # 人员索引超出范围,说明批次信息不一致,跳过保存
-                    logger.warning(f"[配对保存] 人员索引 {person_index} 超出批次范围 {len(self._current_batch.persons)},跳过计数")
-                    
-                return str(filepath)
-                
+                        self._upload_ptz_to_oss(batch_id, person_index, str(original_filepath), str(marked_filepath))
+
+                return str(original_filepath), str(marked_filepath)
+
             except Exception as e:
                 logger.error(f"[配对保存] 保存球机图失败: {e}")
-                return None
+                return None, None
     
-    def _upload_panorama_to_oss(self, batch_id: str, panorama_path: str):
+    def _upload_panorama_to_oss(self, batch_id: str, panorama_path: str, image_type: str = 'panorama'):
         """上传全景图到 OSS"""
-        logger.info(f"[OSS] _upload_panorama_to_oss 被调用: batch_id={batch_id}, path={panorama_path}")
+        logger.info(f"[OSS] _upload_panorama_to_oss: batch_id={batch_id}, path={panorama_path}, type={image_type}")
+
+        # 确定是原图还是标记图
+        is_original = image_type == 'panorama_original'
 
         def on_upload_complete(result):
-            logger.info(f"[OSS] 全景图上传完成回调: success={result.success}, url={result.oss_url}")
-            # 更新上传状态(即使 _current_batch 已切换也能正确记录)
-            self._upload_status[batch_id]['panorama'] = True
-            self._upload_status[batch_id]['panorama_url'] = result.oss_url
-            if self._current_batch and self._current_batch.batch_id == batch_id:
-                self._current_batch.panorama_oss_url = result.oss_url
+            logger.info(f"[OSS] 全景图上传完成回调: type={image_type}, url={result.oss_url}")
+            if is_original:
+                self._upload_status[batch_id]['panorama_original'] = True
+                self._upload_status[batch_id]['panorama_original_url'] = result.oss_url
+                if self._current_batch and self._current_batch.batch_id == batch_id:
+                    self._current_batch.panorama_oss_url_original = result.oss_url
+            else:
+                self._upload_status[batch_id]['panorama'] = True
+                self._upload_status[batch_id]['panorama_url'] = result.oss_url
+                if self._current_batch and self._current_batch.batch_id == batch_id:
+                    self._current_batch.panorama_oss_url = result.oss_url
             with self._stats_lock:
                 self._stats['oss_upload_success'] += 1
-            logger.info(f"[OSS] 全景图上传成功: {result.oss_url}")
 
         def on_upload_error(result):
-            logger.error(f"[OSS] 全景图上传失败回调: {result.error}")
-            self._upload_status[batch_id]['panorama'] = False
-            self._upload_status[batch_id]['panorama_url'] = None
+            logger.error(f"[OSS] 全景图上传失败回调: type={image_type}, error={result.error}")
             with self._stats_lock:
                 self._stats['oss_upload_failed'] += 1
-            logger.error(f"[OSS] 全景图上传失败: {result.error}")
 
-        # 包装回调,同时处理成功和失败
         def on_upload_done(result):
-            logger.info(f"[OSS] 全景图上传结果: success={result.success}")
             if result.success:
                 on_upload_complete(result)
             else:
@@ -459,54 +481,80 @@ class PairedImageSaver:
             oss_key = self.oss_uploader.upload_image(
                 local_path=panorama_path,
                 batch_id=batch_id,
-                image_type='panorama',
+                image_type=image_type,
                 callback=on_upload_done
             )
-            logger.info(f"[OSS] 全景图已加入上传队列: oss_key={oss_key}")
+            logger.info(f"[OSS] 全景图已加入上传队列: type={image_type}, oss_key={oss_key}")
         except Exception as e:
             logger.error(f"[OSS] 全景图上传异常: {e}")
     
-    def _upload_ptz_to_oss(self, batch_id: str, person_index: int, ptz_path: str):
-        """上传球机图到 OSS"""
-        logger.info(f"[OSS] _upload_ptz_to_oss 被调用: batch_id={batch_id}, person={person_index}, path={ptz_path}")
-
-        def on_upload_complete(result):
-            logger.info(f"[OSS] 球机图上传完成回调: person={person_index}, success={result.success}, url={result.oss_url}")
-            # 更新上传状态(即使 _current_batch 已切换也能正确记录)
-            self._upload_status[batch_id]['ptz'][person_index] = result.oss_url
-            if self._current_batch and self._current_batch.batch_id == batch_id:
-                if person_index < len(self._current_batch.persons):
-                    self._current_batch.persons[person_index].ptz_oss_url = result.oss_url
-            with self._stats_lock:
-                self._stats['oss_upload_success'] += 1
-            logger.info(f"[OSS] 球机图上传成功 (person_{person_index}): {result.oss_url}")
-
-        def on_upload_error(result):
-            logger.error(f"[OSS] 球机图上传失败回调: person={person_index}, error={result.error}")
-            self._upload_status[batch_id]['ptz'][person_index] = None
-            with self._stats_lock:
-                self._stats['oss_upload_failed'] += 1
-            logger.error(f"[OSS] 球机图上传失败 (person_{person_index}): {result.error}")
+    def _upload_ptz_to_oss(self, batch_id: str, person_index: int, original_path: str = None, marked_path: str = None):
+        """上传球机图到 OSS(原图和标记图)"""
+        logger.info(f"[OSS] _upload_ptz_to_oss: batch={batch_id}, person={person_index}, original={original_path}, marked={marked_path}")
+
+        # 初始化上传状态
+        if batch_id not in self._upload_status:
+            self._upload_status[batch_id] = {'ptz': {}}
+        if 'ptz_original' not in self._upload_status[batch_id]:
+            self._upload_status[batch_id]['ptz_original'] = {}
+        if 'ptz_marked' not in self._upload_status[batch_id]:
+            self._upload_status[batch_id]['ptz_marked'] = {}
+
+        # 上传原图
+        if original_path:
+            def on_original_complete(result):
+                self._upload_status[batch_id]['ptz_original'][person_index] = result.oss_url
+                if self._current_batch and self._current_batch.batch_id == batch_id:
+                    if person_index < len(self._current_batch.persons):
+                        self._current_batch.persons[person_index].ptz_oss_url_original = result.oss_url
+                with self._stats_lock:
+                    self._stats['oss_upload_success'] += 1
+                logger.info(f"[OSS] 球机原图上传成功: {result.oss_url}")
+
+            def on_original_done(result):
+                if result.success:
+                    on_original_complete(result)
+                else:
+                    logger.error(f"[OSS] 球机原图上传失败: {result.error}")
 
-        # 包装回调,同时处理成功和失败
-        def on_upload_done(result):
-            logger.info(f"[OSS] 球机图上传结果: person={person_index}, success={result.success}")
-            if result.success:
-                on_upload_complete(result)
-            else:
-                on_upload_error(result)
+            try:
+                self.oss_uploader.upload_image(
+                    local_path=original_path,
+                    batch_id=batch_id,
+                    image_type='ptz_original',
+                    person_index=person_index,
+                    callback=on_original_done
+                )
+            except Exception as e:
+                logger.error(f"[OSS] 球机原图上传异常: {e}")
+
+        # 上传标记图
+        if marked_path:
+            def on_marked_complete(result):
+                self._upload_status[batch_id]['ptz_marked'][person_index] = result.oss_url
+                if self._current_batch and self._current_batch.batch_id == batch_id:
+                    if person_index < len(self._current_batch.persons):
+                        self._current_batch.persons[person_index].ptz_oss_url = result.oss_url
+                with self._stats_lock:
+                    self._stats['oss_upload_success'] += 1
+                logger.info(f"[OSS] 球机标记图上传成功: {result.oss_url}")
+
+            def on_marked_done(result):
+                if result.success:
+                    on_marked_complete(result)
+                else:
+                    logger.error(f"[OSS] 球机标记图上传失败: {result.error}")
 
-        try:
-            oss_key = self.oss_uploader.upload_image(
-                local_path=ptz_path,
-                batch_id=batch_id,
-                image_type='ptz',
-                person_index=person_index,
-                callback=on_upload_done
-            )
-            logger.info(f"[OSS] 球机图已加入上传队列: person={person_index}, oss_key={oss_key}")
-        except Exception as e:
-            logger.error(f"[OSS] 球机图上传异常: {e}")
+            try:
+                self.oss_uploader.upload_image(
+                    local_path=marked_path,
+                    batch_id=batch_id,
+                    image_type='ptz',
+                    person_index=person_index,
+                    callback=on_marked_done
+                )
+            except Exception as e:
+                logger.error(f"[OSS] 球机标记图上传异常: {e}")
     
     def _finalize_batch(self, batch: DetectionBatch):
         """完成批次处理"""
@@ -577,17 +625,19 @@ class PairedImageSaver:
         Returns:
             Dict: 批次信息字典
         """
-        # 从上传状态获取最新的 OSS URL(异步回调可能已更新)
+        # 从上传状态获取最新的 OSS URL
         upload_status = self._upload_status.get(batch.batch_id, {})
 
-        # 获取全景图 OSS URL(优先使用回调更新的状态)
+        # 获取全景图 OSS URL
         panorama_oss_url = upload_status.get('panorama_url', batch.panorama_oss_url)
+        panorama_oss_url_original = upload_status.get('panorama_original_url', batch.panorama_oss_url_original)
 
         # 人员信息列表
         persons_list = []
         for person in batch.persons:
-            # 获取球机图 OSS URL(优先使用回调更新的状态)
+            # 获取球机图 OSS URL
             ptz_oss_url = upload_status.get('ptz', {}).get(person.person_index, person.ptz_oss_url)
+            ptz_oss_url_original = upload_status.get('ptz_original', {}).get(person.person_index, person.ptz_oss_url_original)
 
             person_data = {
                 'person_index': person.person_index,
@@ -614,8 +664,10 @@ class PairedImageSaver:
                     'y2': person.ptz_bbox[3]
                 } if person.ptz_bbox else None,
                 'ptz_image_saved': person.ptz_image_saved,
-                'ptz_image_path': person.ptz_image_path,
-                'ptz_oss_url': ptz_oss_url
+                'ptz_image_path': person.ptz_image_path,  # 标记图
+                'ptz_image_original_path': person.ptz_image_original_path,  # 原图
+                'ptz_oss_url': ptz_oss_url,  # 标记图 OSS URL
+                'ptz_oss_url_original': ptz_oss_url_original  # 原图 OSS URL
             }
             persons_list.append(person_data)
 
@@ -629,12 +681,15 @@ class PairedImageSaver:
             'total_persons': batch.total_persons,
             'ptz_images_count': batch.ptz_images_count,
             'panorama': {
-                'local_path': batch.panorama_path,
-                'oss_url': panorama_oss_url
+                'local_path': batch.panorama_path,  # 标记图
+                'local_path_original': batch.panorama_original_path,  # 原图
+                'oss_url': panorama_oss_url,  # 标记图 OSS URL
+                'oss_url_original': panorama_oss_url_original  # 原图 OSS URL
             },
             'persons': persons_list,
             'upload_status': {
                 'panorama_uploaded': panorama_oss_url is not None,
+                'panorama_original_uploaded': panorama_oss_url_original is not None,
                 'all_ptz_uploaded': all(
                     upload_status.get('ptz', {}).get(p.person_index) is not None
                     for p in batch.persons if p.ptz_image_saved
@@ -676,19 +731,47 @@ class PairedImageSaver:
     
     def _cleanup_old_batches(self):
         """清理旧批次目录"""
+        if not self.cleanup_enabled:
+            logger.debug("[配对保存] 自动清理已禁用")
+            return
+
         try:
+            from datetime import timedelta
+
             batch_dirs = sorted(
                 [d for d in self.base_dir.iterdir() if d.is_dir() and d.name.startswith('batch_')],
                 key=lambda x: x.stat().st_mtime
             )
-            
-            if len(batch_dirs) > self.max_batches:
-                to_delete = batch_dirs[:len(batch_dirs) - self.max_batches]
-                for d in to_delete:
-                    import shutil
-                    shutil.rmtree(d)
-                    logger.info(f"[配对保存] 清理旧批次: {d.name}")
-                    
+
+            if not batch_dirs:
+                return
+
+            now = time.time()
+            to_delete = []
+            deleted_count = 0
+
+            for d in batch_dirs:
+                # 1. 按数量清理(优先)
+                if len(batch_dirs) - deleted_count > self.max_batches:
+                    to_delete.append(d)
+                    continue
+
+                # 2. 按天数清理
+                if self.retention_days > 0:
+                    age_days = (now - d.stat().st_mtime) / 86400
+                    if age_days > self.retention_days:
+                        to_delete.append(d)
+
+            # 执行删除
+            for d in to_delete:
+                import shutil
+                shutil.rmtree(d)
+                deleted_count += 1
+                logger.info(f"[配对保存] 清理旧批次: {d.name}")
+
+            if deleted_count > 0:
+                logger.info(f"[配对保存] 共清理 {deleted_count} 个旧批次")
+
         except Exception as e:
             logger.error(f"[配对保存] 清理旧批次失败: {e}")
     
@@ -747,30 +830,38 @@ class PairedImageSaver:
 _paired_saver_instance: Optional[PairedImageSaver] = None
 
 
-def get_paired_saver(base_dir: str = None, time_window: float = 5.0,
+def get_paired_saver(base_dir: str = None, time_window: float = None,
+                     max_batches: int = None, cleanup_enabled: bool = None,
+                     retention_days: int = None,
                      enable_oss: bool = False, oss_uploader = None,
                      device_config: Dict = None) -> PairedImageSaver:
     """
     获取配对保存管理器实例(单例模式)
-    
+
     如果实例已存在但缺少 OSS/设备配置,会自动从配置模块更新
-    
+
     Args:
-        base_dir: 基础保存目录
-        time_window: 时间窗口
+        base_dir: 基础保存目录(默认从配置读取)
+        time_window: 时间窗口(默认从配置读取)
+        max_batches: 最大保留批次数量(默认从配置读取)
+        cleanup_enabled: 是否启用自动清理(默认从配置读取)
+        retention_days: 保留天数(默认从配置读取)
         enable_oss: 是否启用 OSS 上传
         oss_uploader: OSS 上传器实例
         device_config: 设备配置字典
-        
+
     Returns:
         PairedImageSaver 实例
     """
     global _paired_saver_instance
-    
+
     if _paired_saver_instance is None:
         _paired_saver_instance = PairedImageSaver(
-            base_dir=base_dir or '/home/admin/dsh/paired_images',
+            base_dir=base_dir,
             time_window=time_window,
+            max_batches=max_batches,
+            cleanup_enabled=cleanup_enabled,
+            retention_days=retention_days,
             enable_oss=enable_oss,
             oss_uploader=oss_uploader,
             device_config=device_config
@@ -786,7 +877,7 @@ def get_paired_saver(base_dir: str = None, time_window: float = 5.0,
         if device_config is not None:
             _paired_saver_instance.device_config.update(device_config)
             logger.info(f"[配对保存] 更新设备配置: {device_config}")
-    
+
     return _paired_saver_instance