用 Python+WebRTC 和吴彦祖视频 |Python 主题月

2,835 阅读9分钟

本文正在参加「Python主题月」,详情查看 活动链接

WebRTC

WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的 API。与直播常用的 RTMP 协议相比,WebRTC 拥有极低的延迟,并且整合了大量的终端多媒体问题和传输问题的应对方案的实现,包括音视频的编解码、同步、带宽预测、QoS,AEC等,因此使用支持 WebRTC 的设备和浏览器可以轻松实现 P2P 实时语音通话的功能。 想要实现 P2P 实时语音通话的功能,需要思考以下几个问题:

  1. 通话两端怎样找到对方,并建立通话连接?
  2. 如何从本地设备获取视频和音频?
  3. 如何将本地视频和语音传输给对方?
  4. 通话两端设备不同,如果保证一端发出的视频和语音能让对方正确理解?

对于上面的问题,WebRTC 依次给出了解决方法

建立连接

WebRTC 建立连接的过程主要依赖 信令服务 和 TURN/STUN 服务

信令服务

由于一开始通话的双方对另一方可以说是一无所知,因此需要一个“中介”来转发消息,信令服务就充当了这个“中介”的角色。 想要通信的两端在启动之后可以在信令服务中注册,一般可以通过与信令服务建立 WebSocket 的方式与信令服务通信。

WebRTC 并没有规定客户端与信令服务的通信协议,除了 WebSocket,也可使用 Socket.io 等其他方式

客户端连上信令服务之后就可以告知信令服务自己想和谁通话,信令服务就会在自己的注册名单中查询对应的用户,如果没有找到,就会回绝客户端的请求,反之则会将请求的消息转发给对应的用户。 请求消息中会包含建立 RTC 连接所需要的 Candidate 信息,里面存储了客户端 A 的 IP。B 知道 A 的 IP 之后就能向 A 发起连接请求了。

STUN 服务

前面讲到 A 会将包含自己 IP 的请求消息发给信令服务,由信令服务转发给 B。那么 A 如何知道自己的 IP 呢?这就需要借助 STUN 服务了。客户端请求 STUN 服务之后,STUN 服务会将 A 的内网 IP 和公网 IP 返回给客户端。STUN 服务不需要自己搭建,Google 提供了一个免费的 STUN 服务 stun.l.google.com:19302 供客户端使用,打开 WebRTC samples Trickle ICE 即可体验。 点击 "Gather candidates" 就可以获取本地的 Candidate 信息。其中 Component Type 为 host 对应的 Protocol Address 是内网地址,Component Type 为 srflx 对应的 Protocol Address 是公网地址。 客户端 A 将自己的内网地址和公网地址发给 B 之后,B 就可以尝试使用这两个地址建立连接了。

TURN 服务

由于防火墙的存在,B 尝试发给 A 的请求可能被防火墙拦截,实际情况与 NAT 的类型有关,相关内容可以查看:穿越防火牆技術。如果 A 和 B 都是对称性 NAT 或者端口限制性 NAT,那么 AB 之间就无法直接建立 RTC 连接,需要一个 TURN 服务进行转发。GitHub 上有很多成熟的 TURN 服务代码,例如 coturn/coturn, pion/turn,部署好就能直接用。TURN 服务除了转发数据,本身也带有 STUN 的功能,相当于是 STUN 的超集,并且除了 host 和 srflx 类型的 Candidate 之外还会返回一个 relay 类型的 Candidate。 客户端拿到这几个 Candidate 之后会按 host, srflx, relay 的顺序尝试。如果客户端使用 relay 的 Candidate 尝试建立 RTC 连接,那么代表 RTC 连接依赖 TURN 服务的转发。

SDP 协商

建立连接过程中,除了知道对方的地址,还要了解对方的音视频协议。通信双方就是通过 SDP 协商这个过程沟通音视频信息。SDP 协议的内容包含了音频格式、视频格式、Candidate 等信息,具体可以查看 Session_Description_Protocol。SDP 协商的过程主要就是看对方支持什么格式,这决定了之后将音视频发给对方时使用的格式。SDP 协商过程中的数据传输也依赖信令服务的转发。

以上整个建立连接的过程如下图所示

沟通好以上的元数据信息后,双方终于可以开始正式通信了。

获取本地音视频

浏览器

目前主流的浏览器都支持通过 MediaDevices.getUserMedia() 接口获取摄像头和麦克风权限:

var promise = navigator.mediaDevices.getUserMedia(constraints);

可以通过 constraints 参数指定视频的分辨率、帧率,例如:

navigator.mediaDevices.getUserMedia({
  audio: true,
  video: { width: 1280, height: 720 }
})

表示获取音频和视频,且规定视频的分辨率为 1280x720,详细的接口可以查看 MediaDevices/getUserMedia。 需要注意的是,大多数浏览器都限制了在 HTTPS 的连接下才能获取摄像头和麦克风的权限。

操作系统

如果不使用浏览器获取摄像头和麦克风,也可以直接使用操作系统提供的接口获取权限。后面会讲到的 Python 库 aiortc 对接口进行了封装,在判断操作系统类型之后就能直接获取到音视频流:

import platform
from aiortc.contrib.media import MediaRelay

if platform.system() == "Darwin":
    webcam = MediaPlayer(
        "default:none", format="avfoundation", options=options
    )
elif platform.system() == "Windows":
    webcam = MediaPlayer(
        "video=Integrated Camera", format="dshow", options=options
    )
else:
    webcam = MediaPlayer("/dev/video0", format="v4l2", options=options)

aiortc

目前主流的浏览器都支持了 WebRTC,但如果想让浏览器和服务器之前是用 WebRTC 通信,那么就需要服务端也能够支持 WebRTC。aiortc 就是这样一个 WebRTC 的 Python 版本的实现,它基于 asyncio 开发,充分发挥了 Python 协程的优势。项目地址:github.com/aiortc/aior…

demo

demo 的逻辑如下:

  1. 客户端使用浏览器获取视频,将视频传给 aiortc 实现的服务端
  2. 服务端将视频中的人脸换成吴彦祖😁,然后返回给客户端
  3. 客户端将从服务端获取的新视频展示出来

demo 的完整代码 👉🏻 github.com/tsonglew/ai…

信令服务实现

信令服务负责通信两端数据的转发。为了方便,demo 中浏览器通过 HTTP 请求向信令服务发送数据,而没有使用 WebSocket 连接。并且直接在同一个进程中运行信令服务和 WebRTC 的服务端,当信令服务接收到来自客户端的请求时,就通过共享内存的方式将数据发给服务端。 demo 中信令服务兼职了 Web 服务器的功能,为前端提供静态文件。

提供静态文件

提供首页资源

async def index(request):
    content = open(os.path.join(ROOT, "index.html"), "r").read()
    return web.Response(content_type="text/html", text=content)

提供 js 文件

async def javascript(request):
    content = open(os.path.join(ROOT, "client.js"), "r").read()
    return web.Response(content_type="application/javascript", text=content)

提供信令服务

这里信令服务接收客户端使用 HTTP 请求发来的 SDP,将 SDP 转发给服务端,并将服务端的 SDP 返回给客户端

pcs = set()

async def offer(request):
    // 接收客户端请求
    params = await request.json()
    
    // 提取客户端发来的 SDP,生成服务端 SDP
    offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"])

    // 创建服务端连接对象
    pc = RTCPeerConnection()
    // 将服务端连接对象保存到全局变量 pcs 中
    pcs.add(pc)

    // 服务端逻辑
	await server(pc)
    
    // 将服务端的 SDP 返回给客户端
    return web.Response(
        content_type="application/json",
        text=json.dumps(
            {"sdp": pc.localDescription.sdp, "type": pc.localDescription.type}
        ),
    )

客户端实现

客户端包含以下几个部分:

  1. WebRTC 的协商流程
  2. 获取摄像头,并将视频传给服务端
  3. 接收服务端传来的新视频,在页面上展示出来

初始化 WebRTC 连接

function start () {
  // WebRTC 连接参数,使用 Google 的 STUN 服务
	var config = {
		sdpSemantics: 'unified-plan',
    iceServers: [{ urls: ['stun:stun.l.google.com:19302'] }]
	};

  // 创建 WebRTC 连接对象
	pc = new RTCPeerConnection(config);
  
  // 将本地的视频流通过 RTC 连接发送到服务端
	localVideo.srcObject.getVideoTracks().forEach(track => {
		pc.addTrack(track);
	});
  
  // 监听服务端发来的视频,将它绑定到 serverVideo
	pc.addEventListener('track', function (evt) {
		if (evt.track.kind == 'video') {
			document.querySelector('video#serverVideo').srcObject = evt.streams[0];
		}
	});
}

WebRTC 协商流程

function negotiate () {
  // 创建客户端本地的 SDP
	return pc.createOffer().then(function (offer) {
    // 记录本地 SDP
		return pc.setLocalDescription(offer);
	}).then(function () {
		var offer = pc.localDescription;
    // 将本地的 SDP 发给信令服务
		return fetch('/offer', {
			body: JSON.stringify({
				sdp: offer.sdp,
				type: offer.type,
			}),
			headers: {
				'Content-Type': 'application/json'
			},
			method: 'POST'
		});
	}).then(function (response) {
    // 接收并解析服务端的 SDP
		return response.json();
	}).then(function (answer) {
    // 记录服务端的 SDP
		return pc.setRemoteDescription(answer);
	})
}

获取本地摄像头

navigator.mediaDevices.getUserMedia({
	video: true
}).then(stream => {
	// 将本地获取到的视频流绑定到 localVideo 对象
  localVideo.srcObject = stream;
	localVideo.addEventListener('loadedmetadata', () => {
		localVideo.play();
	});
});

服务端实现

服务端处理以下逻辑:

  1. 处理 WebRTC 协商流程
  2. 接收客户端发来的视频
  3. 使用 OpenCV 提供的 Cascade Classifier 定位人脸
  4. 使用替换视频中的人脸
  5. 将替换好后的视频传回客户端

处理 WebRTC 协商流程

async def server(pc):
    # 监听 RTC 连接状态
    @pc.on("connectionstatechange")
    async def on_connectionstatechange():
        print("Connection state is %s" % pc.connectionState)
        # 当 RTC 连接中断后将连接关闭
        if pc.connectionState == "failed":
            await pc.close()
            pcs.discard(pc)

    # 监听客户端发来的视频流
    @pc.on("track")
    def on_track(track):
        print("======= received track: ", track)
        if track.kind == "video":
            # 对视频流进行人脸替换
            t = FaceSwapper(track)
            # 绑定替换后的视频流
            pc.addTrack(t)
            
    # 记录客户端 SDP
    await pc.setRemoteDescription(offer)
    # 生成本地 SDP
    answer = await pc.createAnswer()
    # 记录本地 SDP
    await pc.setLocalDescription(answer)

替换人脸

FaceSwapper 继承了 aiortc 的 VideoStreamTrack , aiortc 会调用 FaceSwapper 的 recv() 方法来获取视频帧,并将视频帧通过 RTC 连接发送给客户端。 这里首先用 OpenCV 提供的 xml 初始化了一个人脸检测器 self.face_detector ,并准备好了替换人脸的图片 self.face 。 self.track用来存放原始的视频流。

class FaceSwapper(VideoStreamTrack):
    kind = "video"

    def __init__(self, track):
        super().__init__()
        self.track = track
        self.face_detector = cv2.CascadeClassifier("./haarcascade_frontalface_alt.xml")
        self.face = cv2.imread("./face.png")

	...

aiortc 底层会循环调用 recv(),将获取到的视频帧发给客户端。 self.next_timestamp() 用来控制帧率和生成视频帧对应的时间参数。生成返回视频帧的过程中,先从原始视频流中读取一帧,使用人脸检测器检测出视频帧中人脸的位置,然后将对应返回内的图片用准备好的图片替换,最后将生成好的视频帧返回。

class FaceSwapper(VideoStreamTrack):
    ...
    
    async def recv(self):
        # 生成视频帧对应的时间参数
        timestamp, video_timestamp_base = await self.next_timestamp()
        # 读取原始视频流中的一帧
        frame = await self.track.recv()
        
        # 将视频帧以 BGR24 格式转化成 numpy array 方便后面处理
        frame = frame.to_ndarray(format="bgr24")
        # 检测出人脸的位置
        face_zones = self.face_detector.detectMultiScale(
            cv2.cvtColor(frame, code=cv2.COLOR_BGR2GRAY)
        )
        # 将对应位置替换成准备好的图片
        for x, y, w, h in face_zones:
            # 替换前先改变图片的大小,让它能塞满图片中人脸的区域
            face = cv2.resize(self.face, dsize=(w, h))
            # 执行替换过程
            frame[y : y + h, x : x + w] = face
        # 将修改好的 numpy array 重新转换成视频帧
        frame = VideoFrame.from_ndarray(frame, format="bgr24")
        
        # 填充视频帧参数
        frame.pts = timestamp
        frame.time_base = video_timestamp_base
        
        # 返回视频帧
        return frame

最终效果

参考