manual_calibrate.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  1. #!/usr/bin/env python3
  2. """
  3. 交互式手动标定脚本(Web UI 版)
  4. 流程:
  5. 1. 用户在 panorama.jpg 上选择标定点,写入 points.txt(每行:x_ratio y_ratio [备注])
  6. 2. 脚本抓取全景图、启动 Web UI
  7. 3. 脚本控制球机移动到每个点的初始估计位置并抓拍
  8. 4. 用户在浏览器点击按钮调整 pan/tilt,确认后进入下一点
  9. 5. 最终生成 calibration_group1_manual.json
  10. 用法:
  11. python manual_calibrate.py --points points.txt --output manual_calib --http-port 8000
  12. """
  13. import os
  14. import sys
  15. import json
  16. import time
  17. import argparse
  18. import http.server
  19. import socketserver
  20. import threading
  21. import queue
  22. from pathlib import Path
  23. from datetime import datetime
  24. from urllib.parse import urlparse, parse_qs
  25. # 必须在导入 cv2 前设置
  26. os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
  27. import cv2
  28. import numpy as np
  29. sys.path.insert(0, '/home/admin/dsh/dual_camera_system')
  30. from dahua_sdk import DahuaSDK
  31. from ptz_camera import PTZCamera
  32. # 全局状态
  33. calib_state = {
  34. 'running': True,
  35. 'point_idx': 0,
  36. 'total_points': 0,
  37. 'pan': 0.0,
  38. 'tilt': 0.0,
  39. 'zoom': 1,
  40. 'note': '',
  41. 'status': 'initializing', # initializing, moving, waiting, finished, error
  42. 'message': '初始化中...',
  43. 'image_ts': 0,
  44. 'calib_points': [],
  45. 'http_port': 8000,
  46. }
  47. cmd_queue = queue.Queue()
  48. def load_points(points_path: str) -> list:
  49. """从文件加载标定点,格式:每行 x_ratio y_ratio [备注]"""
  50. points = []
  51. if not os.path.exists(points_path):
  52. return points
  53. with open(points_path, 'r', encoding='utf-8') as f:
  54. for line in f:
  55. line = line.strip()
  56. if not line or line.startswith('#'):
  57. continue
  58. parts = line.split()
  59. if len(parts) >= 2:
  60. x = float(parts[0])
  61. y = float(parts[1])
  62. note = ' '.join(parts[2:]) if len(parts) > 2 else ''
  63. points.append({'x': x, 'y': y, 'note': note})
  64. return points
  65. def estimate_initial_ptz(x_ratio: float, y_ratio: float) -> tuple:
  66. """用粗略线性模型估计初始 PTZ 角度"""
  67. pan = 340.0 - 160.0 * x_ratio
  68. tilt = 25.0 - 40.0 * y_ratio
  69. return pan, tilt
  70. def capture_ptz_frame(ptz: PTZCamera, attempts: int = 15) -> np.ndarray:
  71. """抓取清晰的 PTZ 帧"""
  72. best_frame = None
  73. best_var = 0
  74. for _ in range(attempts):
  75. frame = ptz.get_frame()
  76. if frame is not None:
  77. gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  78. var = cv2.Laplacian(gray, cv2.CV_64F).var()
  79. if var > best_var:
  80. best_var = var
  81. best_frame = frame
  82. if best_var > 200:
  83. break
  84. time.sleep(0.05)
  85. return best_frame
  86. def save_panorama_with_marks(panorama: np.ndarray, points: list, output_path: Path):
  87. """在全景图上标记所有标定点"""
  88. vis = panorama.copy()
  89. h, w = vis.shape[:2]
  90. for i, p in enumerate(points):
  91. cx, cy = int(p['x'] * w), int(p['y'] * h)
  92. cv2.circle(vis, (cx, cy), 30, (0, 0, 255), 4)
  93. cv2.putText(vis, f"P{i+1}", (cx + 40, cy),
  94. cv2.FONT_HERSHEY_SIMPLEX, 2.0, (0, 255, 0), 4)
  95. cv2.imwrite(str(output_path), vis)
  96. return output_path
  97. def save_ptz_shot(frame: np.ndarray, shots_dir: Path, latest_path: Path,
  98. point_idx: int, pan: float, tilt: float):
  99. """保存 PTZ 历史图和 latest_ptz.jpg"""
  100. history_path = shots_dir / f'P{point_idx:02d}_p{int(pan)}_t{int(tilt)}.jpg'
  101. cv2.imwrite(str(history_path), frame)
  102. cv2.imwrite(str(latest_path), frame)
  103. return history_path
  104. def generate_calibration(calib_points: list, output_path: Path):
  105. """从标定点生成 calibration_group1.json"""
  106. if len(calib_points) < 4:
  107. print(f"标定点不足 ({len(calib_points)} 个),无法生成校准文件")
  108. return False
  109. pan_entries = sorted([[p['x'], float(p['pan'])] for p in calib_points], key=lambda item: item[0])
  110. tilt_entries = sorted([[p['y'], float(p['tilt'])] for p in calib_points], key=lambda item: item[0])
  111. def merge_entries(entries):
  112. merged = []
  113. for x, v in entries:
  114. if merged and abs(merged[-1][0] - x) < 0.001:
  115. merged[-1] = [x, (merged[-1][1] + v) / 2]
  116. else:
  117. merged.append([x, v])
  118. return merged
  119. pan_lookup = merge_entries(pan_entries)
  120. tilt_lookup = merge_entries(tilt_entries)
  121. X = np.array([[1.0, p['x'], p['y']] for p in calib_points])
  122. pans = np.array([p['pan'] for p in calib_points])
  123. tilts = np.array([p['tilt'] for p in calib_points])
  124. pan_params, _, _, _ = np.linalg.lstsq(X, pans, rcond=None)
  125. tilt_params, _, _, _ = np.linalg.lstsq(X, tilts, rcond=None)
  126. total_err = 0.0
  127. for p in calib_points:
  128. pred_pan = pan_params[0] + pan_params[1]*p['x'] + pan_params[2]*p['y']
  129. pred_tilt = tilt_params[0] + tilt_params[1]*p['x'] + tilt_params[2]*p['y']
  130. err = ((pred_pan - p['pan'])**2 + (pred_tilt - p['tilt'])**2) ** 0.5
  131. total_err += err ** 2
  132. rms = (total_err / len(calib_points)) ** 0.5
  133. calibration = {
  134. 'pan_offset': float(pan_params[0]),
  135. 'pan_scale_x': float(pan_params[1]),
  136. 'pan_scale_y': float(pan_params[2]),
  137. 'tilt_offset': float(tilt_params[0]),
  138. 'tilt_scale_x': float(tilt_params[1]),
  139. 'tilt_scale_y': float(tilt_params[2]),
  140. 'rms_error': float(rms),
  141. 'pan_rms_error': 0.0,
  142. 'tilt_rms_error': 0.0,
  143. 'pan_lookup': pan_lookup,
  144. 'tilt_lookup': tilt_lookup,
  145. 'overlap_ranges': [{
  146. 'pan_start': 180.0,
  147. 'pan_end': 340.0,
  148. 'tilt_start': -35.0,
  149. 'tilt_end': 45.0,
  150. }],
  151. 'mount_type': 'wall',
  152. 'tilt_flip': False,
  153. 'pan_flip': False,
  154. 'generated_from': 'manual_interactive_calibration',
  155. 'note': 'x_ratio,y_ratio 为全景图归一化坐标;transform 输入归一化坐标',
  156. 'calib_points': calib_points,
  157. }
  158. with open(output_path, 'w', encoding='utf-8') as f:
  159. json.dump(calibration, f, indent=2, ensure_ascii=False)
  160. return True
  161. # Web UI HTML
  162. INDEX_HTML = r"""<!DOCTYPE html>
  163. <html lang="zh-CN">
  164. <head>
  165. <meta charset="utf-8">
  166. <meta name="viewport" content="width=device-width, initial-scale=1">
  167. <title>PTZ 手动标定</title>
  168. <style>
  169. * { box-sizing: border-box; }
  170. body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; margin: 0; padding: 16px; background: #0f172a; color: #e2e8f0; }
  171. h1 { margin: 0 0 12px; font-size: 22px; color: #38bdf8; }
  172. .status { background: #1e293b; border-radius: 10px; padding: 14px 18px; margin-bottom: 16px; }
  173. .status-row { display: flex; flex-wrap: wrap; gap: 18px; }
  174. .status-item { font-size: 15px; }
  175. .status-item span { color: #94a3b8; }
  176. .images { display: grid; grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr); gap: 16px; margin-bottom: 16px; }
  177. .img-box { background: #1e293b; border-radius: 10px; padding: 12px; }
  178. .img-box h3 { margin: 0 0 10px; font-size: 15px; color: #94a3b8; }
  179. .img-box img { width: 100%; border-radius: 6px; background: #0b1120; }
  180. .controls { background: #1e293b; border-radius: 10px; padding: 16px; }
  181. .control-group { margin-bottom: 14px; }
  182. .control-group label { display: block; font-size: 13px; color: #94a3b8; margin-bottom: 8px; }
  183. .btn-row { display: flex; flex-wrap: wrap; gap: 8px; }
  184. button { border: none; border-radius: 6px; padding: 10px 16px; font-size: 14px; cursor: pointer; transition: transform .05s; }
  185. button:active { transform: scale(0.96); }
  186. .btn-pan { background: #3b82f6; color: white; }
  187. .btn-tilt { background: #8b5cf6; color: white; }
  188. .btn-ok { background: #22c55e; color: white; font-weight: bold; padding: 12px 28px; }
  189. .btn-skip { background: #f59e0b; color: white; }
  190. .btn-quit { background: #ef4444; color: white; }
  191. .message { margin-top: 12px; padding: 10px; border-radius: 6px; background: #0f172a; font-size: 14px; min-height: 20px; }
  192. .message.waiting { color: #facc15; }
  193. .message.done { color: #4ade80; }
  194. @media (max-width: 900px) { .images { grid-template-columns: 1fr; } }
  195. </style>
  196. </head>
  197. <body>
  198. <h1>🔭 PTZ-全景手动标定</h1>
  199. <div class="status">
  200. <div class="status-row">
  201. <div class="status-item"><span>当前点:</span><strong id="point">- / -</strong></div>
  202. <div class="status-item"><span>PAN:</span><strong id="pan">-</strong>°</div>
  203. <div class="status-item"><span>TILT:</span><strong id="tilt">-</strong>°</div>
  204. <div class="status-item"><span>状态:</span><strong id="status">连接中...</strong></div>
  205. </div>
  206. </div>
  207. <div class="images">
  208. <div class="img-box">
  209. <h3>实时 PTZ 画面</h3>
  210. <img id="ptz-img" src="ptz_shots/latest_ptz.jpg" alt="ptz">
  211. </div>
  212. <div class="img-box">
  213. <h3>全景标定点标记</h3>
  214. <img id="pano-img" src="panorama_marks.jpg" alt="panorama">
  215. </div>
  216. </div>
  217. <div class="controls">
  218. <div class="control-group">
  219. <label>水平方向 (Pan)</label>
  220. <div class="btn-row">
  221. <button class="btn-pan" onclick="sendCmd('p-10')">← 10°</button>
  222. <button class="btn-pan" onclick="sendCmd('p-5')">← 5°</button>
  223. <button class="btn-pan" onclick="sendCmd('p-3')">← 3°</button>
  224. <button class="btn-pan" onclick="sendCmd('p+3')">3° →</button>
  225. <button class="btn-pan" onclick="sendCmd('p+5')">5° →</button>
  226. <button class="btn-pan" onclick="sendCmd('p+10')">10° →</button>
  227. </div>
  228. </div>
  229. <div class="control-group">
  230. <label>垂直方向 (Tilt)</label>
  231. <div class="btn-row">
  232. <button class="btn-tilt" onclick="sendCmd('t-10')">↓ 10°</button>
  233. <button class="btn-tilt" onclick="sendCmd('t-5')">↓ 5°</button>
  234. <button class="btn-tilt" onclick="sendCmd('t-3')">↓ 3°</button>
  235. <button class="btn-tilt" onclick="sendCmd('t+3')">↑ 3°</button>
  236. <button class="btn-tilt" onclick="sendCmd('t+5')">↑ 5°</button>
  237. <button class="btn-tilt" onclick="sendCmd('t+10')">↑ 10°</button>
  238. </div>
  239. </div>
  240. <div class="control-group">
  241. <label>操作</label>
  242. <div class="btn-row">
  243. <button class="btn-ok" onclick="sendCmd('ok')">✓ 确认该点</button>
  244. <button class="btn-skip" onclick="sendCmd('skip')">跳过该点</button>
  245. <button class="btn-quit" onclick="sendCmd('quit')">退出标定</button>
  246. </div>
  247. </div>
  248. <div class="message" id="message">等待服务器连接...</div>
  249. </div>
  250. <script>
  251. function sendCmd(cmd) {
  252. fetch('/api/command', {
  253. method: 'POST',
  254. headers: {'Content-Type': 'application/json'},
  255. body: JSON.stringify({command: cmd})
  256. }).then(r => r.json()).then(data => {
  257. updateStatus(data);
  258. }).catch(err => {
  259. document.getElementById('message').textContent = '发送失败: ' + err;
  260. });
  261. }
  262. function updateStatus(data) {
  263. document.getElementById('point').textContent = data.point_idx + ' / ' + data.total_points;
  264. document.getElementById('pan').textContent = data.pan.toFixed(1);
  265. document.getElementById('tilt').textContent = data.tilt.toFixed(1);
  266. document.getElementById('status').textContent = data.status_text;
  267. document.getElementById('message').textContent = data.message;
  268. document.getElementById('message').className = 'message ' + (data.status === 'waiting' ? 'waiting' : '');
  269. const ts = data.image_ts || Date.now();
  270. document.getElementById('ptz-img').src = 'ptz_shots/latest_ptz.jpg?t=' + ts;
  271. }
  272. async function poll() {
  273. try {
  274. const res = await fetch('/api/status');
  275. const data = await res.json();
  276. updateStatus(data);
  277. } catch (e) {
  278. document.getElementById('message').textContent = '状态获取失败: ' + e;
  279. }
  280. }
  281. setInterval(poll, 1200);
  282. setInterval(() => {
  283. const img = document.getElementById('ptz-img');
  284. img.src = 'ptz_shots/latest_ptz.jpg?t=' + Date.now();
  285. }, 1500);
  286. poll();
  287. </script>
  288. </body>
  289. </html>
  290. """
  291. class CalibHTTPHandler(http.server.SimpleHTTPRequestHandler):
  292. """自定义 HTTP Handler,支持 API 路由"""
  293. def __init__(self, *args, directory=None, **kwargs):
  294. self.directory = directory
  295. super().__init__(*args, directory=str(directory), **kwargs)
  296. def log_message(self, format, *args):
  297. pass
  298. def do_GET(self):
  299. parsed = urlparse(self.path)
  300. path = parsed.path
  301. if path == '/' or path == '/index.html':
  302. self.send_html(INDEX_HTML)
  303. elif path == '/api/status':
  304. self.send_json(calib_state)
  305. else:
  306. # 静态文件(图片等)
  307. super().do_GET()
  308. def do_POST(self):
  309. parsed = urlparse(self.path)
  310. if parsed.path == '/api/command':
  311. content_length = int(self.headers.get('Content-Length', 0))
  312. body = self.rfile.read(content_length).decode('utf-8')
  313. try:
  314. data = json.loads(body)
  315. cmd = data.get('command', '').strip().lower()
  316. if cmd:
  317. cmd_queue.put(cmd)
  318. calib_state['message'] = f'收到指令: {cmd},执行中...'
  319. self.send_json({'ok': True, 'command': cmd})
  320. except Exception as e:
  321. self.send_json({'ok': False, 'error': str(e)})
  322. else:
  323. self.send_response(404)
  324. self.end_headers()
  325. def send_html(self, html: str):
  326. self.send_response(200)
  327. self.send_header('Content-Type', 'text/html; charset=utf-8')
  328. self.send_header('Cache-Control', 'no-cache')
  329. self.end_headers()
  330. self.wfile.write(html.encode('utf-8'))
  331. def send_json(self, data: dict):
  332. self.send_response(200)
  333. self.send_header('Content-Type', 'application/json; charset=utf-8')
  334. self.send_header('Cache-Control', 'no-cache')
  335. self.end_headers()
  336. self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
  337. def start_http_server(directory: Path, port: int = 8000):
  338. """启动后台 HTTP 服务"""
  339. def handler_factory(*args, **kwargs):
  340. return CalibHTTPHandler(*args, directory=directory, **kwargs)
  341. socketserver.TCPServer.allow_reuse_address = True
  342. httpd = socketserver.TCPServer(("", port), handler_factory)
  343. httpd.allow_reuse_address = True
  344. thread = threading.Thread(target=httpd.serve_forever, daemon=True)
  345. thread.start()
  346. return httpd
  347. def update_state(point_idx=0, total_points=0, pan=0.0, tilt=0.0, zoom=1,
  348. note='', status='initializing', message=''):
  349. calib_state['point_idx'] = point_idx
  350. calib_state['total_points'] = total_points
  351. calib_state['pan'] = pan
  352. calib_state['tilt'] = tilt
  353. calib_state['zoom'] = zoom
  354. calib_state['note'] = note
  355. calib_state['status'] = status
  356. calib_state['image_ts'] = int(time.time() * 1000)
  357. status_map = {
  358. 'initializing': '初始化',
  359. 'moving': '移动中',
  360. 'waiting': '等待确认',
  361. 'finished': '已完成',
  362. 'error': '错误',
  363. }
  364. calib_state['status_text'] = status_map.get(status, status)
  365. calib_state['message'] = message
  366. def wait_for_command(timeout: float = 0.5) -> str:
  367. """从队列等待命令,支持优雅退出"""
  368. while calib_state['running']:
  369. try:
  370. return cmd_queue.get(timeout=timeout)
  371. except queue.Empty:
  372. continue
  373. return 'quit'
  374. def main():
  375. parser = argparse.ArgumentParser()
  376. parser.add_argument('--points', default='points.txt', help='标定点文件')
  377. parser.add_argument('--output', default='/home/admin/dsh/calibration_scan_180_360_z1/manual_calib', help='输出目录')
  378. parser.add_argument('--stabilize', type=float, default=1.5, help='移动后稳定秒数')
  379. parser.add_argument('--http-port', type=int, default=8000, help='HTTP 预览服务端口')
  380. args = parser.parse_args()
  381. calib_state['http_port'] = args.http_port
  382. output_dir = Path(args.output)
  383. output_dir.mkdir(parents=True, exist_ok=True)
  384. shots_dir = output_dir / 'ptz_shots'
  385. shots_dir.mkdir(exist_ok=True)
  386. latest_path = shots_dir / 'latest_ptz.jpg'
  387. # 加载标定点
  388. points = load_points(args.points)
  389. if not points:
  390. print(f"未找到 {args.points},使用默认标定点")
  391. points = [
  392. {'x': 0.2, 'y': 0.5, 'note': '左侧'},
  393. {'x': 0.4, 'y': 0.5, 'note': '左中'},
  394. {'x': 0.6, 'y': 0.5, 'note': '右中'},
  395. {'x': 0.8, 'y': 0.5, 'note': '右侧'},
  396. {'x': 0.5, 'y': 0.3, 'note': '中上'},
  397. {'x': 0.5, 'y': 0.7, 'note': '中下'},
  398. {'x': 0.5, 'y': 0.9, 'note': '底部'},
  399. ]
  400. with open(output_dir / 'points_default.txt', 'w') as f:
  401. for p in points:
  402. f.write(f"{p['x']} {p['y']} {p['note']}\n")
  403. else:
  404. print(f"从 {args.points} 加载了 {len(points)} 个标定点")
  405. update_state(total_points=len(points), message='抓取全景图...')
  406. # 抓取全景图
  407. print("抓取全景图...")
  408. pano_url = 'rtsp://admin:Aa1234567@192.168.20.196:554/cam/realmonitor?channel=1&subtype=0'
  409. cap = cv2.VideoCapture(pano_url, cv2.CAP_FFMPEG)
  410. cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
  411. panorama = None
  412. for _ in range(30):
  413. ret, frame = cap.read()
  414. if ret and frame is not None:
  415. panorama = frame
  416. break
  417. time.sleep(0.1)
  418. cap.release()
  419. if panorama is None:
  420. print("无法获取全景图")
  421. update_state(status='error', message='无法获取全景图')
  422. return 1
  423. cv2.imwrite(str(output_dir / 'panorama.jpg'), panorama)
  424. save_panorama_with_marks(panorama, points, output_dir / 'panorama_marks.jpg')
  425. # 启动 HTTP 服务
  426. try:
  427. httpd = start_http_server(output_dir, port=args.http_port)
  428. print(f"\nHTTP 预览服务已启动: http://<rk3588-ip>:{args.http_port}/")
  429. except OSError as e:
  430. print(f"启动 HTTP 服务失败 (端口 {args.http_port}): {e}")
  431. httpd = None
  432. return 1
  433. # 初始化 SDK
  434. sdk = DahuaSDK(lib_path='/home/admin/dsh/dh/arm/Bin/libdhnetsdk.so')
  435. if not sdk.init():
  436. print("SDK 初始化失败")
  437. return 1
  438. try:
  439. ptz_config = {
  440. 'ip': '192.168.20.197',
  441. 'port': 37777,
  442. 'rtsp_port': 554,
  443. 'username': 'admin',
  444. 'password': 'Aa1234567',
  445. 'channel': 0,
  446. 'rtsp_url': 'rtsp://admin:Aa1234567@192.168.20.197:554/cam/realmonitor?channel=1&subtype=1',
  447. }
  448. ptz = PTZCamera(sdk, ptz_config)
  449. if not ptz.connect():
  450. print("连接球机失败")
  451. return 1
  452. if not ptz.start_stream_rtsp():
  453. print("启动球机 RTSP 失败")
  454. return 1
  455. calib_points = []
  456. for i, p in enumerate(points):
  457. if not calib_state['running']:
  458. break
  459. print(f"\n标定点 {i+1}/{len(points)}: ({p['x']:.3f}, {p['y']:.3f}) {p.get('note', '')}")
  460. init_pan, init_tilt = estimate_initial_ptz(p['x'], p['y'])
  461. pan, tilt = init_pan, init_tilt
  462. update_state(point_idx=i+1, total_points=len(points), pan=pan, tilt=tilt,
  463. note=p.get('note', ''), status='moving',
  464. message=f'移动到 P{i+1} 初始位置...')
  465. while True:
  466. if not calib_state['running']:
  467. break
  468. update_state(point_idx=i+1, total_points=len(points), pan=pan, tilt=tilt,
  469. note=p.get('note', ''), status='moving',
  470. message=f'移动到 pan={pan:.1f}, tilt={tilt:.1f}...')
  471. print(f"移动到 pan={pan:.1f}, tilt={tilt:.1f}, zoom=1")
  472. if not ptz.goto_exact_position(pan, tilt, 1):
  473. print("移动失败,重试...")
  474. time.sleep(1)
  475. continue
  476. time.sleep(args.stabilize)
  477. frame = capture_ptz_frame(ptz)
  478. if frame is None:
  479. print("抓拍失败,重试...")
  480. continue
  481. save_ptz_shot(frame, shots_dir, latest_path, i + 1, pan, tilt)
  482. update_state(point_idx=i+1, total_points=len(points), pan=pan, tilt=tilt,
  483. note=p.get('note', ''), status='waiting',
  484. message=f'P{i+1} 已就位,请在浏览器调整并确认')
  485. print("已抓拍,等待 Web UI 指令...")
  486. cmd = wait_for_command()
  487. print(f"收到指令: {cmd}")
  488. if cmd == 'ok':
  489. calib_points.append({
  490. 'x': p['x'],
  491. 'y': p['y'],
  492. 'pan': pan,
  493. 'tilt': tilt,
  494. 'zoom': 1,
  495. 'note': p.get('note', '')
  496. })
  497. update_state(point_idx=i+1, total_points=len(points), pan=pan, tilt=tilt,
  498. note=p.get('note', ''), status='moving',
  499. message=f'P{i+1} 已记录,进入下一点')
  500. print(f"记录标定点: ({p['x']:.3f}, {p['y']:.3f}) -> ({pan:.1f}, {tilt:.1f})")
  501. time.sleep(0.5)
  502. break
  503. elif cmd == 'skip':
  504. update_state(point_idx=i+1, total_points=len(points), pan=pan, tilt=tilt,
  505. note=p.get('note', ''), status='moving',
  506. message=f'跳过 P{i+1},进入下一点')
  507. print("跳过这个点")
  508. break
  509. elif cmd == 'quit':
  510. print("退出标定")
  511. calib_state['running'] = False
  512. raise KeyboardInterrupt
  513. elif cmd.startswith('p') or cmd.startswith('t'):
  514. try:
  515. delta = float(cmd[1:])
  516. if cmd.startswith('p'):
  517. pan += delta
  518. pan = pan % 360
  519. else:
  520. tilt += delta
  521. tilt = max(-90, min(90, tilt))
  522. update_state(point_idx=i+1, total_points=len(points), pan=pan, tilt=tilt,
  523. note=p.get('note', ''), status='moving',
  524. message=f'调整至 pan={pan:.1f}, tilt={tilt:.1f}')
  525. print(f"调整: pan={pan:.1f}, tilt={tilt:.1f}")
  526. except ValueError:
  527. update_state(message='指令格式错误,请使用按钮')
  528. print("指令格式错误")
  529. else:
  530. update_state(message=f'未知指令: {cmd}')
  531. print("未知指令")
  532. # 保存中间结果
  533. with open(output_dir / 'manual_points.json', 'w', encoding='utf-8') as f:
  534. json.dump({
  535. 'points': calib_points,
  536. 'time': datetime.now().isoformat(),
  537. }, f, indent=2, ensure_ascii=False)
  538. # 生成校准文件
  539. if len(calib_points) >= 4:
  540. generate_calibration(calib_points, output_dir / 'calibration_group1_manual.json')
  541. update_state(status='finished',
  542. message=f'标定完成,{len(calib_points)} 个有效点,已生成校准文件')
  543. print(f"\n标定完成,共 {len(calib_points)} 个有效点")
  544. print(f"校准文件: {output_dir / 'calibration_group1_manual.json'}")
  545. else:
  546. update_state(status='error',
  547. message=f'有效标定点不足 ({len(calib_points)} 个)')
  548. print(f"\n有效标定点不足 ({len(calib_points)} 个),未生成校准文件")
  549. ptz.stop_stream()
  550. ptz.disconnect()
  551. except KeyboardInterrupt:
  552. print("\n标定被中断")
  553. finally:
  554. sdk.cleanup()
  555. if httpd is not None:
  556. httpd.shutdown()
  557. print("HTTP 服务已关闭")
  558. return 0
  559. if __name__ == '__main__':
  560. sys.exit(main())