build_calibration_mapping.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. # build_calibration_mapping.py
  2. import argparse
  3. import json
  4. import re
  5. from pathlib import Path
  6. from typing import Dict, List, Optional, Tuple
  7. import cv2
  8. import numpy as np
  9. from matchers import TemplateMatcher, FeatureMatcher
  10. from mapping_model import MappingModel
  11. def parse_filename(filename: str) -> Optional[Tuple[float, float]]:
  12. m = re.match(r'ptz_p(\d+)_t([+-]?\d+)\.jpg', filename)
  13. if not m:
  14. return None
  15. return float(m.group(1)), float(m.group(2))
  16. def _to_native(value):
  17. """Convert numpy scalar to native Python type for JSON serialization."""
  18. if isinstance(value, np.generic):
  19. return value.item()
  20. return value
  21. def _convert_result(result):
  22. """Convert matcher result tuple to JSON-serializable native types."""
  23. if result is None:
  24. return None
  25. return tuple(_to_native(v) for v in result)
  26. def fuse_results(tm_result, fm_result) -> Optional[Dict]:
  27. if tm_result is None and fm_result is None:
  28. return None
  29. if tm_result is None:
  30. return {
  31. 'x_ratio': fm_result[0],
  32. 'y_ratio': fm_result[1],
  33. 'confidence': 'feature_only',
  34. 'tm_score': None,
  35. 'fm_inliers': fm_result[2],
  36. }
  37. if fm_result is None:
  38. return {
  39. 'x_ratio': tm_result[0],
  40. 'y_ratio': tm_result[1],
  41. 'confidence': 'template_only',
  42. 'tm_score': tm_result[2],
  43. 'fm_inliers': None,
  44. }
  45. tm_x, tm_y, tm_score, _ = tm_result
  46. fm_x, fm_y, fm_inliers, _ = fm_result
  47. diff = abs(tm_x - fm_x) + abs(tm_y - fm_y)
  48. if diff > 0.30: # 差异过大,优先信任特征匹配
  49. if tm_score >= 0.8 and fm_inliers < 10:
  50. x, y = tm_x, tm_y
  51. conf = 'template_high_disagree'
  52. else:
  53. x, y = fm_x, fm_y
  54. conf = 'feature_high_disagree'
  55. else:
  56. # 加权融合
  57. w_tm = tm_score * 0.4
  58. w_fm = min(fm_inliers / 30.0, 1.0) * 0.6
  59. total = w_tm + w_fm
  60. if total < 1e-6:
  61. x, y = (tm_x + fm_x) / 2, (tm_y + fm_y) / 2
  62. else:
  63. x = (tm_x * w_tm + fm_x * w_fm) / total
  64. y = (tm_y * w_tm + fm_y * w_fm) / total
  65. conf = 'fused'
  66. return {
  67. 'x_ratio': x,
  68. 'y_ratio': y,
  69. 'confidence': conf,
  70. 'tm_score': tm_score,
  71. 'fm_inliers': fm_inliers,
  72. }
  73. def draw_match(panorama: np.ndarray, x_ratio: float, y_ratio: float,
  74. pan: float, tilt: float, out_path: Path) -> None:
  75. vis = panorama.copy()
  76. h, w = vis.shape[:2]
  77. cx, cy = int(x_ratio * w), int(y_ratio * h)
  78. cv2.circle(vis, (cx, cy), 15, (0, 0, 255), 2)
  79. cv2.putText(vis, f'P{pan:.0f} T{tilt:.0f}', (cx + 20, cy),
  80. cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2)
  81. cv2.imwrite(str(out_path), vis)
  82. def main():
  83. parser = argparse.ArgumentParser(description='Build PTZ-panorama coordinate mapping')
  84. parser.add_argument('--ptz-dir', default='ptz_images')
  85. parser.add_argument('--panorama', default='panorama.jpg')
  86. parser.add_argument('--output', default='calibration_group1.json')
  87. parser.add_argument('--detail-output', default='mapping_fused.json')
  88. parser.add_argument('--vis-dir', default='matches_fused')
  89. parser.add_argument('--save-vis', action='store_true')
  90. args = parser.parse_args()
  91. ptz_dir = Path(args.ptz_dir)
  92. panorama_path = Path(args.panorama)
  93. panorama = cv2.imread(str(panorama_path))
  94. if panorama is None:
  95. raise FileNotFoundError(f'Cannot read panorama: {panorama_path}')
  96. tm = TemplateMatcher()
  97. fm = FeatureMatcher()
  98. model = MappingModel()
  99. records = []
  100. detail_records = []
  101. ptz_files = sorted(ptz_dir.glob('ptz_p*_t*.jpg'))
  102. print(f'Found {len(ptz_files)} PTZ images')
  103. if args.save_vis:
  104. vis_dir = Path(args.vis_dir)
  105. vis_dir.mkdir(exist_ok=True)
  106. for ptz_path in ptz_files:
  107. parsed = parse_filename(ptz_path.name)
  108. if parsed is None:
  109. continue
  110. pan, tilt = parsed
  111. ptz_img = cv2.imread(str(ptz_path))
  112. if ptz_img is None:
  113. continue
  114. tm_result = _convert_result(tm.match(ptz_img, panorama))
  115. fm_result = _convert_result(fm.match(ptz_img, panorama))
  116. fused = fuse_results(tm_result, fm_result)
  117. detail = {
  118. 'filename': ptz_path.name,
  119. 'pan': pan,
  120. 'tilt': tilt,
  121. 'matched': fused is not None,
  122. 'tm': tm_result,
  123. 'fm': fm_result,
  124. 'fused': fused,
  125. }
  126. detail_records.append(detail)
  127. if fused is not None:
  128. record = {
  129. 'pan': pan,
  130. 'tilt': tilt,
  131. 'x_ratio': fused['x_ratio'],
  132. 'y_ratio': fused['y_ratio'],
  133. 'confidence': fused['confidence'],
  134. }
  135. records.append(record)
  136. if args.save_vis:
  137. draw_match(panorama, fused['x_ratio'], fused['y_ratio'], pan, tilt,
  138. vis_dir / f'match_{ptz_path.stem}.jpg')
  139. print(f'Valid fused records: {len(records)}/{len(ptz_files)}')
  140. if len(records) < 4:
  141. raise RuntimeError(f'Too few valid records: {len(records)}')
  142. ok = model.fit(records)
  143. if not ok:
  144. raise RuntimeError('Failed to fit mapping model')
  145. model.save(args.output)
  146. print(f'Saved mapping to {args.output}, RMS error: {model.rms_error:.2f}°')
  147. with open(args.detail_output, 'w', encoding='utf-8') as f:
  148. json.dump({
  149. 'records': detail_records,
  150. 'panorama_size': {'width': int(panorama.shape[1]), 'height': int(panorama.shape[0])},
  151. }, f, indent=2, ensure_ascii=False)
  152. print(f'Saved detail to {args.detail_output}')
  153. if __name__ == '__main__':
  154. main()