rtsp_player.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import av
  2. import sys
  3. import os
  4. import socket
  5. import struct
  6. import numpy as np
  7. import time
  8. import cv2
  9. import shutil
  10. import subprocess
  11. import threading
  12. import logging
  13. import platform
  14. from urllib.parse import urlparse, urlunparse, quote
  15. from PySide6.QtCore import QObject, Signal, QMutex
  16. from PySide6.QtGui import QImage, QPixmap
  17. from typing import List, Dict, Any, Optional
  18. logger = logging.getLogger(__name__)
  19. def _try_av_open(rtsp_url: str, options: dict):
  20. """用 av 库打开 RTSP,返回 (container, used_url) 或 (None, None)"""
  21. urls = [rtsp_url]
  22. parsed = urlparse(rtsp_url)
  23. if parsed.username or parsed.password:
  24. user = quote(parsed.username or '', safe='')
  25. passwd = quote(parsed.password or '', safe='')
  26. netloc = f"{user}:{passwd}@{parsed.hostname}"
  27. if parsed.port:
  28. netloc += f":{parsed.port}"
  29. encoded = urlunparse(parsed._replace(netloc=netloc))
  30. if encoded != rtsp_url:
  31. urls.append(encoded)
  32. for url in urls:
  33. try:
  34. container = av.open(url, options=options)
  35. return container, url
  36. except Exception as e:
  37. if '401' in str(e) or 'Unauthorized' in str(e):
  38. logger.warning(f"av 库 RTSP 认证失败 ({url}): {e}")
  39. continue
  40. raise
  41. return None, None
  42. def _find_system_ffmpeg():
  43. """查找系统 ffmpeg 二进制路径(绕过 PyInstaller 打包的版本)"""
  44. # 优先用 /usr/bin 下的系统版本
  45. for path in ['/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg']:
  46. if os.path.isfile(path) and os.access(path, os.X_OK):
  47. return path
  48. # fallback:查找 PATH 中的 ffmpeg
  49. p = shutil.which('ffmpeg')
  50. if p:
  51. return p
  52. return None
  53. class _FFmpegSubprocess:
  54. """通过 subprocess 调用系统 ffmpeg 读取 RTSP 流,支持 Digest 认证"""
  55. def __init__(self, rtsp_url: str, width: int = 1920, height: int = 1080, timeout: float = 10.0):
  56. self.rtsp_url = rtsp_url
  57. self.width = width
  58. self.height = height
  59. self.timeout = timeout
  60. self.proc = None
  61. self._stderr_thread = None
  62. self._stderr_log = []
  63. def _read_stderr(self):
  64. """后台线程读取 ffmpeg stderr,记录错误"""
  65. try:
  66. for line in iter(self.proc.stderr.readline, b''):
  67. line_str = line.decode('utf-8', errors='replace').strip()
  68. if line_str:
  69. self._stderr_log.append(line_str)
  70. logger.error(f"[ffmpeg stderr] {line_str}")
  71. except Exception:
  72. pass
  73. def open(self) -> bool:
  74. ffmpeg_bin = _find_system_ffmpeg()
  75. if not ffmpeg_bin:
  76. logger.error("未找到系统 ffmpeg 二进制")
  77. return False
  78. cmd = [
  79. ffmpeg_bin,
  80. '-hide_banner',
  81. '-rtsp_transport', 'tcp',
  82. '-i', self.rtsp_url,
  83. '-f', 'rawvideo',
  84. '-pix_fmt', 'rgb24',
  85. '-vf', f'scale={self.width}:{self.height}',
  86. '-r', '25',
  87. 'pipe:1'
  88. ]
  89. logger.info(f"ffmpeg subprocess 命令: {' '.join(cmd)}")
  90. try:
  91. self.proc = subprocess.Popen(
  92. cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
  93. bufsize=self.width * self.height * 3
  94. )
  95. except Exception as e:
  96. logger.error(f"ffmpeg subprocess 启动失败: {e}")
  97. return False
  98. # 后台读取 stderr
  99. self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True)
  100. self._stderr_thread.start()
  101. # 等待第一帧数据(带超时)
  102. frame_size = self.width * self.height * 3
  103. import select
  104. try:
  105. ready = select.select([self.proc.stdout], [], [], self.timeout)
  106. if not ready[0]:
  107. logger.error(f"ffmpeg subprocess 超时 ({self.timeout}s) 未输出帧数据")
  108. # 打印 stderr 中的错误
  109. if self._stderr_log:
  110. logger.error(f"ffmpeg 错误输出: {'; '.join(self._stderr_log)}")
  111. self.proc.kill()
  112. self.proc = None
  113. return False
  114. data = self.proc.stdout.read(frame_size)
  115. if not data or len(data) < frame_size:
  116. logger.error(f"ffmpeg subprocess 首帧数据不足: {len(data) if data else 0}/{frame_size}")
  117. if self._stderr_log:
  118. logger.error(f"ffmpeg 错误输出: {'; '.join(self._stderr_log)}")
  119. self.proc.kill()
  120. self.proc = None
  121. return False
  122. logger.info(f"ffmpeg subprocess 连接成功: {self.rtsp_url}")
  123. return True
  124. except Exception as e:
  125. logger.error(f"ffmpeg subprocess 读取失败: {e}")
  126. if self._stderr_log:
  127. logger.error(f"ffmpeg 错误输出: {'; '.join(self._stderr_log)}")
  128. if self.proc:
  129. self.proc.kill()
  130. self.proc = None
  131. return False
  132. def read_frame(self):
  133. if not self.proc or not self.proc.stdout:
  134. return None
  135. frame_size = self.width * self.height * 3
  136. data = self.proc.stdout.read(frame_size)
  137. if not data or len(data) < frame_size:
  138. logger.warning(f"ffmpeg subprocess 帧数据不足: {len(data) if data else 0}/{frame_size}")
  139. return None
  140. return np.frombuffer(data, dtype=np.uint8).reshape((self.height, self.width, 3))
  141. def close(self):
  142. if self.proc:
  143. try:
  144. self.proc.kill()
  145. except Exception:
  146. pass
  147. self.proc = None
  148. class RTSPPlayer(QObject):
  149. frame_ready = Signal(QPixmap)
  150. first_frame_received = Signal()
  151. timeout = Signal()
  152. def __init__(self, rtsp_url: str, target_width: int = 640, target_height: int = 360, timeout_sec: float = 5.0):
  153. super().__init__()
  154. self.rtsp_url = rtsp_url
  155. self.target_width = target_width
  156. self.target_height = target_height
  157. self.timeout_sec = timeout_sec
  158. self.running = False
  159. self._thread: Optional[threading.Thread] = None
  160. self._stop_socket: Optional[socket.socket] = None
  161. self.ai_objects: List[Dict[str, Any]] = []
  162. self.target_fps = 25
  163. self.mutex = QMutex()
  164. self._last_frame_time: float = 0.0
  165. self._timeout_emitted = False
  166. def set_target_size(self, width: int, height: int):
  167. self.mutex.lock()
  168. self.target_width = width
  169. self.target_height = height
  170. self.mutex.unlock()
  171. def set_ai_objects(self, objects):
  172. self.mutex.lock()
  173. self.ai_objects = list(objects)
  174. self.mutex.unlock()
  175. def _build_rtsp_urls(self, url: str) -> list:
  176. """构建原始 URL 和 URL 编码版本,用于认证重试"""
  177. urls = [url]
  178. try:
  179. parsed = urlparse(url)
  180. if parsed.username or parsed.password:
  181. user = quote(parsed.username or '', safe='')
  182. passwd = quote(parsed.password or '', safe='')
  183. netloc = f"{user}:{passwd}@{parsed.hostname}"
  184. if parsed.port:
  185. netloc += f":{parsed.port}"
  186. encoded = urlunparse(parsed._replace(netloc=netloc))
  187. if encoded != url:
  188. urls.append(encoded)
  189. except Exception:
  190. pass
  191. return urls
  192. def run(self):
  193. self.running = True
  194. options = {
  195. 'rtsp_transport': 'tcp',
  196. 'buffer_size': '1024',
  197. 'max_delay': '50000',
  198. 'flags': 'low_delay',
  199. 'fflags': 'nobuffer+discardcorrupt',
  200. 'analyzeduration': '50000',
  201. 'probesize': '50000',
  202. 'timeout': str(int(self.timeout_sec * 1000000)),
  203. 'stimeout': str(int(self.timeout_sec * 1000000)),
  204. }
  205. start_time = time.time()
  206. first_packet = True
  207. container = None
  208. ffsub = None
  209. use_ffmpeg_subprocess = False
  210. # 先尝试 av 库
  211. try:
  212. container, used_url = _try_av_open(self.rtsp_url, options)
  213. if container:
  214. if used_url != self.rtsp_url:
  215. logger.info(f"av 库认证成功(使用编码URL): {used_url}")
  216. else:
  217. logger.info(f"av 库连接成功: {self.rtsp_url}")
  218. except Exception as e:
  219. logger.warning(f"av 库异常: {e}")
  220. # av 失败时尝试系统 ffmpeg subprocess(支持 Digest 认证)
  221. if container is None:
  222. logger.info("av 库失败,尝试系统 ffmpeg subprocess...")
  223. ffsub = _FFmpegSubprocess(self.rtsp_url, self.target_width, self.target_height, self.timeout_sec)
  224. if ffsub.open():
  225. use_ffmpeg_subprocess = True
  226. else:
  227. ffsub = None
  228. logger.error(f"所有方式均失败: {self.rtsp_url}")
  229. self.timeout.emit()
  230. self.running = False
  231. return
  232. try:
  233. if use_ffmpeg_subprocess:
  234. # ===== 系统 ffmpeg subprocess 模式 =====
  235. self.first_frame_received.emit()
  236. last_emit = 0
  237. frame_interval = 1.0 / self.target_fps
  238. while self.running:
  239. img = ffsub.read_frame()
  240. if img is None:
  241. logger.warning("ffmpeg subprocess 读取帧失败,触发重连")
  242. break
  243. now = time.time()
  244. if now - last_emit >= frame_interval:
  245. self.process_frame(img)
  246. last_emit = now
  247. self._last_frame_time = time.time()
  248. self._timeout_emitted = True
  249. self.timeout.emit()
  250. else:
  251. # ===== av 库模式 =====
  252. video_stream = container.streams.video[0]
  253. video_stream.thread_type = 'AUTO'
  254. video_stream.thread_count = 1
  255. last_emit = 0
  256. frame_interval = 1.0 / self.target_fps
  257. for packet in container.demux(video_stream):
  258. if not self.running:
  259. break
  260. if first_packet:
  261. first_packet = False
  262. logger.info(f"RTSP 首包到达耗时 {time.time() - start_time:.1f}s: {self.rtsp_url}")
  263. self.first_frame_received.emit()
  264. for frame in packet.decode():
  265. if not self.running:
  266. break
  267. img = frame.to_rgb().to_ndarray()
  268. now = time.time()
  269. if now - last_emit >= frame_interval:
  270. self.process_frame(img)
  271. last_emit = now
  272. self._last_frame_time = time.time()
  273. if self._last_frame_time and (time.time() - self._last_frame_time) > self.timeout_sec:
  274. logger.warning("RTSP帧超时,触发重连")
  275. break
  276. if first_packet and (time.time() - start_time) > self.timeout_sec:
  277. logger.warning("RTSP连接超时")
  278. break
  279. if not self.running:
  280. break
  281. self._timeout_emitted = True
  282. self.timeout.emit()
  283. except Exception as e:
  284. logger.error(f"RTSP错误: {e}")
  285. if not self._timeout_emitted:
  286. self._timeout_emitted = True
  287. self.timeout.emit()
  288. finally:
  289. if container:
  290. container.close()
  291. if ffsub:
  292. ffsub.close()
  293. self.running = False
  294. def start(self):
  295. self._thread = threading.Thread(target=self.run, daemon=True)
  296. self._thread.start()
  297. def stop(self):
  298. self.running = False
  299. def is_alive(self):
  300. return self._thread is not None and self._thread.is_alive()
  301. def process_frame(self, frame):
  302. self.mutex.lock()
  303. tw, th = self.target_width, self.target_height
  304. objects = list(self.ai_objects)
  305. self.mutex.unlock()
  306. orig_h, orig_w = frame.shape[:2]
  307. if tw > 0 and th > 0 and (orig_w != tw or orig_h != th):
  308. # 保持原始宽高比缩放到 target 内,不足补黑边
  309. scale = min(tw / orig_w, th / orig_h)
  310. new_w = int(orig_w * scale)
  311. new_h = int(orig_h * scale)
  312. resized = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
  313. canvas = np.zeros((th, tw, 3), dtype=np.uint8)
  314. x_off = (tw - new_w) // 2
  315. y_off = (th - new_h) // 2
  316. canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized
  317. frame = canvas
  318. # AI 坐标从 display 空间映射到实际画布内图像区域
  319. sx = new_w / tw
  320. sy = new_h / th
  321. else:
  322. sx = sy = 1.0
  323. x_off = y_off = 0
  324. for obj in objects:
  325. if obj.get('text'):
  326. pos = obj.get('pos', (8, 20))
  327. color = obj.get('color', (0, 255, 0))
  328. if obj.get('anchor') == 'tr':
  329. w = cv2.getTextSize(obj['text'], cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)[0][0]
  330. tx = frame.shape[1] - w - int(pos[0])
  331. ty = int(pos[1])
  332. else:
  333. tx = int(pos[0] * sx + x_off)
  334. ty = int(pos[1] * sy + y_off)
  335. cv2.putText(frame, obj['text'], (tx, ty),
  336. cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2)
  337. continue
  338. if obj.get('points'):
  339. pts = [(int((p[0] * sx) + x_off), int((p[1] * sy) + y_off)) for p in obj['points']]
  340. color = obj.get('color', (0, 255, 0))
  341. alpha = obj.get('alpha', 1.0)
  342. thickness = obj.get('thickness', 2)
  343. label = obj.get('label', '')
  344. if len(pts) >= 2:
  345. if alpha < 1.0:
  346. overlay = frame.copy()
  347. cv2.polylines(overlay, [np.array(pts, dtype=np.int32)], False, color, thickness)
  348. cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
  349. else:
  350. cv2.polylines(frame, [np.array(pts, dtype=np.int32)], False, color, thickness)
  351. if label and pts:
  352. tx, ty = pts[-1]
  353. cv2.putText(frame, label, (tx + 3, ty - 3), cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1)
  354. continue
  355. x = int(obj.get('x', 0) * sx + x_off)
  356. y = int(obj.get('y', 0) * sy + y_off)
  357. w = int(obj.get('w', 0) * sx)
  358. h = int(obj.get('h', 0) * sy)
  359. label = obj.get('label', '')
  360. color = obj.get('color', (0, 255, 0))
  361. alpha = obj.get('alpha', 1.0)
  362. thickness = 1 if alpha < 1.0 else (1 if obj.get('dimmed') else 2)
  363. if obj.get('dimmed') and alpha >= 1.0:
  364. color = tuple(c // 2 for c in color)
  365. if alpha < 1.0:
  366. overlay = frame.copy()
  367. cv2.rectangle(overlay, (x, y), (x+w, y+h), color, thickness)
  368. cv2.putText(overlay, label, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, thickness)
  369. cv2.addWeighted(overlay, alpha, frame, 1 - alpha, 0, frame)
  370. else:
  371. cv2.rectangle(frame, (x, y), (x+w, y+h), color, thickness)
  372. cv2.putText(frame, label, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, thickness)
  373. h, w, ch = frame.shape
  374. bytes_per_line = ch * w
  375. qt_img = QImage(frame.data, w, h, bytes_per_line, QImage.Format_RGB888).copy()
  376. pixmap = QPixmap.fromImage(qt_img)
  377. self.frame_ready.emit(pixmap)