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()
相关推荐
七夜zippoe2 小时前
PostgreSQL高级特性在Python中的实战:JSONB、全文搜索、物化视图与分区表深度解析
数据库·python·postgresql·性能优化·分区表
郝学胜-神的一滴2 小时前
一序平衡,括号归真:单括号匹配算法的优雅美学
java·前端·数据结构·c++·python·算法
清水白石0082 小时前
Python 方法绑定机制深度解析:bound method、三种方法类型与代码评审实战
开发语言·网络·python
@国境以南,太阳以西2 小时前
从0实现OnCall基于Python语言框架
开发语言·python
转型AI的宏达2 小时前
ETF遍历取数模块 金融量化建模 Python
python
yaoxin5211232 小时前
352. Java IO API - Java 文件操作:java.io.File 与 java.nio.file 功能对比 - 4
java·python·nio
nananaij2 小时前
【LeetCode-04 数组异或操作 python解法】
python·算法·leetcode
badhope2 小时前
一命速通蓝桥杯全攻略
开发语言·前端·人工智能·python·职场和发展·蓝桥杯·github
誰氵难浔2 小时前
了解和使用python的click命令行cli工具
python