mapping_model.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. from typing import Dict, List, Optional, Tuple
  2. import math
  3. import numpy as np
  4. class MappingModel:
  5. def __init__(self):
  6. self.pan_offset = 0.0
  7. self.pan_scale_x = 0.0
  8. self.pan_scale_y = 0.0
  9. self.tilt_offset = 0.0
  10. self.tilt_scale_x = 0.0
  11. self.tilt_scale_y = 0.0
  12. self.rms_error = 0.0
  13. self.pan_lookup: List[Tuple[float, float]] = []
  14. self.tilt_lookup: List[Tuple[float, float]] = []
  15. self.panorama_width = 3840
  16. self.panorama_height = 1080
  17. @staticmethod
  18. def _angular_diff(a: float, b: float) -> float:
  19. diff = a - b
  20. while diff > 180:
  21. diff -= 360
  22. while diff < -180:
  23. diff += 360
  24. return diff
  25. @staticmethod
  26. def _unwrap_pan_angles(pan_values: np.ndarray) -> np.ndarray:
  27. if len(pan_values) == 0:
  28. return pan_values
  29. ref = float(np.median(pan_values))
  30. unwrapped = pan_values.astype(float).copy()
  31. for i in range(len(unwrapped)):
  32. diff = unwrapped[i] - ref
  33. while diff > 180:
  34. unwrapped[i] -= 360
  35. diff = unwrapped[i] - ref
  36. while diff < -180:
  37. unwrapped[i] += 360
  38. diff = unwrapped[i] - ref
  39. return unwrapped
  40. def fit(self, records: List[Dict]) -> bool:
  41. valid = [r for r in records if 'x_ratio' in r and 'y_ratio' in r]
  42. if len(valid) < 4:
  43. return False
  44. # RANSAC 过滤
  45. inlier_mask = self._ransac_filter(valid)
  46. inliers = [valid[i] for i in range(len(valid)) if inlier_mask[i]]
  47. if len(inliers) < 4:
  48. inliers = valid
  49. # 最小二乘拟合
  50. self._fit_linear(inliers)
  51. # 生成查找表
  52. self._build_lookups(inliers)
  53. # 计算 RMS
  54. self.rms_error = self._calculate_rms_error(inliers)
  55. return True
  56. def _ransac_filter(self, records: List[Dict],
  57. iterations: int = 200,
  58. threshold: float = 15.0) -> np.ndarray:
  59. n = len(records)
  60. if n < 8:
  61. return np.ones(n, dtype=bool)
  62. x = np.array([r['x_ratio'] for r in records])
  63. y = np.array([r['y_ratio'] for r in records])
  64. pan = self._unwrap_pan_angles(np.array([r['pan'] for r in records]))
  65. tilt = np.array([r['tilt'] for r in records])
  66. best_inliers = np.ones(n, dtype=bool)
  67. best_count = 0
  68. rng = np.random.RandomState(42)
  69. for _ in range(iterations):
  70. idx = rng.choice(n, 4, replace=False)
  71. A = np.ones((4, 3))
  72. A[:, 1] = x[idx]
  73. A[:, 2] = y[idx]
  74. try:
  75. pan_params, _, _, _ = np.linalg.lstsq(A, pan[idx], rcond=None)
  76. tilt_params, _, _, _ = np.linalg.lstsq(A, tilt[idx], rcond=None)
  77. except np.linalg.LinAlgError:
  78. continue
  79. pred_pan = pan_params[0] + pan_params[1] * x + pan_params[2] * y
  80. pred_tilt = tilt_params[0] + tilt_params[1] * x + tilt_params[2] * y
  81. pan_err = np.array([self._angular_diff(float(pred_pan[i]), float(pan[i])) for i in range(n)])
  82. tilt_err = pred_tilt - tilt
  83. errors = np.sqrt(pan_err ** 2 + tilt_err ** 2)
  84. inliers = errors < threshold
  85. count = int(np.sum(inliers))
  86. if count > best_count:
  87. best_count = count
  88. best_inliers = inliers
  89. return best_inliers
  90. def _fit_linear(self, records: List[Dict]) -> None:
  91. x = np.array([r['x_ratio'] for r in records])
  92. y = np.array([r['y_ratio'] for r in records])
  93. pan = self._unwrap_pan_angles(np.array([r['pan'] for r in records]))
  94. tilt = np.array([r['tilt'] for r in records])
  95. A = np.ones((len(records), 3))
  96. A[:, 1] = x
  97. A[:, 2] = y
  98. pan_params, _, _, _ = np.linalg.lstsq(A, pan, rcond=None)
  99. tilt_params, _, _, _ = np.linalg.lstsq(A, tilt, rcond=None)
  100. self.pan_offset = float(pan_params[0])
  101. self.pan_scale_x = float(pan_params[1])
  102. self.pan_scale_y = float(pan_params[2])
  103. self.tilt_offset = float(tilt_params[0])
  104. self.tilt_scale_x = float(tilt_params[1])
  105. self.tilt_scale_y = float(tilt_params[2])
  106. # 系数异常则回退简化模型
  107. if (abs(self.pan_scale_x) > 500 or abs(self.pan_scale_y) > 500 or
  108. abs(self.tilt_scale_x) > 300 or abs(self.tilt_scale_y) > 300):
  109. A_pan = np.ones((len(records), 2))
  110. A_pan[:, 1] = x
  111. A_tilt = np.ones((len(records), 2))
  112. A_tilt[:, 1] = y
  113. pan_params_s, _, _, _ = np.linalg.lstsq(A_pan, pan, rcond=None)
  114. tilt_params_s, _, _, _ = np.linalg.lstsq(A_tilt, tilt, rcond=None)
  115. self.pan_offset = float(pan_params_s[0])
  116. self.pan_scale_x = float(pan_params_s[1])
  117. self.pan_scale_y = 0.0
  118. self.tilt_offset = float(tilt_params_s[0])
  119. self.tilt_scale_x = 0.0
  120. self.tilt_scale_y = float(tilt_params_s[1])
  121. def _build_lookups(self, records: List[Dict]) -> None:
  122. grid_size = 0.05
  123. # pan_lookup
  124. x_buckets: Dict[float, List[Tuple[float, float]]] = {}
  125. for r in records:
  126. x_key = round(r['x_ratio'] / grid_size) * grid_size
  127. x_buckets.setdefault(x_key, []).append((r['pan'], 1.0))
  128. raw = []
  129. for x_key in sorted(x_buckets.keys()):
  130. pans = [p for p, _ in x_buckets[x_key]]
  131. raw.append((x_key, float(np.median(pans)), len(pans)))
  132. filtered = self._filter_continuous_monotonic(raw)
  133. self.pan_lookup = [(x, pan) for x, pan, _ in filtered]
  134. # tilt_lookup:只在 pan_lookup 有效的 x 附近取点
  135. pan_valid_x = {x for x, _ in self.pan_lookup}
  136. pan_tolerance = grid_size * 1.5
  137. valid_for_tilt = []
  138. for r in records:
  139. if any(abs(r['x_ratio'] - vx) <= pan_tolerance for vx in pan_valid_x):
  140. valid_for_tilt.append(r)
  141. y_buckets: Dict[float, List[float]] = {}
  142. for r in valid_for_tilt:
  143. y_key = round(r['y_ratio'] / grid_size) * grid_size
  144. y_buckets.setdefault(y_key, []).append(r['tilt'])
  145. self.tilt_lookup = [(y_key, float(np.median(tilts))) for y_key, tilts in sorted(y_buckets.items())]
  146. def _filter_continuous_monotonic(self, entries: List[Tuple[float, float, float]],
  147. max_step: float = 60.0) -> List[Tuple[float, float, float]]:
  148. n = len(entries)
  149. if n <= 2:
  150. return [(x, pan % 360, w) for x, pan, w in entries]
  151. best_result = []
  152. for direction in ['decreasing', 'increasing']:
  153. dp = [1] * n
  154. parent = [-1] * n
  155. for i in range(1, n):
  156. for j in range(i):
  157. diff = entries[i][1] - entries[j][1]
  158. while diff > 180:
  159. diff -= 360
  160. while diff < -180:
  161. diff += 360
  162. if direction == 'decreasing':
  163. ok = diff <= 0 and abs(diff) <= max_step
  164. else:
  165. ok = diff >= 0 and abs(diff) <= max_step
  166. if ok and dp[j] + 1 > dp[i]:
  167. dp[i] = dp[j] + 1
  168. parent[i] = j
  169. end = max(range(n), key=lambda i: dp[i])
  170. seq = []
  171. idx = end
  172. while idx >= 0:
  173. seq.append(idx)
  174. idx = parent[idx]
  175. seq.reverse()
  176. result = self._unwrap_entries(entries, seq)
  177. if len(result) > len(best_result):
  178. best_result = result
  179. if len(best_result) < 3 and n >= 3:
  180. for wider_step in [90, 120, 180]:
  181. for direction in ['decreasing', 'increasing']:
  182. # 同上逻辑,用 wider_step
  183. dp = [1] * n
  184. parent = [-1] * n
  185. for i in range(1, n):
  186. for j in range(i):
  187. diff = entries[i][1] - entries[j][1]
  188. while diff > 180:
  189. diff -= 360
  190. while diff < -180:
  191. diff += 360
  192. if direction == 'decreasing':
  193. ok = diff <= 0 and abs(diff) <= wider_step
  194. else:
  195. ok = diff >= 0 and abs(diff) <= wider_step
  196. if ok and dp[j] + 1 > dp[i]:
  197. dp[i] = dp[j] + 1
  198. parent[i] = j
  199. end = max(range(n), key=lambda i: dp[i])
  200. seq = []
  201. idx = end
  202. while idx >= 0:
  203. seq.append(idx)
  204. idx = parent[idx]
  205. seq.reverse()
  206. result = self._unwrap_entries(entries, seq)
  207. if len(result) > len(best_result):
  208. best_result = result
  209. if len(best_result) >= 3:
  210. break
  211. if not best_result:
  212. return [(x, pan % 360, w) for x, pan, w in entries]
  213. return best_result
  214. def _unwrap_entries(self, entries: List[Tuple[float, float, float]],
  215. indices: List[int]) -> List[Tuple[float, float, float]]:
  216. result = []
  217. prev = None
  218. for idx in indices:
  219. x, pan, w = entries[idx]
  220. if prev is None:
  221. unwrapped = pan
  222. else:
  223. diff = pan - prev
  224. while diff > 180:
  225. pan -= 360
  226. diff = pan - prev
  227. while diff < -180:
  228. pan += 360
  229. diff = pan - prev
  230. unwrapped = pan
  231. prev = unwrapped
  232. result.append((x, unwrapped, w))
  233. return result
  234. def _calculate_rms_error(self, records: List[Dict]) -> float:
  235. total = 0.0
  236. for r in records:
  237. pred_pan, pred_tilt = self.transform(
  238. int(r['x_ratio'] * self.panorama_width),
  239. int(r['y_ratio'] * self.panorama_height)
  240. )
  241. pan_err = self._angular_diff(pred_pan, r['pan'])
  242. tilt_err = pred_tilt - r['tilt']
  243. total += pan_err ** 2 + tilt_err ** 2
  244. return math.sqrt(total / len(records))
  245. def transform(self, panorama_x: int, panorama_y: int) -> Tuple[float, float]:
  246. x_ratio = panorama_x / self.panorama_width
  247. y_ratio = panorama_y / self.panorama_height
  248. if self.pan_lookup:
  249. pan = self._interp_lookup(self.pan_lookup, x_ratio)
  250. else:
  251. pan = self.pan_offset + self.pan_scale_x * x_ratio + self.pan_scale_y * y_ratio
  252. if self.tilt_lookup:
  253. tilt = self._interp_lookup(self.tilt_lookup, y_ratio)
  254. else:
  255. tilt = self.tilt_offset + self.tilt_scale_x * x_ratio + self.tilt_scale_y * y_ratio
  256. pan = pan % 360
  257. tilt = max(-90, min(90, tilt))
  258. return pan, tilt
  259. def _interp_lookup(self, lookup: List[Tuple[float, float]], ratio: float) -> float:
  260. if not lookup:
  261. return 0.0
  262. if len(lookup) == 1:
  263. return lookup[0][1]
  264. if ratio <= lookup[0][0]:
  265. return lookup[0][1]
  266. if ratio >= lookup[-1][0]:
  267. return lookup[-1][1]
  268. lo, hi = 0, len(lookup) - 1
  269. while lo < hi - 1:
  270. mid = (lo + hi) // 2
  271. if lookup[mid][0] <= ratio:
  272. lo = mid
  273. else:
  274. hi = mid
  275. x0, v0 = lookup[lo]
  276. x1, v1 = lookup[hi]
  277. if abs(x1 - x0) < 1e-10:
  278. return v0
  279. t = (ratio - x0) / (x1 - x0)
  280. return v0 + t * (v1 - v0)
  281. def to_dict(self) -> Dict:
  282. return {
  283. 'pan_offset': self.pan_offset,
  284. 'pan_scale_x': self.pan_scale_x,
  285. 'pan_scale_y': self.pan_scale_y,
  286. 'tilt_offset': self.tilt_offset,
  287. 'tilt_scale_x': self.tilt_scale_x,
  288. 'tilt_scale_y': self.tilt_scale_y,
  289. 'rms_error': self.rms_error,
  290. 'pan_lookup': self.pan_lookup,
  291. 'tilt_lookup': self.tilt_lookup,
  292. 'overlap_ranges': [{
  293. 'pan_start': 180.0,
  294. 'pan_end': 340.0,
  295. 'tilt_start': -35.0,
  296. 'tilt_end': 45.0,
  297. }],
  298. 'mount_type': 'wall',
  299. 'tilt_flip': False,
  300. 'pan_flip': False,
  301. 'generated_from': 'ptz_panorama_fused_matching',
  302. 'note': 'x_ratio,y_ratio 为全景图归一化坐标;transform 输入像素坐标 3840x1080',
  303. }
  304. def save(self, path: str) -> None:
  305. import json
  306. with open(path, 'w', encoding='utf-8') as f:
  307. json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)