瀏覽代碼

refactor: 优化双摄像头系统校准与检测逻辑

调整人员检测阈值、PTZ配置参数,重构坐标转换逻辑,新增分区域检测和离线校准加载功能,优化日志与上传流程
wenhongquan 3 周之前
父節點
當前提交
4d278826bc

+ 33 - 9
dual_camera_system/calibration.py

@@ -520,6 +520,18 @@ 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线性映射(替代不稳定的查找表)
+        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
@@ -1107,18 +1119,30 @@ class CameraCalibrator:
         return math.sqrt(total_error / len(points))
 
     def transform(self, x_ratio: float, y_ratio: float) -> Tuple[float, float]:
-        """将全景坐标转换为PTZ角度 - 优先使用分段线性查找,后备使用线性模型"""
-        # 优先使用分段线性查找表
-        if self.pan_lookup and self.tilt_lookup:
+        """将全景坐标转换为PTZ角度 - 梯形透视补偿"""
+        # 优先使用分段线性查找表(pan)
+        if self.pan_lookup:
             pan = self._interp_lookup(self.pan_lookup, x_ratio)
+        else:
+            pan = self.pan_offset + self.pan_scale_x * x_ratio + self.pan_scale_y * y_ratio
+
+        # pan边缘曲线补偿:越靠近边缘补偿越大,中心不补偿
+        # 梯形透视:底部(y大)更宽,边缘补偿更大;顶部(y小)更窄,补偿更小
+        if self.pan_edge_offset != 0:
+            dx = 2 * x_ratio - 1  # -1(左) ~ 0(中) ~ +1(右)
+            y_scale = 0.3 + 0.7 * y_ratio  # 顶部0.3倍,底部1.0倍
+            pan_correction = self.pan_edge_offset * y_scale * math.copysign(abs(dx) ** self.pan_curve_power, dx)
+            pan += pan_correction
+
+        # tilt:优先使用曲线映射(查找表tilt数据不稳定),后备查找表
+        if self.tilt_linear_enabled:
+            tilt = self.tilt_y0 + (self.tilt_y1 - self.tilt_y0) * (y_ratio ** self.tilt_curve_power)
+        elif self.tilt_lookup:
             tilt = self._interp_lookup(self.tilt_lookup, y_ratio)
-            return (pan % 360, tilt)
+        else:
+            tilt = self.tilt_offset + self.tilt_scale_x * x_ratio + self.tilt_scale_y * y_ratio
 
-        # 后备:线性模型
-        pan = self.pan_offset + self.pan_scale_x * x_ratio + self.pan_scale_y * y_ratio
-        tilt = self.tilt_offset + self.tilt_scale_x * x_ratio + self.tilt_scale_y * y_ratio
-        pan = pan % 360
-        return (pan, tilt)
+        return (pan % 360, tilt)
 
     def _interp_lookup(self, lookup: List[Tuple[float, float]], ratio: float) -> float:
         """分段线性插值"""

+ 1 - 1
dual_camera_system/config/detection.py

@@ -31,7 +31,7 @@ DETECTION_CONFIG = {
         4: 'reflective'
     },
     'person_class_id': 3,           # 人员在模型中的类别ID
-    'person_threshold': 0.5,        # 人员检测置信度阈值(降低以捕获更多目标)
+    'person_threshold': 0.4,        # 人员检测置信度阈值(降低以捕获更多目标)
 }
 
 # 安全检测模型配置

+ 10 - 1
dual_camera_system/config/ptz.py

@@ -8,7 +8,16 @@ PTZ_CONFIG = {
     'max_zoom': 20,                  # 最大变焦倍数
     'move_speed': 4,                 # 移动速度 (1-8)
     'coordinate_offset': (0, 0),     # 坐标偏移校准(仅用于线性映射后备)
-    'tilt_offset': 10,               # tilt偏移(度),正值=向下补偿,负值=向上补偿
+    'tilt_offset': 25,               # tilt偏移(度),正值=向下补偿,负值=向上补偿
+    'pan_offset': 0,                 # pan固定偏移(度),已被边缘曲线补偿替代
+    'pan_edge_offset': 25,           # pan边缘补偿幅度(度),边缘处最大补偿
+    'pan_curve_power': 2.0,          # pan边缘曲线指数,>1时边缘补偿更大、中间更小
+
+    # tilt线性映射参数(覆盖校准查找表的tilt,因为查找表tilt数据不稳定)
+    'tilt_linear_enabled': True,     # 是否使用线性tilt映射替代查找表
+    'tilt_y0': 15,                   # y_ratio=0时对应的tilt角度
+    'tilt_y1': 55,                   # y_ratio=1时对应的tilt角度
+    'tilt_curve_power': 0.8,         # tilt曲线指数,>0.5中间区域tilt更大
 
     # 视野角度配置 (根据实际摄像头参数设置)
     'pan_range': (0, 180),           # 水平视野范围 (度) - 全景相机通常覆盖180度

+ 18 - 7
dual_camera_system/coordinator.py

@@ -320,10 +320,8 @@ class Coordinator:
             (pan, tilt, zoom)
         """
         if self.enable_calibration and self.calibrator and self.calibrator.is_calibrated():
-            # 使用校准结果进行转换
+            # 使用校准结果进行转换(tilt偏移已在calibrator.transform中应用)
             pan, tilt = self.calibrator.transform(x_ratio, y_ratio)
-            # 应用tilt偏移补偿(校准系统性偏差)
-            tilt += PTZ_CONFIG.get('tilt_offset', 0)
             zoom = 8  # 默认变倍
         else:
             # 使用默认估算
@@ -985,9 +983,10 @@ class AsyncCoordinator(Coordinator):
         log_interval = 5.0  # 每5秒打印一次帧率统计
         detection_run_count = 0
         detection_person_count = 0
+        detection_last_seen = 0
         last_no_detect_log_time = 0
         no_detect_log_interval = 30.0
-        
+
         with self.stats_lock:
             self.stats['start_time'] = time.time()
         
@@ -1022,7 +1021,11 @@ class AsyncCoordinator(Coordinator):
                     elif not self.enable_detection:
                         stats_parts.append("检测=已禁用")
                     else:
-                        stats_parts.append(f"检测轮次={detection_run_count}(有人={detection_person_count})")
+                        if detection_last_seen > 0:
+                            ago = int(current_time - detection_last_seen)
+                            stats_parts.append(f"检测轮次={detection_run_count}(最后有人={ago}s前)")
+                        else:
+                            stats_parts.append(f"检测轮次={detection_run_count}(未检出)")
                     
                     with self.targets_lock:
                         target_count = len(self.tracking_targets)
@@ -1039,10 +1042,11 @@ class AsyncCoordinator(Coordinator):
                     
                     # YOLO 人体检测
                     detections = self._detect_persons(frame)
-                    
+
                     if detections:
                         self._update_stats('persons_detected', len(detections))
                         detection_person_count += 1
+                        detection_last_seen = current_time
                     
                     # 更新跟踪目标(track_id 在此方法内分配)
                     self._update_tracking_targets(detections, frame_size)
@@ -1757,6 +1761,7 @@ class SequentialCoordinator(AsyncCoordinator):
         log_interval = 5.0
         detection_run_count = 0
         detection_person_count = 0
+        detection_last_seen = 0
         last_no_detect_log_time = 0
         no_detect_log_interval = 30.0
         
@@ -1787,8 +1792,13 @@ class SequentialCoordinator(AsyncCoordinator):
                     elapsed = current_time - last_log_time
                     fps = frame_count / elapsed if elapsed > 0 else 0
                     state_str = self._get_capture_state()
+                    if detection_last_seen > 0:
+                        ago = int(current_time - detection_last_seen)
+                        person_info = f"最后有人={ago}s前"
+                    else:
+                        person_info = "未检出"
                     logger.info(f"[检测线程] 帧率={fps:.1f}fps, 状态={state_str}, "
-                               f"检测轮次={detection_run_count}(有人={detection_person_count})")
+                               f"检测轮次={detection_run_count}({person_info})")
                     frame_count = 0
                     last_log_time = current_time
                 
@@ -1812,6 +1822,7 @@ class SequentialCoordinator(AsyncCoordinator):
                         
                         if detections:
                             self._update_stats('persons_detected', len(detections))
+                            detection_last_seen = current_time
                             detection_person_count += 1
                             
                             # 更新跟踪目标

+ 25 - 1
dual_camera_system/main.py

@@ -285,15 +285,39 @@ class DualCameraSystem:
         should_calibrate = SYSTEM_CONFIG.get('enable_calibration', True)
         if not should_calibrate:
             logger.info("自动校准已禁用 (enable_calibration=False)")
+            self._load_existing_calibration()
         elif skip_calibration:
             logger.info("自动校准已跳过 (--skip-calibration)")
+            self._load_existing_calibration()
         else:
             if not self._auto_calibrate():
                 logger.error("自动校准失败!")
                 return False
         
         return True
-    
+
+    def _load_existing_calibration(self):
+        """加载已有的校准数据并传递给联动控制器(跳过校准时使用)"""
+        try:
+            from calibration import CameraCalibrator, CalibrationManager
+            from config import CALIBRATION_CONFIG
+            calibrator = CameraCalibrator(
+                ptz_camera=self.ptz_camera,
+                get_frame_func=self.panorama_camera.get_frame,
+                ptz_capture_func=self._capture_ptz_frame
+            )
+            calibration_file = CALIBRATION_CONFIG.get('calibration_file', '/home/admin/dsh/calibration.json')
+            if calibrator.load_calibration(calibration_file):
+                logger.info(f"已加载已有校准数据: {calibration_file}")
+                if self.coordinator and calibrator.is_calibrated():
+                    self.coordinator.set_calibrator(calibrator)
+                    logger.info("校准器已传递给联动控制器")
+                self.calibrator = calibrator
+            else:
+                logger.warning("无已有校准数据,跟踪将使用默认线性映射")
+        except Exception as e:
+            logger.warning(f"加载校准数据失败: {e},跟踪将使用默认线性映射")
+
     def _pause_detection(self):
         """暂停检测线程(校准时使用,避免检测与校准争抢PTZ控制权)"""
         if self.coordinator is None:

+ 23 - 12
dual_camera_system/paired_image_saver.py

@@ -304,22 +304,33 @@ class PairedImageSaver:
             batch.completed = True
             batch_dir = self.base_dir / f"batch_{batch.batch_id}"
 
-            # 等待 OSS 上传完成(最多等待8秒,避免阻塞太久
-            if self.enable_oss and batch.batch_id in self._upload_status:
+            # 等待 OSS 上传完成(最多等待30秒
+            if self.enable_oss:
                 wait_start = time.time()
-                max_wait = 8.0
+                max_wait = 30.0
+                # 统计需要等待的 PTZ 上传数量
+                expected_ptz_count = sum(1 for p in batch.persons if p.ptz_image_saved)
                 while time.time() - wait_start < max_wait:
-                    status = self._upload_status[batch.batch_id]
+                    status = self._upload_status.get(batch.batch_id, {})
                     panorama_done = status.get('panorama_url') is not None or not batch.panorama_path
-                    ptz_status = status.get('ptz', {})
-                    ptz_done = all(
-                        ptz_status.get(idx) is not None
-                        for idx, person in enumerate(batch.persons)
-                        if person.ptz_image_saved
-                    )
+                    ptz_marked_status = status.get('ptz_marked', {})
+                    ptz_original_status = status.get('ptz_original', {})
+                    # 只有当 PTZ 上传状态已提交后才判断完成,避免空字典误判
+                    if expected_ptz_count == 0:
+                        ptz_done = True
+                    else:
+                        ptz_done = (
+                            len(ptz_marked_status) >= expected_ptz_count
+                            and len(ptz_original_status) >= expected_ptz_count
+                            and all(
+                                ptz_marked_status.get(idx) is not None and ptz_original_status.get(idx) is not None
+                                for idx, person in enumerate(batch.persons)
+                                if person.ptz_image_saved
+                            )
+                        )
                     if panorama_done and ptz_done:
                         break
-                    time.sleep(0.2)
+                    time.sleep(0.5)
 
             # 构建并保存 batch_info.json
             batch_info = self._build_batch_info_json(batch)
@@ -895,7 +906,7 @@ class PairedImageSaver:
         persons_list = []
         for person in batch.persons:
             # 获取球机图 OSS URL
-            ptz_oss_url = upload_status.get('ptz', {}).get(person.person_index, person.ptz_oss_url)
+            ptz_oss_url = upload_status.get('ptz_marked', {}).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 = {

+ 101 - 16
dual_camera_system/panorama_camera.py

@@ -640,12 +640,17 @@ class ObjectDetector:
         return canvas, scale, pad_w, pad_h, h0, w0
     
     def _detect_rknn(self, frame: np.ndarray) -> List[DetectedObject]:
-        """使用 RKNN/ONNX 模型检测"""
+        """使用 RKNN/ONNX 模型检测 - 宽幅全景图分区域检测以提高远处目标识别率"""
         results = []
-        
+        h0, w0 = frame.shape[:2]
+
+        # 宽幅图(宽高比>2.5)使用分区域检测,避免letterbox后人太小
+        if w0 / h0 > 2.5:
+            return self._detect_rknn_tiled(frame)
+
         try:
             canvas, scale, pad_w, pad_h, h0, w0 = self._letterbox(frame)
-            
+
             if hasattr(self, 'rknn'):
                 # RKNN
                 img = canvas[..., ::-1].astype(np.float32) / 255.0
@@ -657,49 +662,49 @@ class ObjectDetector:
                 img = img.transpose(2, 0, 1)
                 blob = img[None, ...]
                 outputs = self.session.run([self.output_name], {self.input_name: blob})
-            
+
             output = outputs[0]
             if len(output.shape) == 3:
                 output = output[0]
-            
+
             num_boxes = output.shape[1]
             conf_threshold = self.config['confidence_threshold']
-            
+
             for i in range(num_boxes):
                 x_center = float(output[0, i])
                 y_center = float(output[1, i])
                 width = float(output[2, i])
                 height = float(output[3, i])
-                
+
                 class_probs = output[4:, i]
                 best_class = int(np.argmax(class_probs))
                 confidence = float(class_probs[best_class])
-                
+
                 if confidence < conf_threshold:
                     continue
-                
+
                 # 转换到原始图像坐标
                 x1 = int(((x_center - width / 2) - pad_w) / scale)
                 y1 = int(((y_center - height / 2) - pad_h) / scale)
                 x2 = int(((x_center + width / 2) - pad_w) / scale)
                 y2 = int(((y_center + height / 2) - pad_h) / scale)
-                
+
                 x1 = max(0, min(w0, x1))
                 y1 = max(0, min(h0, y1))
                 x2 = max(0, min(w0, x2))
                 y2 = max(0, min(h0, y2))
-                
+
                 if x2 - x1 < 10 or y2 - y1 < 10:
                     continue
-                
+
                 # 使用配置的类别映射获取类别名称
                 class_map = self.config.get('class_map', {0: 'hat',3: 'person',4: 'reflective'})
                 cls_name = class_map.get(best_class, str(best_class))
-                
+
                 # 检查是否为目标类别
                 if cls_name not in self.config['target_classes']:
                     continue
-                
+
                 obj = DetectedObject(
                     class_name=cls_name,
                     confidence=confidence,
@@ -707,10 +712,90 @@ class ObjectDetector:
                     center=((x1 + x2) // 2, (y1 + y2) // 2)
                 )
                 results.append(obj)
-                
+
         except Exception as e:
             logger.error(f"RKNN/ONNX 检测错误: {e}")
-        
+
+        return results
+
+    def _detect_rknn_tiled(self, frame: np.ndarray) -> List[DetectedObject]:
+        """分区域检测 - 将宽幅全景图分成多个重叠区域分别检测,提高远处目标识别率"""
+        results = []
+        h0, w0 = frame.shape[:2]
+        conf_threshold = self.config['confidence_threshold']
+        class_map = self.config.get('class_map', {0: 'hat', 3: 'person', 4: 'reflective'})
+
+        # 分3个重叠区域
+        overlap = int(h0 * 0.2)
+        regions = [
+            (0, w0 // 3 + overlap),
+            (w0 // 3 - overlap // 2, 2 * w0 // 3 + overlap // 2),
+            (2 * w0 // 3 - overlap, w0),
+        ]
+
+        seen_centers = []
+
+        for x_start, x_end in regions:
+            crop = frame[:, x_start:x_end]
+            canvas, scale, pad_w, pad_h, ch, cw = self._letterbox(crop)
+
+            if hasattr(self, 'rknn'):
+                img = canvas[..., ::-1].astype(np.float32) / 255.0
+                outputs = self.rknn.inference(inputs=[img[None, ...]])
+            else:
+                img = canvas[..., ::-1].astype(np.float32) / 255.0
+                img = img.transpose(2, 0, 1)
+                outputs = self.session.run([self.output_name], {self.input_name: img[None, ...]})
+
+            output = outputs[0]
+            if len(output.shape) == 3:
+                output = output[0]
+
+            for i in range(output.shape[1]):
+                class_probs = output[4:, i]
+                best_class = int(np.argmax(class_probs))
+                confidence = float(class_probs[best_class])
+
+                if confidence < conf_threshold:
+                    continue
+
+                cls_name = class_map.get(best_class, str(best_class))
+                if cls_name not in self.config['target_classes']:
+                    continue
+
+                xc = float(output[0, i])
+                yc = float(output[1, i])
+                bw = float(output[2, i])
+                bh = float(output[3, i])
+
+                # 转换到原图坐标(加上区域偏移)
+                x1 = int(((xc - bw / 2) - pad_w) / scale) + x_start
+                y1 = int(((yc - bh / 2) - pad_h) / scale)
+                x2 = int(((xc + bw / 2) - pad_w) / scale) + x_start
+                y2 = int(((yc + bh / 2) - pad_h) / scale)
+
+                x1 = max(0, min(w0, x1))
+                y1 = max(0, min(h0, y1))
+                x2 = max(0, min(w0, x2))
+                y2 = max(0, min(h0, y2))
+
+                if x2 - x1 < 10 or y2 - y1 < 10:
+                    continue
+
+                # 去重:同一目标可能被相邻区域重复检测
+                cx, cy = (x1 + x2) // 2, (y1 + y2) // 2
+                if any(abs(cx - sx) < 50 and abs(cy - sy) < 50 for sx, sy in seen_centers):
+                    continue
+                seen_centers.append((cx, cy))
+
+                obj = DetectedObject(
+                    class_name=cls_name,
+                    confidence=confidence,
+                    bbox=(x1, y1, x2 - x1, y2 - y1),
+                    center=(cx, cy)
+                )
+                results.append(obj)
+
         return results
     
     def detect(self, frame: np.ndarray) -> List[DetectedObject]:

+ 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"
 
 # 启动参数
-START_ARGS="--skip-calibration"  # 可添加其他参数,如 --multi-group
+START_ARGS=""  # 可添加其他参数,如 --skip-calibration --multi-group
 
 # ============================================================
 # 环境设置