詳解V4L2 (video for linux 2)
この記事は「自動運転システムをエッジデバイスに組み込むための技術」を3回に分けて紹介するTURINGのテックブログ連載の第3回の記事「詳解V4L2 (video for linux2)」です。
第1回の「C++でOpenCV完全入門!」、第2回の「OpenCVをNPPにした結果→10倍高速に!」もぜひご覧ください!
はじめに
こんにちは。TURING株式会社(以下、TURING)で、インターンをしている東大B3の中村です。
TURINGは、完全自動運転EVを作ることを目的に設立されたベンチャー企業です。自動運転システムとそれを搭載したEV車の開発を行っています。
TURINGの自動運転システムは、カメラからの映像入力を肝としています。これまではOpenCVを入力のインターフェイスとして利用していました。OpenCVを使用していたのは、
- buildや使用法についての情報が多い
- コードが簡単になる
という利点からでしたが、製品に搭載される計算機が定まると
- 製品環境向けビルドに時間がかかる
- 使用していない機能がほとんどである一方でストレージを浪費する
- アクセラレータ等の使用ができない
といった問題が浮かび上がりました。
そこで、TURINGではOpenCVを完全に排除し、ハードウェアアクセラレータや、CUDAを使用して画像をこれまで以上に高速に処理するようにしました。
先日投稿された記事では、NPPを使用することでホモグラフィー変換を高速に実行出来ることを紹介しました。
この記事では、カメラからの入力をOpenCVを使用せずに受け取る方法を示します。具体的にはLinuxに搭載されているAPI、Video for Linux APIを使用します。
まず、全体の流れを追い、それぞれで使われる関数や構造体について、説明していきます。
もし、Linux Foundation公式の詳細な解説を読みたい方は、下のページも読むことをオススメします。この記事も基本的には下記ページに則って説明していきます。
https://docs.kernel.org/userspace-api/media/index.html
想定読者
-
V4L2なんもわからんな初心者
-
C言語の
- 配列
- ポインタ
に関する知識を(ある程度)持っていること
-
メモリとアドレスについての知識をある程度持っている
-
システムコールに関するぼんやりとした知識
- manコマンドで調べることができるなら、特に問題ないです
- 主に使うsyscallは以下です。
- open
- write
- read
- ioctl
V4L2とは
V4L2とは、Video for Linux 2の略です。その名の通り、VideoをLinuxで扱うためにLinux Kernelに実装されているuser space APIです。実際のところは、カメラ以外にもTVやラジオにも対応しているようですが、この記事では特にウェブカメラを扱うことのみに焦点を当てて解説していこうと思います。
https://docs.kernel.org/userspace-api/media/intro.htmlより引用
全体の流れ
まず、詳細に解説をするよりもざっくりとウェブカメラから画像を1枚取得するフローを示します。画像を1枚取得すれば、これを連続的に行うことで、動画も実質的には作成可能です。
まず以下に全体の流れを示します。
index | 内容 | 使用する主な関数・構造体・enum |
---|---|---|
1 | デバイスファイルを開く | open |
2 | デバイスが画像送信などに対応しているか確認する | ioctl, VIDIOC_QUERYCAP, v4l2_capability |
3 | デバイスにフォーマットを設定する | ioctl, v4l2_format, VIDIOC_S_FMT, VIDIOC_G_FMT |
4 | デバイスにbufferを要求する | ioctl, VIDIOC_REQBUFS, v4l2_requestbuffers |
5 | デバイスにbufferを問い合わせて、マッピングする | ioctl, VIDIOC_QUERYBUF, mmap, v4l2_buffer |
6 | デバイスのbufferにからのデータをエンキューして、streamの開始を指示する | ioctl, VIDIOC_QBUF, v4l2_buffer, VIDIOC_STREAMEON, v4l2_buf_type |
7 | デバイスファイルが、読み込み可能な状態になるのを待つ | select, ないしは poll |
8 | デバイスのbufferからデータをdequeueし、bufferに入れる | ioctl, VIDIOC_DBUF, v4l2_buffer |
9 | なんらかの処理を行う | そのときによりけり |
10 | デバイスのbufferにenqueueの指示を出す | ioctl, VIDIOC_QBUF |
11 | デバイスにstream停止の指示を出す | ioctl, VIDIOC_STREAMOFF |
12 | マッピングしたメモリを開放する | munmap |
13 | デバイスファイルを閉じる | close |
画像を1枚とるだけにしてはあまりにも処理が多いと思うかもしれないですが、これが普段使用しているカメラアプリの裏側で行われていることであり、逆にこれが理解できると、ドライバーが対応していれば、I2Cなどで直接通信するよりも上の中では、最も低いレイヤーでカメラを制御できるということになります。(また、Linuxに標準搭載のAPIなのでライブラリを別に用意する必要がなかったり、ムダな判別式がなく軽くなるということも期待できます。)
少なくともbufferという文言が4回出てきますが、デバイス(ここでは、ウェブカメラ)のbufferなのか、PC側のbufferなのか、という区別をきっちりすることが必要です。
基本的には、デバイス側のバッファしかいじらず、最後に画像取得でPCのバッファを使用する
ということを覚えておくと良いかなと思います。
エンキューやらデキューやらが出てきて混乱すると思いますが、これは要するに
- カメラ → バッファ
という流れをエンキューとし、
- バッファ → PC
という流れをデキューとしているだけです。画像の流れについてもいちいち指示をしなければいけないということで、これがわかれば特に問題ありません。次からは各プロセスの詳細について説明していきます。
1. デバイスファイルを開く
openを知っていれば特に問題ないです。
int fd = open("/dev/video0", O_RDRW);
/* error handling */
if (fd == -1){
perror("device open");
return EXIT_FAILURE;
}
デバイスファイルとは、デバイスドライバのインタフェースとして、ファイルの形で提供されるもののことを言います。詳しいことは調べてもらえるといいですが、簡単な話このファイルにいろんな操作をすることで、カメラを操作できるということを覚えておいてもらえると良いと思います。
2. デバイスが使用したい方法に対応しているか確認する
struct v4l2_capability cap;
/* ここでデバイスのcapabilityについて問い合わせる */
if (-1 == ioctl(fd, VIDIOC_QUERYCAP, &cap)) {
perror("QUERYCAP");
return EXIT_FAILURE;
}
/* 対応しているかの確認*/
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
perror("no video capture");
return EXIT_FAILURE;
}
if (!(cap.capabilities & V4L2_CAP_STREAMING)){
perror("does not support stream");
return EXIT_FAILURE;
}
ここでは、Video Captureと、それをストリーミング(後述)することに対応するかを見ています。
ioctl
について少し触れます。1つめの引数で指定したスペシャルファイルのデバイスのパラメータを、2つめの引数にしたがって操作する関数であり、これもsyscallの一種です。
実際にはこれを少し改良した xioctl
関数やそれに類似した関数を作成することが多いです。これは、デバイスの割り込みによって、エラーが帰ってきた場合には、エラーとして扱わずに、同じ操作を何度でも繰り返すというものです。以下にその実装を示します。
static int xioctl(int fd, int request, void *arg){
int r;
do {
r = ioctl(fd,request,arg);
} while (-1 == r && EINTR == errno);
return r;
}
以降、説明なしで xioctl
関数を使用します。
3. デバイスに画像フォーマットを設定する
画像サイズや、フォーマットなどをここで設定します。また、確認も行うのが基本的には良いとされています。例によって xioctl
を用いて、2つめの引数に VIDIOC_S_FMT
を使うことで設定することが出来ます。 VIDIOC_G_FMT
で現在の設定を取得することができます。
struct v4l2_format fmt = {0};
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = 800;
fmt.fmt.pix.height = 600;
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_MJPEG;
fmt.fmt.pix.field = V4L2_FIELD_ANY;
/* set format */
if (-1 == xioctl(fd, VIDIOC_S_FMT, &fmt)){
perror("Setting Pixel Format");
return EXIT_FAILURE;
}
memset(&fmt, 0, sizeof(fmt));
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
/* get format */
if (-1 == xioctl(fd, VIDIOC_G_FMT,&fmt)){
perror("get format");
return EXIT_FAILURE;
}
/* 確認(違ってもここではExitしていない) */
if (fmt.fmt.pix.width != 800 || fmt.fmt.pix.height != 600 || fmt.fmt.pix.pixelformat != V4L2_PIX_FMT_MJPEG){
printf("The desired format is not supported\n");
}
4. デバイスにバッファを要求する
ここから少し難しいかもしれません。まず、前提としてメモリはカメラにも存在しています。カメラは毎秒15枚だか30枚だかの画像を撮って、何らかの形で出力できるわけですが、これらは、撮影されて一度カメラのメモリに蓄えられ、それをPCが使用しています。
このメモリにどれくらいの枚数蓄えるのかは、こちらから要求しないといけません。1枚でも別に良いんですが、1枚の場合、なにか問題が起きたときの余裕がありません。(バッファの意味がない)したがって、余裕をもった設定にしたいですが、余裕をもたせると遅延が生じることもあるので、いい感じに設定する必要があります。
xioctl
と VIDIOC_REQBUFS
を用いて、要求することができます(下図参照)
struct v4l2_requestbuffers req = {0};
req.count = 3;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
if (-1 == xioctl(fd,VIDIOC_REQBUFS,&req)){
perror("Requesting Buffer");
return EXIT_FAILURE;
}
/* 確保できた枚数の確認 */
if (req.count < 3) {
fprintf(stderr, "Insufficient buffer memory on camera\n");
return EXIT_FAILURE;
}
5. Bufferをマッピングする
カメラにbufferを要求し、指定した枚数を確保できたとしても、まだPCではそのbufferがどこにあるのか、どうやってアクセスするのかはわかりません。(簡単な話、アドレスを知らない)
したがって、カメラにbufferを問い合わせ(Query)、それらのアドレスを1つ1つPC側のアドレスと対応させる(mapping)する必要があります。このmappingは確保されたbufferの枚数分必要であることを忘れないようにしてください。
例によって、 xioctl
を用います。また、ここでは、引数として、 VIDIOC_QUERYBUF
を用います。
これによって、bufferの各種情報が得られます。(アドレスや、長さなど)
指定枚数文のループを回しますが、ここではポインタの開始と、長さを持つ構造体の配列である buffers
に情報を入れることにします。
mmap
が、デバイスのメモリとPCのメモリをマッピングする関数です。
1つめの変数は、PCのどのアドレスから確保するかを指定することができます。ここでは NULL
で、PC側が任意に決定できるようにしてあります。2つ目の引数は要求されるサイズを指定しています。3つ目の引数は、領域へのアクセス方法の指定であり、ここでは読み書きが可能であるとしています。4つ目の引数は、領域を共有するかなどが設定できます。ここでは、プロセス間での共有が可能であるようにしていますが、このプロセス単体でしか使用しない場合は特に意味はないです。5つ目の引数はデバイスファイルの指定です。6つ目の指定は、デバイスのアドレスで、先頭からのoffsetを入れています。
struct buffer {
void* start;
size_t length;
};
struct buffer buffers;
buffers = (struct buffer*)calloc(n_buffers, sizeof(*buffers));
if (buffers == NULL){
perror("calloc");
}
for (int i = 0; i < req.count ; i++){
struct v4l2_buffer buff = {0};
buff.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buff.memory = V4L2_MEMORY_MMAP;
buff.index = i;
if (-1 == xioctl(fd,VIDIOC_QUERYBUF,&buff)){
perror("Querying Buffer");
return EXIT_FAILURE;
}
buffers[i].length = buff.length;
buffers[i].start = mmap (NULL, buff.length,\
PROT_READ | PROT_WRITE ,\
MAP_SHARED, fd,\
buff.m.offset);
}
6. デバイスのbufferにデータをエンキューする
最初のうちにデバイスのbufferにデータを入れておいたほうが、あとからデータ取得を開始したときに空っぽなデータが来なくて良いですよね。ということで、bufferにいっぱいになるまで画像データを入れるように指示します。(これはお決まりのパターンです)
xioctl
と、 VIDIOC_QBUF
で行えます。 v4l2_buffer
は、エンキューするbufferの形式や場所を指定していると思えば良いです。
for (i = 0; i < n_buffers; ++i) {
/*エンキューするbufferの情報を入れる*/
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = i;
/*ここでエンキューする*/
if (-1 == xioctl(fd, VIDIOC_QBUF, &buf)){
perror("VIDIOC_QBUF");
return EXIT_FAILURE;
}
}
7. デバイスからの出力を可能にする指示を出す
今の所enqueue、すなわちデバイスのメモリに画像データを蓄えることはできていますが、そこから取り出し(dequeue)てPCに移すことはできていません。メモリをマッピングしつつ、必要に応じて取り出す(dequeue)入出力方式をstreamといい、これを許可するようにデバイスに要求します。
xioctl
と VIDIOC_STREAMON
で可能です。
enum v4l2_buf_type type;
type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (-1 == xioctl(fd, VIDIOC_STREAMON, &type)){
perror("VIDIOC_STREAMON");
return EXIT_FAILURE;
}
8. デバイスファイルが読み込み可能になるのを待つ
いよいよbufferから画像データを取り出していきます。その前にカメラ側が読み出し可能な状態になっているかどうかを確認する必要があります。これには select
か poll
を使うことになります。どちらもシステムコールで、タイムアウトの時間を決めて、ファイルディスクリプタが読み込み可能になるまで実行するという関数です。個人的には poll
のほうが実装が簡単だと思うので、そちらをおすすめしますが、そもそもこれ自体は必須ではなく、やらなくても問題が起きたことは今のところないです。
#include<poll.h>
struct pollfd fds[1];
/* ファイルディスクリプタと読み込み可能であるというイベントを予め入れておく*/
fds[0].fd = fd;
fds[0].events = POLLIN;
int p = poll(fds,1,5000);
if (-1 == p){
perror("Waiting for Frame");
return EXIT_FAILURE;
}
9. Bufferから画像データを取り出す
ようやく画像データを取得します。現在、Bufferは既にいっぱいなはずなので、ここから取り出すだけでOK。そのためには、dequeueの命令を送ってやります。 xioctl
と VIDIOC_DQBUF
を使用します。
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (-1 == xioctl(fd, VIDIOC_DQBUF, &buf)) {
perror("Retrieving Frame");
return EXIT_FAILURE;
}
10. 画像の処理をする(optional)
ここでは、純粋に画像を保存することにします。フォーマットを設定する時点で、MJPEGを指定していたので、画像は単純な1枚のJPEG画像として保存出来ます。注意してほしいのは、マッピングの段階で buffers
配列のstartにメモリのアドレスを覚え込ませている点です。したがって、先のdequeueで与えられたindexを使用して、配列の中の番地を指定して画像データを取り出し(実際にはメモリの番地を指定するだけ)、これを書き込めば良いことになります。
/*画像ファイルを開く(ない場合は作成する)*/
int out = open("out.jpg",O_RDWR | O_CREAT, S_IRWXU|S_IRWXO|S_IRWXG);
if (out == -1){
perror("file error");
}
/* 書き込む */
write(out,buffers[buf.index].start,buffers[buf.index].length);
close(out);
write
関数を見ると、buffersの指定indexのスタートアドレスから、buffersの指定indexの長さだけ書き込むということをしています。これだけで画像の保存ができます。
11. デバイスのbufferにデータをエンキューする
dequeueしたら、出た分enqueueしないといけません。さっきと同じなので、特に説明することはありませんが、一応書いておきます。ちなみに画像を1枚保存するだけであれば、次の画像を使うことはないのでenqueueせずに終了しても大丈夫です。
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = 0;
if (-1 == xioctl(fd, VIDIOC_QBUF, &buf)){
perror("Query BUffer");
return EXIT_FAILURE;
}
12. デバイスにstreamの停止を指示する
終了は、開始の手順のほぼ逆順ですね。もうPCにstreamしなくていいよと教えてあげます。
enum v4l2_buf_type type;
type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (-1 == xiocctl(fd, VIDIOC_STREAMOFF, &type)){
return EXIT_FAILURE;
}
13. mappingしたメモリを開放する
mappingしたメモリも開放してあげる必要があります。 munmap
関数を使用します。必ず確保した枚数を開放するのを忘れないようにしてください。
for (int i = 0; i < n_buffers; ++i){
if (-1 == munmap(buffers[i].start, buffers[i].length)){
return EXIT_FAILURE;
}
}
free(buffers);
14. デバイスファイルを閉じる
これも特に説明はいらないと思います。忘れずに閉じてあげてください。
close(fd);
これで1通りの動作ができるようになった。
まとめて実行する
以下にソースコードを載せておきました。
動画を保存するには?
まず、連続的に画像を取得するためにenqueue -> dequeue -> 画像保存のループを作る必要があります。(例えば以下のようなループ)
int frame = 0;
while (frame < 30 * 30){
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (-1 == xioctl(fd, VIDIOC_DQBUF, &buf)) {
perror("Retrieving Frame");
return EXIT_FAILURE;
}
/*画像ファイルを開く(ない場合は作成する)*/
char filename[32];
sprintf(filename, "out%d.jpeg", frame);
int out = open(filename, O_RDWR | O_CREAT, S_IRWXU|S_IRWXO|S_IRWXG);
if (out == -1){
perror("file error");
}
/* 書き込む */
write(out,buffers[buf.index].start,buffers[buf.index].length);
close(out);
if (-1 == xioctl(fd, VIDIOC_QBUF, &buf)){
perror("Query BUffer");
return EXIT_FAILURE;
}
frame ++;
}
これで保存される画像はただのJPEGであるため、これらを連結して、動画にする必要があります。
このプロセスは別のコードに書くのがいいでしょう。あまりにもコードが複雑になるためです。最も手軽な方法は、ffmpegを使用した方法だと思います。(例えば以下のような感じで出来ます)
ffmpeg -f image2 -r 30 -i path/to/image/out%d.jpeg -vcodec libx264 output.mp4
OpenCVとの比較
OpenCVからの脱却として、わざわざV4L2を使用するようにしたわけですが、OpenCVを使用する場合はどのようにかけたのか、示そうと思います。
int main() {
cv::VideoCapture cap(0, cv::CAP_V4L2);
cap.set(cv::CAP_PROP_FRAME_WIDTH, 800);
cap.set(cv::CAP_PROP_FRAME_HEIGHT, 600);
cap.set(cv::CAP_PROP_FOURCC, cv::VideoWriter::fourcc('M', 'J', 'P', 'G'));
if(!cap.isOpened()) {
return -1;
}
cv::Mat frame;
cap >> frame;
cv::imwrite("out.png", frame);
return 0;
}
とてもシンプルでわかりやすいですね😊。フレームワークの強さを感じます。
実は、上の注意のコードをしっかりと書いた上で、JPEG画像を保存する上では、CPUの使用率に大きな差はありません。問題があるとすれば、 cv::Mat
への保存を強制されるくらいなので、基本的なPC環境ではOpenCVを使用して全く問題ないと言えます。
TURINGではただ画像を保存するだけでなく、上のコードで無効化したRGBへの変換を行う必要がありました。これをOpenCVで行うとかなりのCPUを使用するため、代替手段を用いる必要がありました。これについては次の機会に説明したいと思います。
まとめ
V4L2を使用するのは一見難しいですが、理屈さえわかれば割と簡単です。一方でOpenCVと簡単なコードでのパフォーマンス差はほぼなく、一般にはOpenCVを使用すれば十分とも言えます。
システムコールを利用してウェブカメラを操る利点はこの記事の範囲内ではOpenCVをビルドして端末の容量を割かなくてよいという点に尽きますが、実際はこの後に様々な処理を加える中で、OpenCVを使用したくない場面が出てくることもあるので、そういった場合には特に威力を発揮します。
また、OSのAPIを直接叩きたいシステムコールが大好きな人は是非V4L2を使ってみてください。
おわりに
今回までで「自動運転システムをエッジデバイスに組み込むための技術」についての記事は終わりになります。今回はV4L2を使用して、ウェブカメラから画像を取得する方法を紹介しました。
昨年末のMLOps LT会にて発表した、「車載エッジデバイスにおけるAI実装」では、これら(とTensorRT)を組み合わせて推論を高速化した話をしています。資料を公開していますので、興味がある方はぜひご覧ください。
TURINGでは、EV及び自動運転システムの開発を行っています。AIだけでなく、今回紹介したようなV4L2などシステムコール、Linux Kernelなど低レイヤが大好きな人との相性もバッチリな環境が整っています。「現代最高のエンジニアリング課題の一つ完全自動運転を一緒に解きたい」「車をゼロから作り、公道を走る感動をチームで味わいたい」「低レイヤだけど新しい技術もガッツリ取り入れながら働きたい」そんな方はぜひご連絡ください。
問い合わせ先
また、HPではメンバー紹介等をご覧いただけます。ぜひご覧ください。
その他ご質問や気になる点がありましたら、お気軽にTwitterのDMをお送りください。共同代表山本・青木どちらもDMを開放しております。→ @issei_y, @aoshun7
Discussion
素晴らしい記事をありがとうございます!
ここに掲載していただいたサンプルプログラムをZig言語で書き直してみました。
すると、このサンプルプログラムにある潜在バグを発見しました。こちらのブログをご覧になってください。
記事を拝読させていただきました!
Zigめっちゃいいですね!こちらのコードも修正、記事に追記させていただきました。ありがとうございます。
ついでと言っては何ですがTwitterもフォローさせて頂きました!