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()
相关推荐
caoerzhong13 分钟前
中小企业上 WMS 该先上哪几块:JeeWMS 开源 Java 仓库管理系统的分批上线清单
java·python·开源
正在走向自律28 分钟前
AI数据分析与可视化:从基础到应用实践
服务器·人工智能·python·机器学习·数据分析·pandas
databook1 小时前
从手动检查到自动监控:一个数据质量工作流的实现
后端·python·数据分析
Android系统攻城狮1 小时前
Linux Gstreamer深度解析之gst_audio_encoder_set_frame_max调用流程与实战(六十一)
android·linux·运维·音视频·gstreamer音视频·音视频进阶·gstreamer音视频进阶
北冥有鱼被烹3 小时前
砷化镓与磷化铟:光模块的地基——从化学键到AI缺货潮的底层逻辑
人工智能·python
for_ever_love__4 小时前
线性回归与梯度下降——从零手写一个模型
python·机器学习·线性回归·梯度下降
code_whiter5 小时前
7.自动化测试常用函数
python·功能测试
小园子的小菜5 小时前
Python协程深度解析:从原理演进到实战避坑
后端·python
砚底藏山河5 小时前
量化实战:截面因子有效性检验(IC 分析与分层回测)
java·python·金融·maven
GitFun6 小时前
给策略加了条-8%止损线,11年只触发1次
python·股票·量化交易