calibration.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. """加载并应用 PTZ 校准映射."""
  2. import json
  3. import logging
  4. import math
  5. from bisect import bisect_left
  6. from pathlib import Path
  7. from typing import Dict, List, Optional, Tuple
  8. logger = logging.getLogger(__name__)
  9. class CalibrationMapper:
  10. """
  11. 把视觉坐标 (pan, tilt) 映射到球机物理坐标 (device_pan, device_tilt)。
  12. 支持两种校准文件格式:
  13. 1. 线性/仿射系数格式:
  14. pan = pan_offset + pan_scale_x * x_ratio + pan_scale_y * y_ratio
  15. tilt = tilt_offset + tilt_scale_x * x_ratio + tilt_scale_y * y_ratio
  16. 2. 查找表格式:
  17. pan_lookup: [[x_ratio, pan_angle], ...]
  18. tilt_lookup: [[y_ratio, tilt_angle], ...]
  19. x_ratio / y_ratio 由视觉角度和视野配置计算:
  20. x_ratio = (visual_pan - pan_center) / (pan_range[1] - pan_range[0]) + 0.5
  21. y_ratio = (visual_tilt - tilt_center) / (tilt_range[1] - tilt_range[0]) + 0.5
  22. """
  23. def __init__(
  24. self,
  25. calibration_path: Optional[str],
  26. ptz_config: Dict,
  27. ):
  28. self.path = calibration_path
  29. self.data: Optional[Dict] = None
  30. self.mount_type: Optional[str] = None
  31. self.pan_flip: bool = False
  32. self.tilt_flip: bool = False
  33. # 视野配置
  34. self.pan_range = tuple(ptz_config.get("pan_range", (-90, 90)))
  35. self.pan_center = float(ptz_config.get("pan_center", 0.0))
  36. self.tilt_range = tuple(ptz_config.get("tilt_range", (-5, 20)))
  37. self.tilt_center = float(ptz_config.get("tilt_center", 0.0))
  38. if calibration_path:
  39. self._load(calibration_path)
  40. def is_loaded(self) -> bool:
  41. return self.data is not None
  42. def _load(self, path: str) -> None:
  43. try:
  44. p = Path(path)
  45. if not p.exists():
  46. logger.warning("[calibration] 校准文件不存在: %s", path)
  47. return
  48. self.data = json.loads(p.read_text(encoding="utf-8"))
  49. self.mount_type = self.data.get("mount_type")
  50. self.pan_flip = bool(self.data.get("pan_flip", False))
  51. self.tilt_flip = bool(self.data.get("tilt_flip", False))
  52. logger.info("[calibration] 加载校准文件: %s", path)
  53. except Exception as exc:
  54. logger.error("[calibration] 加载校准文件失败: %s, %s", path, exc)
  55. self.data = None
  56. def _visual_to_ratio(self, pan: float, tilt: float) -> Tuple[float, float]:
  57. """视觉角度 -> 归一化坐标 (x_ratio, y_ratio)。"""
  58. pan_span = self.pan_range[1] - self.pan_range[0]
  59. tilt_span = self.tilt_range[1] - self.tilt_range[0]
  60. x = 0.5 if pan_span == 0 else (pan - self.pan_center) / pan_span + 0.5
  61. y = 0.5 if tilt_span == 0 else (tilt - self.tilt_center) / tilt_span + 0.5
  62. return float(x), float(y)
  63. @staticmethod
  64. def _interpolate(lookup: List[List[float]], ratio: float) -> float:
  65. """分段线性插值。"""
  66. if not lookup:
  67. return float("nan")
  68. ratios = [row[0] for row in lookup]
  69. values = [row[1] for row in lookup]
  70. if ratio <= ratios[0]:
  71. return values[0]
  72. if ratio >= ratios[-1]:
  73. return values[-1]
  74. idx = bisect_left(ratios, ratio)
  75. x0, x1 = ratios[idx - 1], ratios[idx]
  76. y0, y1 = values[idx - 1], values[idx]
  77. if abs(x1 - x0) < 1e-9:
  78. return y0
  79. return y0 + (y1 - y0) * (ratio - x0) / (x1 - x0)
  80. def visual_to_device(self, pan: float, tilt: float) -> Tuple[float, float]:
  81. """
  82. 把视觉坐标转换为球机物理坐标。
  83. Returns:
  84. (device_pan, device_tilt)
  85. """
  86. if not self.data:
  87. return float(pan), float(tilt)
  88. x, y = self._visual_to_ratio(pan, tilt)
  89. # 优先使用仿射系数
  90. pan_offset = self.data.get("pan_offset")
  91. pan_scale_x = self.data.get("pan_scale_x")
  92. tilt_offset = self.data.get("tilt_offset")
  93. tilt_scale_y = self.data.get("tilt_scale_y")
  94. if (
  95. pan_offset is not None
  96. and pan_scale_x is not None
  97. and tilt_offset is not None
  98. and tilt_scale_y is not None
  99. ):
  100. pan_scale_y = self.data.get("pan_scale_y", 0.0) or 0.0
  101. tilt_scale_x = self.data.get("tilt_scale_x", 0.0) or 0.0
  102. device_pan = pan_offset + pan_scale_x * x + pan_scale_y * y
  103. device_tilt = tilt_offset + tilt_scale_x * x + tilt_scale_y * y
  104. return float(device_pan), float(device_tilt)
  105. # 回退到查找表
  106. pan_lookup = self.data.get("pan_lookup", [])
  107. tilt_lookup = self.data.get("tilt_lookup", [])
  108. device_pan = self._interpolate(pan_lookup, x)
  109. device_tilt = self._interpolate(tilt_lookup, y)
  110. if math.isnan(device_pan):
  111. device_pan = pan
  112. if math.isnan(device_tilt):
  113. device_tilt = tilt
  114. return float(device_pan), float(device_tilt)