玩转Perl, Eyetoy和RaspberryPi¶
作者: Roberto De Ioris
时间: 2013-12-07
简介¶
这篇文章是在2.0版本发布之前,旨在提高uWSGI在各种领域的性能和可用性的各种实验的产物。
要跟着这篇文章,你需要:
- 一个树莓派 (任何model),上面安装了Linux发行版 (我使用的是标准的Raspbian)
- 一个PS3 Eyetoy网络摄像头
- 一个可用websocket的浏览器 (基本上任何正经的浏览器都可以)
- 一点点Perl知识 (真的只要一点点,少于10行Perl代码 ;)
- 耐心 (在RPI上构建uWSGI + PSGI + coroae需要13分钟)
uWSGI子系统和插件¶
这个项目利用以下uWSGI子系统和插件:
- WebSocket支持
- SharedArea —— uWSGI组件间共享内存页 (用来存储帧)
- uWSGI Mule (用来收集帧)
- Symcall插件
- uWSGI Perl支持 (PSGI)
- uWSGI异步/非堵塞模式 (已更新至uWSGI 1.9) (可选,我们使用
Coro::Anyevent
,但是你可以依靠标准进程,虽然这将需要更多内存)
我们想要完成什么呢¶
我们想要我们的RPI收集来自Eyetoy的帧,然后使用wesockets将其流化至各种已连接的客户端,使用一个HTML5 canvas元素来展示它们。
整个系统必须尽可能使用少的内存,尽可能少的CPU周期,并且它应该支持大量的客户端 (... 好吧,即使是10个客户端对树莓派硬件而言也是个胜利 ;)
技术背景¶
Eyetoy以YUYV格式 (称为YUV 4:2:2) 捕获帧。这意味着,对于2个像素,我们需要4个字节。
默认情况下,分辨率被设置为640x480,因此每个帧将需要614,400字节。
一旦我们有了帧,我们就需要将其解码为RGBA,以允许HTML5 canvas展示它。
YUYV和RGBA之间的转换对RPI来说是重量的 (特别是若你需要为每个已连接客户端进行转换的时候),因此我们会在浏览器中使用Javascript来进行转换。(我们可以使用其他的方式,看看文章末尾以获取相关信息。)
uWSGI栈是由一个从Eyetoy收集帧,并将这些帧写到uWSGI的共享区域的mule组成的。
worker不断地从那个共享区域读取帧,并将它们作为websockets消息发送出去。
让我们开始吧:uwsgi-capture插件¶
uWSGI 1.9.21引入了一个构建uWSGI插件的简化(并且安全)的过程。(期待稍后会有更多的第三方插件!)
位于 https://github.com/unbit/uwsgi-capture 的项目展示了一个使用Video4Linux 2 API来收集帧的非常简单的插件。
每一帧都会被写入到由插件自身初始化的共享区域中。
第一步是获取uWSGI,然后用’coroae’配置文件来构建它:
sudo apt-get install git build-essential libperl-dev libcoro-perl
git clone https://github.com/unbit/uwsgi
cd uwsgi
make coroae
这个过程需要大约13分钟。如果一切顺利,那么你可以克隆uwsgi-capture插件,然后构建它。
git clone https://github.com/unbit/uwsgi-capture
./uwsgi --build-plugin uwsgi-capture
现在,在你的uwsgi目录中就有了capture_plugin.so文件。
把你的Eyetoy插入到RPI上的USB口中,然后看看它是否工作:
./uwsgi --plugin capture --v4l-capture /dev/video0
( --v4l-capture
选项是由capture插件公开的)
如果一切顺利,那么你应该在uWSGI启动日志中看到以下行:
/dev/video0 detected width = 640
/dev/video0 detected height = 480
/dev/video0 detected format = YUYV
sharedarea 0 created at 0xb6935000 (150 pages, area at 0xb6936000)
/dev/video0 started streaming frames to sharedarea 0
(这个共享区域内存指针显然将会不一样)
uWSGI进程将会在此之后立即退出,因为我们并没有告诉它要做什么。 :)
uwsgi-capture
插件公开了2个函数:
captureinit()
, 作为插件的init()钩子的映射,将会由uWSGI自动调用。如果指定了–v4l-capture选项,那么这个函数将会初始化指定设备,并且将其映射到一个uWSGI共享区域。captureloop()
是收集帧,并将它们写入到共享区域的函数。这个函数应该不断运行 (即使没有客户端读取帧)
我们想要一个mule来运行 captureloop()
函数。
./uwsgi --plugin capture --v4l-capture /dev/video0 --mule="captureloop()" --http-socket :9090
这次,我们绑定uWSGI到HTTP端口9090,并且带有一个映射到”captureloop()”函数的mule。这个mule语法是由symcall插件公开的,这个插件控制每一个由”()”结尾的mule参数 (引号是必须的,用来避免shell搞乱括号)。
如果一切顺利,那么你应该看到你的uWSGI服务器生成一个master,一个mule和一个worker。
第二步:PSGI应用¶
是时候来写我们发送Eyetoy帧的websocket服务器了 (你可以在这里找到这个例子的源代码:https://github.com/unbit/uwsgi-capture/tree/master/rpi-examples)。
这个PSGI应用将会非常简单:
use IO::File;
use File::Basename;
my $app = sub {
my $env = shift;
# websockets connection happens on /eyetoy
if ($env->{PATH_INFO} eq '/eyetoy') {
# complete the handshake
uwsgi::websocket_handshake($env->{HTTP_SEC_WEBSOCKET_KEY}, $env->{HTTP_ORIGIN});
while(1) {
# wait for updates in the sharedarea
uwsgi::sharedarea_wait(0, 50);
# send a binary websocket message directly from the sharedarea
uwsgi::websocket_send_binary_from_sharedarea(0, 0)
}
}
# other requests generate the html
else {
return [200, ['Content-Type' => 'text/html'], new IO::File(dirname(__FILE__).'/eyetoy.html')];
}
}
唯一有趣的部分是:
uwsgi::sharedarea_wait(0, 50);
这个函数挂起当前请求,直到指定的共享区域 (‘zero’那个) 得到更新。由于这个函数基本上是一个频繁循环的poll,因此第二个参数指定了poll的频率,以毫秒为单位。50毫秒就能有不错的结果了(随意尝试其他值)。
uwsgi::websocket_send_binary_from_sharedarea(0, 0)
这是一个特别的功能性函数,直接发送一个来自于共享区域(是哒,zero拷贝)的websocket二进制消息。第一个参数是共享区域id (‘zero’那个),而第二个是共享区域中开始读取的位置 (再次是0,因为我们想要一个完整的帧)。
第三步:HTML5¶
HTML部分 (好吧,说是Javascript部分更恰当些) 是非常简单的,除了从YUYV到RGB(A)转换。
<html>
<body>
<canvas id="mystream" width="640" height="480" style="border:solid 1px red"></canvas>
<script>
var canvas = document.getElementById('mystream');
var width = canvas.width;
var height = canvas.height;
var ctx = canvas.getContext("2d");
var rgba = ctx.getImageData(0, 0, width, height);
// fill alpha (optimization)
for(y = 0; y< height; y++) {
for(x = 0; x < width; x++) {
pos = (y * width * 4) + (x * 4) ;
rgba.data[pos+3] = 255;
}
}
// connect to the PSGI websocket server
var ws = new WebSocket('ws://' + window.location.host + '/eyetoy');
ws.binaryType = 'arraybuffer';
ws.onopen = function(e) {
console.log('ready');
};
ws.onmessage = function(e) {
var x, y;
var ycbcr = new Uint8ClampedArray(e.data);
// convert YUYV to RGBA
for(y = 0; y< height; y++) {
for(x = 0; x < width; x++) {
pos = (y * width * 4) + (x * 4) ;
var vy, cb, cr;
if (x % 2 == 0) {
ycbcr_pos = (y * width * 2) + (x * 2);
vy = ycbcr[ycbcr_pos];
cb = ycbcr[ycbcr_pos+1];
cr = ycbcr[ycbcr_pos+3];
}
else {
ycbcr_pos = (y * width * 2) + ((x-1) * 2);
vy = ycbcr[ycbcr_pos+2];
cb = ycbcr[ycbcr_pos+1];
cr = ycbcr[ycbcr_pos+3];
}
var r = (cr + ((cr * 103) >> 8)) - 179;
var g = ((cb * 88) >> 8) - 44 + ((cr * 183) >> 8) - 91;
var b = (cb + ((cb * 198) >> 8)) - 227;
rgba.data[pos] = vy + r;
rgba.data[pos+1] = vy + g;
rgba.data[pos+2] = vy + b;
}
}
// draw pixels
ctx.putImageData(rgba, 0, 0);
};
ws.onclose = function(e) { alert('goodbye');}
ws.onerror = function(e) { alert('oops');}
</script>
</body>
</html>
这里没啥特别的。绝大部分的代码是关于YUYV->RGBA转换。注意设置websocket通信为“二进制”模式 (binaryType = ‘arraybuffer’就够了),并且一定要使用Uint8ClampedArray (否则性能将会很糟糕)
准备观看¶
./uwsgi --plugin capture --v4l-capture /dev/video0 --http-socket :9090 --psgi uwsgi-capture/rpi-examples/eyetoy.pl --mule="captureloop()"
连接你的浏览器到你的树莓派到TCP端口9090,然后开始看看。
并发性¶
当你看你的websocket流时,你或许想要启动另一个浏览器窗口来看看你的视频的第二份拷贝。不幸的是,你生成的uWSGI只有一个worker,因此只有一个客户端才能获取到流。
你可以轻松添加多个worker:
./uwsgi --plugin capture --v4l-capture /dev/video0 --http-socket :9090 --psgi uwsgi-capture/rpi-examples/eyetoy.pl --mule="captureloop()" --processes 10
就像这个,最多支持10个人观看视频流。
但是对于像这样的绑定I/O应用,协程是更好的方式 (并且更便宜):
./uwsgi --plugin capture --v4l-capture /dev/video0 --http-socket :9090 --psgi uwsgi-capture/rpi-examples/eyetoy.pl --mule="captureloop()" --coroae 10
现在,奇妙的是,我们能够只用一个进程来管理10个客户端!树莓派上的内存将会对你心存感激。
零拷贝所有的东西¶
为什么我们要使用共享区域?
共享区域是uWSGI最高级的特性之一。如果你看看uwsgi-capture插件,那么你会看到它是如何轻松创建一个指向一个mmap()区域的共享区域的。基本上,每个worker,线程(但是在Perl中请千万不要使用线程)或者协程将会以一种并发安全的方式访问那个内存。
除此之外,多亏了websocket/共享区域合作API,你可以直接发送来自于一个共享区域的websocket包,而无需拷贝内存 (除了结果websocket包)。
这是比下面这样更快的方式:
my $chunk = uwsgi::sharedarea_read(0, 0)
uwsgi::websocket_send_binary($chunk)
我们需要每次迭代的时候为$chunk分配内存,拷贝共享区域内容到它里面,最后在一个websocket消息中封装它。
有了共享区域,你移除了不断分配(和释放)内存,以及将其从共享区域拷贝到Perl VM的需求。
其他方法¶
显然你还可以使用其他方法。
你可以破解uwsgi-capture,分配直接写入RGBA帧的第二个共享区域。
JPEG编码是相当快的,你可以尝试在RPI中编码帧,然后将其作为MJPEG帧发送 (而不是使用websockets):
my $writer = $responder->( [200, ['Content-Type' => 'multipart/x-mixed-replace; boundary=uwsgi_mjpeg_frame']]);
$writer->write("--uwsgi_mjpeg_frame\r\n");
while(1) {
uwsgi::sharedarea_wait(0);
my $chunk = uwsgi::sharedarea_read(0, 0);
$writer->write("Content-Type: image/jpeg\r\n");
$writer->write("Content-Length: ".length($chunk)."\r\n\r\n");
$writer->write($chunk);
$writer->write("\r\n--uwsgi_mjpeg_frame\r\n");
}
其他语言¶
在写这篇文章的时候,uWSGI PSGI插件是唯一一个为websockets+sharedarea公开了额外API的插件。其他语言插件将在不久后进行更新。
更多的hack¶
捣鼓RPI板子是相当有趣的,而uWSGI则是它的一个不错的伴侣 (特别是它的低层次API函数)。
注解
留给读者的一个练习:记住,你可以mmap()地址0x20200000来访问Raspberry PI GPIO控制器……准备好写一个uwsgi-gpio插件了吗?