mapping_model.py 15 KB

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