本文正在参加「Python主题月」,详情查看 活动链接
WebRTC
WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的 API。与直播常用的 RTMP 协议相比,WebRTC 拥有极低的延迟,并且整合了大量的终端多媒体问题和传输问题的应对方案的实现,包括音视频的编解码、同步、带宽预测、QoS,AEC等,因此使用支持 WebRTC 的设备和浏览器可以轻松实现 P2P 实时语音通话的功能。
想要实现 P2P 实时语音通话的功能,需要思考以下几个问题:
- 通话两端怎样找到对方,并建立通话连接?
- 如何从本地设备获取视频和音频?
- 如何将本地视频和语音传输给对方?
- 通话两端设备不同,如果保证一端发出的视频和语音能让对方正确理解?
对于上面的问题,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 }
})
复制代码
表示获取音频和视频,且规定视频的分辨率为 1280×720,详细的接口可以查看 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 的逻辑如下:
- 客户端使用浏览器获取视频,将视频传给 aiortc 实现的服务端
- 服务端将视频中的人脸换成吴彦祖?,然后返回给客户端
- 客户端将从服务端获取的新视频展示出来
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}
),
)
复制代码
客户端实现
客户端包含以下几个部分:
- WebRTC 的协商流程
- 获取摄像头,并将视频传给服务端
- 接收服务端传来的新视频,在页面上展示出来
初始化 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();
});
});
复制代码
服务端实现
服务端处理以下逻辑:
- 处理 WebRTC 协商流程
- 接收客户端发来的视频
- 使用 OpenCV 提供的 Cascade Classifier 定位人脸
- 使用替换视频中的人脸
- 将替换好后的视频传回客户端
处理 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
复制代码