python搭建webrtc音视频服务端客户端

webrtc客户端-服务端连接过程时序图

服务端代码

python 复制代码
import asyncio
import cv2
import numpy as np
from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
import json
import websockets
import threading
from av import VideoFrame
import time
import logging
from fractions import Fraction

# 设置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class OpenCVVideoTrack(VideoStreamTrack):
    """
    自定义视频轨道:从 OpenCV 读取 RTSP 流
    """

    def __init__(self, rtsp_url):
        super().__init__()
        self.rtsp_url = rtsp_url
        self.cap = None
        self.running = True
        self.fps = 25
        self.frame_time = 1.0 / self.fps
        self.last_frame = None
        self.frame_count = 0
        self.time_base = Fraction(1, 90000)  # 90kHz 时钟,使用 Fraction 对象

        # 启动 OpenCV 捕获线程
        self.thread = threading.Thread(target=self._capture_frames)
        self.thread.daemon = True
        self.thread.start()
        logger.info(f"视频轨道创建,RTSP地址: {rtsp_url}")

    def _capture_frames(self):
        """在独立线程中持续捕获 RTSP 流"""
        retry_count = 0
        while self.running:
            try:
                if self.cap is None or not self.cap.isOpened():
                    # 重新连接 RTSP
                    logger.info(f"连接RTSP流: {self.rtsp_url}")
                    self.cap = cv2.VideoCapture(self.rtsp_url)

                    # 设置缓存大小和超时
                    self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)

                    if not self.cap.isOpened():
                        retry_count += 1
                        logger.error(f"无法打开RTSP流 (尝试 {retry_count}),5秒后重试...")
                        time.sleep(5)
                        continue

                    # 获取实际帧率
                    actual_fps = self.cap.get(cv2.CAP_PROP_FPS)
                    if actual_fps > 0:
                        self.fps = actual_fps
                        self.frame_time = 1.0 / self.fps
                        logger.info(f"RTSP流已连接,帧率: {self.fps:.2f}")
                    else:
                        logger.info("RTSP流已连接")

                    retry_count = 0

                ret, frame = self.cap.read()
                if not ret:
                    logger.warning("读取帧失败,重新连接...")
                    self.cap.release()
                    self.cap = None
                    time.sleep(1)
                    continue

                # 转换为 RGB (OpenCV 默认是 BGR)
                frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)

                # 可以在这里添加图像处理
                self.frame_count += 1
                cv2.putText(frame_rgb, f"Server Frame: {self.frame_count}", (10, 30),
                            cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

                self.last_frame = frame_rgb

                # 控制帧率
                time.sleep(self.frame_time)

            except Exception as e:
                logger.error(f"捕获线程错误: {e}")
                time.sleep(1)

    async def recv(self):
        """
        aiortc 调用此方法获取视频帧
        """
        if self.last_frame is None:
            # 如果没有帧,返回一个黑色帧
            frame = np.zeros((480, 640, 3), dtype=np.uint8)
            pts = time.time()
            video_frame = VideoFrame.from_ndarray(frame, format="rgb24")
            video_frame.pts = int(pts * 90000)  # 90kHz 时钟
            video_frame.time_base = self.time_base  # 使用 Fraction 对象
            return video_frame

        # 使用最新的帧
        pts = time.time()
        video_frame = VideoFrame.from_ndarray(self.last_frame, format="rgb24")
        video_frame.pts = int(pts * 90000)
        video_frame.time_base = self.time_base  # 使用 Fraction 对象
        return video_frame

    def stop(self):
        """停止捕获"""
        self.running = False
        if self.cap:
            self.cap.release()
        logger.info("视频轨道已停止")


class WebRTCServer:
    def __init__(self, host="0.0.0.0", port=8765):
        self.host = host
        self.port = port
        self.pcs = set()
        self.video_tracks = {}

    async def handle_websocket(self, websocket):
        """
        处理 WebSocket 信令
        """
        pc = RTCPeerConnection()
        self.pcs.add(pc)
        logger.info(f"新的客户端连接,当前连接数: {len(self.pcs)}")

        # 当连接关闭时清理
        @pc.on("connectionstatechange")
        async def on_connectionstatechange():
            logger.info(f"连接状态变化: {pc.connectionState}")
            if pc.connectionState in ["failed", "closed", "disconnected"]:
                await self.close_pc(pc)

        @pc.on("iceconnectionstatechange")
        async def on_iceconnectionstatechange():
            logger.info(f"ICE连接状态: {pc.iceConnectionState}")

        @pc.on("icegatheringstatechange")
        async def on_icegatheringstatechange():
            logger.info(f"ICE收集状态: {pc.iceGatheringState}")

        try:
            # 接收客户端消息
            async for message in websocket:
                try:
                    data = json.loads(message)
                    logger.info(f"收到消息类型: {data.get('type')}")

                    if data["type"] == "offer":
                        # 客户端发送了 offer
                        await self.handle_offer(pc, data, websocket)

                    elif data["type"] == "ice-candidate":
                        # 客户端发送 ICE 候选
                        candidate = data["candidate"]
                        if candidate:
                            await pc.addIceCandidate(candidate)
                            logger.info("添加ICE候选")

                except json.JSONDecodeError:
                    logger.error("无效的JSON消息")
                except Exception as e:
                    logger.error(f"处理消息错误: {e}")

        except websockets.exceptions.ConnectionClosed:
            logger.info("客户端断开连接")
        finally:
            await self.close_pc(pc)

    async def handle_offer(self, pc, data, websocket):
        """
        处理客户端的 offer
        """
        try:
            # 获取 RTSP URL
            rtsp_url = data.get("rtsp_url")
            if not rtsp_url:
                rtsp_url = "rtsp://admin:yjzn_2025@192.168.1.236:554/Streaming/Channels/101"

            logger.info(f"请求的RTSP流: {rtsp_url}")

            # 创建 RTSP 视频轨道
            video_track = OpenCVVideoTrack(rtsp_url)
            self.video_tracks[id(pc)] = video_track

            # 添加视频轨道到连接
            pc.addTrack(video_track)
            logger.info("视频轨道已添加到PeerConnection")

            # 设置远程描述符 (客户端的 offer)
            await pc.setRemoteDescription(
                RTCSessionDescription(sdp=data["sdp"], type=data["type"])
            )
            logger.info("远程描述符设置成功")

            # 创建 answer
            answer = await pc.createAnswer()
            await pc.setLocalDescription(answer)
            logger.info("本地answer创建成功")

            # 发送 answer 回客户端
            response = {
                "type": "answer",
                "sdp": pc.localDescription.sdp
            }
            await websocket.send(json.dumps(response))
            logger.info("answer已发送给客户端")

        except Exception as e:
            logger.error(f"处理offer错误: {e}")
            import traceback
            traceback.print_exc()
            raise

    async def close_pc(self, pc):
        """
        关闭 PeerConnection 并清理资源
        """
        # 停止视频轨道
        if id(pc) in self.video_tracks:
            self.video_tracks[id(pc)].stop()
            del self.video_tracks[id(pc)]

        # 关闭连接
        if pc in self.pcs:
            self.pcs.remove(pc)
            await pc.close()
            logger.info(f"PeerConnection已关闭,剩余连接数: {len(self.pcs)}")

    async def start(self):
        """
        启动 WebSocket 服务器
        """
        async with websockets.serve(self.handle_websocket, self.host, self.port):
            logger.info(f"WebRTC信令服务器运行在 ws://{self.host}:{self.port}")
            await asyncio.Future()  # 运行 forever


def main():
    server = WebRTCServer()
    try:
        asyncio.run(server.start())
    except KeyboardInterrupt:
        logger.info("服务器关闭")


if __name__ == "__main__":
    main()

客户端

python 复制代码
import asyncio
import cv2
import json
import websockets
from aiortc import RTCPeerConnection, RTCSessionDescription
from aiortc.contrib.media import MediaPlayer, MediaRecorder
import numpy as np
import logging
import time

# 设置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class WebRTCClient:
    def __init__(self, server_url="ws://localhost:8765"):
        self.server_url = server_url
        self.pc = RTCPeerConnection()
        self.running = True
        self.frame_count = 0

        # 用于显示视频的窗口
        cv2.namedWindow("WebRTC Client - RTSP Stream", cv2.WINDOW_NORMAL)

        # 处理连接状态
        @self.pc.on("connectionstatechange")
        async def on_connectionstatechange():
            logger.info(f"连接状态变化: {self.pc.connectionState}")
            if self.pc.connectionState == "failed":
                await self.cleanup()
            elif self.pc.connectionState == "connected":
                logger.info("WebRTC连接已建立!")

        @self.pc.on("iceconnectionstatechange")
        async def on_iceconnectionstatechange():
            logger.info(f"ICE连接状态: {self.pc.iceConnectionState}")

        # 处理接收到的轨道
        @self.pc.on("track")
        def on_track(track):
            logger.info(f"收到轨道: {track.kind}")
            if track.kind == "video":
                # 创建任务处理视频帧
                asyncio.create_task(self.process_video(track))

        # 添加接收视频的 transceiver
        self.pc.addTransceiver("video", direction="recvonly")
        logger.info("已添加视频接收通道")

    async def process_video(self, track):
        """
        处理接收到的视频帧
        """
        logger.info("开始处理视频流...")

        while self.running:
            try:
                # 接收帧,设置超时
                frame = await asyncio.wait_for(track.recv(), timeout=5.0)

                # 转换为 OpenCV 格式 (BGR)
                img = frame.to_ndarray(format="bgr24")

                # 图像处理
                self.frame_count += 1

                # 添加信息文字
                height, width = img.shape[:2]
                cv2.putText(img, f"Frame: {self.frame_count}", (10, 30),
                            cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
                cv2.putText(img, f"Size: {width}x{height}", (10, 60),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1)
                cv2.putText(img, "RTSP via WebRTC", (10, height - 20),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)

                # 显示图像
                cv2.imshow("WebRTC Client - RTSP Stream", img)

                # 必须调用 waitKey 来刷新窗口
                key = cv2.waitKey(1) & 0xFF
                if key == ord('q') or key == 27:  # 'q' 或 ESC
                    self.running = False
                    break

            except asyncio.TimeoutError:
                logger.warning("接收视频帧超时")
                continue
            except Exception as e:
                logger.error(f"处理视频帧错误: {e}")
                break

        logger.info("视频处理结束")

    async def connect(self, rtsp_url=None):
        """
        连接到服务器并开始接收视频流
        """
        try:
            # 如果没有指定RTSP URL,使用默认值
            if rtsp_url is None:
                rtsp_url = "rtsp://admin:yjzn_2025@192.168.1.236:554/Streaming/Channels/101"

            logger.info(f"正在连接到信令服务器: {self.server_url}")
            logger.info(f"请求RTSP流: {rtsp_url}")

            # 连接到信令服务器
            async with websockets.connect(self.server_url) as websocket:
                logger.info("已连接到信令服务器")

                # 创建 offer
                logger.info("创建WebRTC offer...")
                offer = await self.pc.createOffer()
                await self.pc.setLocalDescription(offer)
                logger.info("offer创建成功")

                # 发送 offer 到服务器
                message = {
                    "type": "offer",
                    "sdp": self.pc.localDescription.sdp,
                    "rtsp_url": rtsp_url
                }
                await websocket.send(json.dumps(message))
                logger.info("offer已发送到服务器")

                # 等待服务器的 answer
                logger.info("等待服务器answer...")
                response = await websocket.recv()
                answer = json.loads(response)
                logger.info(f"收到answer,类型: {answer['type']}")

                # 设置远程描述符
                await self.pc.setRemoteDescription(
                    RTCSessionDescription(sdp=answer["sdp"], type=answer["type"])
                )
                logger.info("远程描述符设置成功")

                logger.info("WebRTC连接建立中,等待视频流...")

                # 保持连接,处理可能的ICE候选
                async for message in websocket:
                    data = json.loads(message)
                    if data["type"] == "ice-candidate":
                        await self.pc.addIceCandidate(data["candidate"])
                        logger.debug("添加ICE候选")

        except websockets.exceptions.ConnectionClosed:
            logger.info("与服务器的连接已关闭")
        except Exception as e:
            logger.error(f"连接错误: {e}")
        finally:
            await self.cleanup()

    async def cleanup(self):
        """
        清理资源
        """
        self.running = False
        await self.pc.close()
        cv2.destroyAllWindows()
        logger.info("资源已清理")


def main():
    # 创建客户端
    client = WebRTCClient(server_url="ws://localhost:8765")

    # 指定RTSP地址
    rtsp_url = "rtsp://admin:yjzn_2025@192.168.1.236:554/Streaming/Channels/101"

    # 运行客户端
    try:
        asyncio.run(client.connect(rtsp_url))
    except KeyboardInterrupt:
        logger.info("用户中断")
    finally:
        logger.info("客户端关闭")


if __name__ == "__main__":
    main()
相关推荐
2401_8734794018 小时前
IP属地为什么有时显示外省?用IP查询工具核查动态分配、运营商出口与GeoIP库
python·tcp/ip·ip
OKkankan20 小时前
LangChain 能力详解!:输出解析、RAG、向量数据库与 Retriever 检索器
数据结构·python·langchain·ai应用
山哥ol21 小时前
【Geany 环境配置与中文乱码解决参考】
python
会飞锦鲤1 天前
基于 Mask R-CNN 的药片缺陷检测系统
人工智能·pytorch·python·神经网络·resnet-50
默 语1 天前
Java新手入门:从零开始安装JDK并配置环境变量
java·开发语言·python·mysql·group by·1024程序员节·数据去重
清水白石0081 天前
Python 异步编程深度解析:Cancellation 到底是异常还是控制信号?从 asyncio 取消机制到企业级事务设计最佳实践
开发语言·python
Patrick在香港1 天前
Python 审计香港开放数据目录:两个端点差 10 倍,只有 9.3% 的资源标了「最后修改时间」
开发语言·数据库·python·数据分析·api·数据治理·开放数据
IT毕设梦工厂1 天前
计算机毕业设计选题推荐:基于大数据的草鱼价格分析与可视化|毕业设计选题|计算机毕设|选题推荐|毕设指导|项目定制|源码|高质量项目
大数据·hadoop·python·数据挖掘·数据分析·课程设计·大数据毕设项目
GISHUB1 天前
基于QGIS 3.44开发Python插件完整教程
python·qgis
大数据魔法师1 天前
curl_cffi从入门到实战
爬虫·python