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