ptz_camera.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620
  1. """
  2. 球机(PTZ)控制模块
  3. 负责PTZ控制、精确定位和视频流获取
  4. """
  5. import os
  6. # 必须在导入cv2之前设置,防止FFmpeg多线程解码崩溃
  7. os.environ['OPENCV_FFMPEG_CAPTURE_OPTIONS'] = 'rtsp_transport;tcp|threads;1'
  8. import math
  9. import time
  10. import threading
  11. import queue
  12. from typing import Optional, Tuple, Dict
  13. from dataclasses import dataclass
  14. import cv2
  15. import numpy as np
  16. from config import PTZ_CAMERA, PTZ_CONFIG
  17. from core.calibration import CalibrationMapper
  18. from dahua_sdk import DahuaSDK, PTZCommand
  19. from video_lock import safe_read, safe_is_opened
  20. @dataclass
  21. class PTZPosition:
  22. """PTZ位置信息"""
  23. pan: float # 水平角度 (0-360度)
  24. tilt: float # 垂直角度 (-90到90度)
  25. zoom: float # 变倍 (1-最大倍数)
  26. class PTZCamera:
  27. """球机控制类"""
  28. def __init__(self, sdk: DahuaSDK, camera_config: Dict = None):
  29. """
  30. 初始化球机
  31. Args:
  32. sdk: 大华SDK实例
  33. camera_config: 摄像头配置
  34. """
  35. self.sdk = sdk
  36. self.config = camera_config or PTZ_CAMERA
  37. # 全局 PTZ 配置作为默认值,camera_config 中的同名 key 可覆盖
  38. self.ptz_config = dict(PTZ_CONFIG)
  39. if camera_config:
  40. for key in ['mount_type', 'pan_flip', 'tilt_flip', 'coordinate_offset',
  41. 'tilt_offset', 'pan_offset', 'pan_edge_offset', 'pan_curve_power',
  42. 'tilt_linear_enabled', 'tilt_y0', 'tilt_y1', 'tilt_curve_power',
  43. 'pan_range', 'tilt_range', 'pan_center', 'tilt_center',
  44. 'overlap_pan_range', 'overlap_tilt_range',
  45. 'overlap_pan_step', 'overlap_tilt_step']:
  46. if key in camera_config:
  47. self.ptz_config[key] = camera_config[key]
  48. self.login_handle = None
  49. self.connected = False
  50. # 当前位置
  51. self.current_position = PTZPosition(pan=0, tilt=0, zoom=1)
  52. self.position_lock = threading.Lock()
  53. # 视频流 (用于校准时抓拍球机画面)
  54. self.rtsp_cap = None
  55. self._rtsp_lock = threading.Lock()
  56. self.current_frame = None
  57. self.frame_lock = threading.Lock()
  58. self.stream_thread = None
  59. self.running_stream = False
  60. self._camera_id = 'ptz' # 用于per-camera锁
  61. # 加载校准映射(如果配置中提供了校准文件)
  62. calibration_path = self.config.get('calibration_file') or camera_config.get('calibration_file') if camera_config else None
  63. self.calibration = CalibrationMapper(calibration_path, self.ptz_config)
  64. def connect(self) -> bool:
  65. """
  66. 连接球机
  67. Returns:
  68. 是否成功
  69. """
  70. print(f"[PTZCamera] 正在连接球机: IP={self.config['ip']}:{self.config['port']}, 通道={self.config['channel']}")
  71. login_handle, error = self.sdk.login(
  72. self.config['ip'],
  73. self.config['port'],
  74. self.config['username'],
  75. self.config['password']
  76. )
  77. if login_handle is None:
  78. print(f"[PTZCamera] 连接球机失败: IP={self.config['ip']}, 错误码={error}")
  79. print(f"[PTZCamera] 请检查: 1)IP地址是否正确 2)网络是否连通 3)用户名密码是否正确")
  80. return False
  81. self.login_handle = login_handle
  82. self.connected = True
  83. print(f"[PTZCamera] 成功连接球机: {self.config['ip']}, handle={login_handle}")
  84. print(f"[PTZCamera] 配置: 通道={self.config['channel']}, 默认变倍={self.ptz_config['default_zoom']}")
  85. return True
  86. def disconnect(self):
  87. """断开连接"""
  88. self.stop_stream()
  89. if self.login_handle:
  90. self.sdk.logout(self.login_handle)
  91. self.login_handle = None
  92. self.connected = False
  93. def is_connected(self) -> bool:
  94. """是否已连接"""
  95. return self.connected
  96. def start_stream_rtsp(self, rtsp_url: str = None) -> bool:
  97. """启动RTSP视频流 (用于校准时获取球机画面)"""
  98. if rtsp_url is None:
  99. rtsp_url = self.config.get('rtsp_url') or \
  100. f"rtsp://{self.config['username']}:{self.config['password']}@{self.config['ip']}:{self.config.get('rtsp_port', 554)}/cam/realmonitor?channel=1&subtype=1"
  101. try:
  102. # 先尝试FFmpeg后端
  103. new_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
  104. if not new_cap.isOpened():
  105. # FFmpeg失败,尝试GStreamer后端(ARM64上更稳定)
  106. print(f"[PTZCamera] FFmpeg后端无法打开RTSP流,尝试GStreamer后端...")
  107. try:
  108. gst_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_GSTREAMER)
  109. if gst_cap.isOpened():
  110. new_cap = gst_cap
  111. print(f"[PTZCamera] 使用GStreamer后端打开RTSP流成功")
  112. else:
  113. print(f"[PTZCamera] 无法打开RTSP流: {rtsp_url}")
  114. return False
  115. except Exception as ge:
  116. print(f"[PTZCamera] GStreamer后端也不可用: {ge}")
  117. return False
  118. new_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
  119. with self._rtsp_lock:
  120. self.rtsp_cap = new_cap
  121. self.running_stream = True
  122. self.stream_thread = threading.Thread(target=self._stream_worker, daemon=True)
  123. self.stream_thread.start()
  124. print(f"[PTZCamera] RTSP视频流已启动")
  125. return True
  126. except Exception as e:
  127. print(f"[PTZCamera] RTSP流启动失败: {e}")
  128. return False
  129. def _stream_worker(self):
  130. """视频流工作线程"""
  131. import signal
  132. if hasattr(signal, 'pthread_sigmask'):
  133. try:
  134. signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGINT})
  135. except (AttributeError, OSError):
  136. pass
  137. max_consecutive_errors = 50
  138. error_count = 0
  139. while self.running_stream:
  140. try:
  141. with self._rtsp_lock:
  142. cap = self.rtsp_cap
  143. if cap is None or not safe_is_opened(cap, self._camera_id):
  144. time.sleep(0.1)
  145. continue
  146. ret, frame = safe_read(cap, self._camera_id)
  147. if not ret or frame is None:
  148. error_count += 1
  149. if error_count > max_consecutive_errors:
  150. print("[PTZCamera] RTSP流连续读取失败,尝试重连...")
  151. self._reconnect_rtsp()
  152. error_count = 0
  153. time.sleep(0.01)
  154. continue
  155. error_count = 0
  156. with self.frame_lock:
  157. self.current_frame = frame.copy()
  158. time.sleep(0.001)
  159. except Exception as e:
  160. err_str = str(e)
  161. if 'async_lock' in err_str or 'Assertion' in err_str:
  162. print(f"[PTZCamera] FFmpeg内部错误,3秒后重建连接: {e}")
  163. time.sleep(3)
  164. self._reconnect_rtsp()
  165. else:
  166. print(f"[PTZCamera] 视频流错误: {e}")
  167. time.sleep(0.5)
  168. def _reconnect_rtsp(self):
  169. rtsp_url = self.config.get('rtsp_url') or \
  170. f"rtsp://{self.config['username']}:{self.config['password']}@{self.config['ip']}:{self.config.get('rtsp_port', 554)}/cam/realmonitor?channel=1&subtype=1"
  171. with self._rtsp_lock:
  172. if self.rtsp_cap is not None:
  173. try:
  174. self.rtsp_cap.release()
  175. except Exception:
  176. pass
  177. self.rtsp_cap = None
  178. time.sleep(1)
  179. try:
  180. new_cap = cv2.VideoCapture(rtsp_url, cv2.CAP_FFMPEG)
  181. if safe_is_opened(new_cap, self._camera_id):
  182. new_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
  183. with self._rtsp_lock:
  184. self.rtsp_cap = new_cap
  185. print("[PTZCamera] RTSP流重连成功")
  186. else:
  187. print("[PTZCamera] RTSP流重连失败")
  188. try:
  189. new_cap.release()
  190. except Exception:
  191. pass
  192. except Exception as e:
  193. print(f"[PTZCamera] RTSP流重连异常: {e}")
  194. def get_frame(self) -> Optional[np.ndarray]:
  195. """获取球机当前帧"""
  196. with self.frame_lock:
  197. return self.current_frame.copy() if self.current_frame is not None else None
  198. def stop_stream(self):
  199. """停止视频流"""
  200. self.running_stream = False
  201. if self.stream_thread:
  202. self.stream_thread.join(timeout=2)
  203. self.stream_thread = None
  204. with self._rtsp_lock:
  205. if self.rtsp_cap:
  206. self.rtsp_cap.release()
  207. self.rtsp_cap = None
  208. def ptz_control(self, command: int, param1: int = 0, param2: int = 0,
  209. param3: int = 0, stop: bool = False) -> bool:
  210. """
  211. PTZ控制
  212. Args:
  213. command: 控制命令
  214. param1-3: 参数
  215. stop: 是否停止
  216. Returns:
  217. 是否成功
  218. """
  219. if not self.connected:
  220. print(f"[PTZCamera] PTZ控制失败: 未连接球机")
  221. return False
  222. if self.login_handle is None or self.login_handle <= 0:
  223. print(f"[PTZCamera] PTZ控制失败: 登录句柄无效 (handle={self.login_handle})")
  224. return False
  225. return self.sdk.ptz_control(
  226. self.login_handle,
  227. self.config['channel'],
  228. command, param1, param2, param3, stop
  229. )
  230. def move_up(self, speed: int = 4, stop: bool = False) -> bool:
  231. """向上移动"""
  232. return self.ptz_control(PTZCommand.UP, 0, speed, 0, stop)
  233. def move_down(self, speed: int = 4, stop: bool = False) -> bool:
  234. """向下移动"""
  235. return self.ptz_control(PTZCommand.DOWN, 0, speed, 0, stop)
  236. def move_left(self, speed: int = 4, stop: bool = False) -> bool:
  237. """向左移动"""
  238. return self.ptz_control(PTZCommand.LEFT, 0, speed, 0, stop)
  239. def move_right(self, speed: int = 4, stop: bool = False) -> bool:
  240. """向右移动"""
  241. return self.ptz_control(PTZCommand.RIGHT, 0, speed, 0, stop)
  242. def zoom_in(self, speed: int = 4, stop: bool = False) -> bool:
  243. """放大"""
  244. return self.ptz_control(PTZCommand.ZOOM_ADD, 0, speed, 0, stop)
  245. def zoom_out(self, speed: int = 4, stop: bool = False) -> bool:
  246. """缩小"""
  247. return self.ptz_control(PTZCommand.ZOOM_DEC, 0, speed, 0, stop)
  248. def stop_move(self) -> bool:
  249. """停止移动"""
  250. # 发送停止命令
  251. self.move_up(0, True)
  252. self.move_left(0, True)
  253. self.zoom_in(0, True)
  254. return True
  255. def _apply_orientation_correction(self, pan: float, tilt: float) -> Tuple[float, float]:
  256. """根据安装方式/翻转配置,把视觉坐标转换为发送给 SDK 的物理坐标。"""
  257. mount_type = self.ptz_config.get('mount_type', 'wall')
  258. tilt_flip = self.ptz_config.get('tilt_flip', False)
  259. pan_flip = self.ptz_config.get('pan_flip', False)
  260. # 吸顶安装或显式 tilt_flip 时反转 tilt
  261. if mount_type == 'ceiling' or tilt_flip:
  262. tilt = -tilt
  263. print(f"[PTZCamera] tilt方向修正: {-tilt} -> {tilt}")
  264. # pan_flip 时水平方向旋转 180°
  265. if pan_flip:
  266. pan = (pan + 180) % 360
  267. print(f"[PTZCamera] pan方向翻转: {(pan - 180) % 360} -> {pan}")
  268. return pan, tilt
  269. def goto_exact_position(self, pan: float, tilt: float, zoom: int) -> bool:
  270. """
  271. 三维精确定位
  272. Args:
  273. pan: 水平角度 (0-360度,视觉坐标)
  274. tilt: 垂直角度 (-90到90度,视觉坐标)
  275. zoom: 变倍 (1-128)
  276. Returns:
  277. 是否成功
  278. """
  279. # 保存视觉坐标,SDK 发送前根据安装方向做修正
  280. visual_pan, visual_tilt = pan, tilt
  281. physical_pan, physical_tilt = self._apply_orientation_correction(pan, tilt)
  282. param1 = int(physical_pan * 10)
  283. param2 = int(physical_tilt * 10)
  284. param3 = int(min(zoom, 128))
  285. print(f"[PTZCamera] goto_exact_position: visual=({visual_pan:.1f}°, {visual_tilt:.1f}°) "
  286. f"physical=({physical_pan:.1f}°, {physical_tilt:.1f}°) zoom={zoom} "
  287. f"→ p1={param1} p2={param2} p3={param3}")
  288. result = self.ptz_control(PTZCommand.EXACTGOTO, param1, param2, param3)
  289. if result:
  290. with self.position_lock:
  291. self.current_position = PTZPosition(pan=visual_pan, tilt=visual_tilt, zoom=zoom)
  292. else:
  293. print(f"[PTZCamera] goto_exact_position FAILED!")
  294. return result
  295. def goto_preset(self, preset_id: int) -> bool:
  296. """
  297. 转到预置点
  298. Args:
  299. preset_id: 预置点ID
  300. Returns:
  301. 是否成功
  302. """
  303. return self.ptz_control(PTZCommand.POINT_GO, 0, preset_id, 0)
  304. def set_preset(self, preset_id: int) -> bool:
  305. """
  306. 设置预置点
  307. Args:
  308. preset_id: 预置点ID
  309. Returns:
  310. 是否成功
  311. """
  312. return self.ptz_control(PTZCommand.POINT_SET, 0, preset_id, 0)
  313. def clear_preset(self, preset_id: int) -> bool:
  314. """
  315. 清除预置点
  316. Args:
  317. preset_id: 预置点ID
  318. Returns:
  319. 是否成功
  320. """
  321. return self.ptz_control(PTZCommand.POINT_CLEAR, 0, preset_id, 0)
  322. def calculate_ptz_position(self, x_ratio: float, y_ratio: float,
  323. zoom: int = None) -> Tuple[float, float, int]:
  324. """
  325. 根据全景画面中的位置计算PTZ角度
  326. Args:
  327. x_ratio: X方向比例 (0-1)
  328. y_ratio: Y方向比例 (0-1)
  329. zoom: 变倍 (默认使用配置值)
  330. Returns:
  331. (pan, tilt, zoom) PTZ位置
  332. """
  333. if zoom is None:
  334. zoom = self.ptz_config['default_zoom']
  335. # 应用坐标偏移校准
  336. offset_x, offset_y = self.ptz_config['coordinate_offset']
  337. x_ratio = max(0, min(1, x_ratio + offset_x))
  338. y_ratio = max(0, min(1, y_ratio + offset_y))
  339. # 从配置获取视野参数
  340. pan_range = self.ptz_config.get('pan_range', (0, 180))
  341. tilt_range = self.ptz_config.get('tilt_range', (-45, 45))
  342. pan_center = self.ptz_config.get('pan_center', 90)
  343. tilt_center = self.ptz_config.get('tilt_center', 0)
  344. # 将画面比例转换为角度(视觉坐标,翻转统一在 goto_exact_position 处理)
  345. # x_ratio=0 对应 pan_range[0], x_ratio=1 对应 pan_range[1]
  346. pan = pan_range[0] + (pan_range[1] - pan_range[0]) * x_ratio
  347. # y_ratio=0.5 对应 tilt_center, y_ratio=0 对应 tilt_range[0], y_ratio=1 对应 tilt_range[1]
  348. tilt = tilt_range[0] + (tilt_range[1] - tilt_range[0]) * y_ratio
  349. return (pan, tilt, zoom)
  350. def move_to_target(self, x_ratio: float, y_ratio: float,
  351. zoom: int = None) -> bool:
  352. """
  353. 移动到目标位置
  354. Args:
  355. x_ratio: X方向比例 (0-1)
  356. y_ratio: Y方向比例 (0-1)
  357. zoom: 变倍
  358. Returns:
  359. 是否成功
  360. """
  361. pan, tilt, zoom = self.calculate_ptz_position(x_ratio, y_ratio, zoom)
  362. return self.goto_exact_position(pan, tilt, zoom)
  363. def track_target(self, x_ratio: float, y_ratio: float,
  364. zoom: int = None) -> bool:
  365. """
  366. 跟踪目标 - 与move_to_target相同,但可以添加跟踪特定逻辑
  367. Args:
  368. x_ratio: X方向比例
  369. y_ratio: Y方向比例
  370. zoom: 变倍
  371. Returns:
  372. 是否成功
  373. """
  374. return self.move_to_target(x_ratio, y_ratio, zoom)
  375. def get_current_position(self) -> PTZPosition:
  376. """获取当前位置"""
  377. with self.position_lock:
  378. return PTZPosition(
  379. pan=self.current_position.pan,
  380. tilt=self.current_position.tilt,
  381. zoom=self.current_position.zoom
  382. )
  383. def goto_and_confirm(self, pan: float, tilt: float, zoom: int,
  384. confirm_timeout: float = 1.0,
  385. get_frame_func=None) -> dict:
  386. """
  387. PTZ精确定位并确认到位
  388. Args:
  389. pan, tilt, zoom: 目标位置
  390. confirm_timeout: 确认超时秒数
  391. get_frame_func: 获取球机帧的函数,用于验证
  392. Returns:
  393. dict: {'success': bool, 'pan': float, 'tilt': float, 'zoom': int,
  394. 'frame_available': bool, 'elapsed_ms': float}
  395. """
  396. import time as _time
  397. start = _time.time()
  398. success = self.goto_exact_position(pan, tilt, zoom)
  399. result = {
  400. 'success': success,
  401. 'pan': pan,
  402. 'tilt': tilt,
  403. 'zoom': zoom,
  404. 'frame_available': False,
  405. 'elapsed_ms': (_time.time() - start) * 1000
  406. }
  407. if not success:
  408. return result
  409. # 等待球机物理移动到位
  410. time.sleep(0.2)
  411. # 如果有帧获取函数,验证球机画面
  412. if get_frame_func is not None:
  413. deadline = _time.time() + confirm_timeout
  414. while _time.time() < deadline:
  415. frame = get_frame_func()
  416. if frame is not None:
  417. result['frame_available'] = True
  418. break
  419. time.sleep(0.05)
  420. result['elapsed_ms'] = (_time.time() - start) * 1000
  421. return result
  422. def is_position_close(self, target_pan: float, target_tilt: float,
  423. threshold: float = 1.0) -> bool:
  424. """
  425. 检查当前位置是否接近目标位置
  426. Args:
  427. target_pan: 目标水平角度
  428. target_tilt: 目标垂直角度
  429. threshold: 角度容差(度)
  430. """
  431. current = self.get_current_position()
  432. pan_diff = abs(current.pan - target_pan)
  433. tilt_diff = abs(current.tilt - target_tilt)
  434. return pan_diff <= threshold and tilt_diff <= threshold
  435. class PTZController:
  436. """
  437. PTZ高级控制器
  438. 提供平滑移动、跟踪等功能
  439. """
  440. def __init__(self, ptz_camera: PTZCamera):
  441. """
  442. 初始化控制器
  443. Args:
  444. ptz_camera: PTZ摄像头实例
  445. """
  446. self.ptz = ptz_camera
  447. self.tracking = False
  448. self.tracking_thread = None
  449. self.target_position = None
  450. def smooth_move_to(self, pan: float, tilt: float, zoom: int,
  451. steps: int = 10, delay: float = 0.1) -> bool:
  452. """
  453. 平滑移动到目标位置
  454. Args:
  455. pan: 目标水平角度
  456. tilt: 目标垂直角度
  457. zoom: 目标变倍
  458. steps: 移动步数
  459. delay: 步间延迟
  460. Returns:
  461. 是否成功
  462. """
  463. current = self.ptz.get_current_position()
  464. # 计算步长
  465. pan_step = (pan - current.pan) / steps
  466. tilt_step = (tilt - current.tilt) / steps
  467. zoom_step = (zoom - current.zoom) / steps
  468. for i in range(1, steps + 1):
  469. current_pan = current.pan + pan_step * i
  470. current_tilt = current.tilt + tilt_step * i
  471. current_zoom = int(current.zoom + zoom_step * i)
  472. self.ptz.goto_exact_position(current_pan, current_tilt, current_zoom)
  473. time.sleep(delay)
  474. return True
  475. def start_tracking(self, get_target_func, update_interval: float = 0.1):
  476. """
  477. 开始跟踪
  478. Args:
  479. get_target_func: 获取目标位置的函数 (返回 x_ratio, y_ratio 或 None)
  480. update_interval: 更新间隔
  481. """
  482. self.tracking = True
  483. def tracking_worker():
  484. while self.tracking:
  485. try:
  486. target = get_target_func()
  487. if target:
  488. x_ratio, y_ratio = target
  489. self.ptz.track_target(x_ratio, y_ratio)
  490. time.sleep(update_interval)
  491. except Exception as e:
  492. print(f"跟踪错误: {e}")
  493. time.sleep(0.1)
  494. self.tracking_thread = threading.Thread(target=tracking_worker, daemon=True)
  495. self.tracking_thread.start()
  496. def stop_tracking(self):
  497. """停止跟踪"""
  498. self.tracking = False
  499. if self.tracking_thread:
  500. self.tracking_thread.join(timeout=1)
  501. self.tracking_thread = None
  502. def zoom_to_target_size(self, target_size: Tuple[int, int],
  503. frame_size: Tuple[int, int],
  504. min_zoom: int = 2, max_zoom: int = 20) -> int:
  505. """
  506. 根据目标大小计算合适的变倍
  507. Args:
  508. target_size: 目标尺寸 (width, height)
  509. frame_size: 画面尺寸 (width, height)
  510. min_zoom: 最小变倍
  511. max_zoom: 最大变倍
  512. Returns:
  513. 计算的变倍值
  514. """
  515. target_area = target_size[0] * target_size[1]
  516. frame_area = frame_size[0] * frame_size[1]
  517. # 目标占画面比例
  518. ratio = target_area / frame_area
  519. # 根据比例计算变倍 (目标占画面30%时变倍为1)
  520. if ratio > 0:
  521. ideal_zoom = math.sqrt(0.3 / ratio)
  522. zoom = int(max(min_zoom, min(max_zoom, ideal_zoom)))
  523. else:
  524. zoom = min_zoom
  525. return zoom