高野山
この前、第19回知能メカトロニクスワークショップというのに参加させて頂きました。会場がお寺の宿坊で、畳の上で議論するっていうのはなかなか新鮮な体験でしたけど、畳の上ってじっくり人の話を聞いたり議論したりするってのには向いてるのかな、などと思いました。事務局の皆様ありがとうございました。高野山は朝晩涼しくて気持ちよかったです。

空気を読みませんでした
私はそこで OpenGL というか GLSL で画像処理してみるって言うお話をしたのですが、自己紹介で「実は画像処理が嫌いで CG 始めた」なんて言っちゃったもんだから、次の発表者の方に「前の発表者の方は画像処理が嫌いだとおっしゃってましたが、私は画像処理が好きです…」などとフォローされてしまい、思わず「アヘアヘ」と苦笑いをしていたりしたのでした。
まあ、それはそうとして、自分の話にはやっぱりサンプルが無いとまずいかなぁって思ったので、この発表原稿書くときに作ったプログラムを整理して、公開しておこうと思います。サンプルプログラムは GitHub 上にあります。
このプログラムは GLFW3(一応チュートリアル [PDF] を書きました)と授業で使っている補助プログラムを使っています。
OpenCV わかりません
処理対象の画像として、USB カメラかなんかの映像をキャプチャしたものを使います。そのために、ここだけ OpenCV を使います。OpenCV には GPU を活用した機能が既に整備されているので、OpenCV 使うんだったらわざわざ別にベタな OpenGL を使って書く必要は全く無いと思いはするんですけど、例によって書いてみたいから書くことにしました。OpenCV の使い始めはこんなんでいいんでしょうか。
cv::VideoCapture camera(CV_CAP_ANY);
if (!camera.isOpened())
{
std::cerr << "cannot open input" << std::endl;
exit(1);
}
// カメラの初期設定
camera.grab();
const GLsizei capture_width(GLsizei(camera.get(CV_CAP_PROP_FRAME_WIDTH)));
const GLsizei capture_height(GLsizei(camera.get(CV_CAP_PROP_FRAME_HEIGHT)));
CV_CAP_ANY (= 0) だとスキャナみたいなイメージングデバイスがつながってる時に具合悪いっぽいので、その時には 1 とか 2 とか、つながっている USB カメラの番号を指定した方がいいのかもしれません。あとカメラの初期設定は、これだと使ってるカメラの現在の設定をそのまま使うことになるんだと思います。キャプチャする画像サイズを明に指定するんだったら、こんなんでいいんでしょうかね。
camera.grab();
const GLsizei capture_width(640);
const GLsizei capture_height(480);
camera.set(CV_CAP_PROP_FRAME_WIDTH, double(capture_width));
camera.set(CV_CAP_PROP_FRAME_HEIGHT, double(capture_height));
ポリゴンを準備する
キャプチャした画像を GPU に送るには、画素を点と見なして頂点属性として扱う方法と、ポリゴンにマッピングするテクスチャとして扱う方法があります。前者の方がラスタライザを介さない分無駄が無いような雰囲気がありますが、画素を一次元の配列である頂点バッファに格納することになるので、フィルタなんかの処理を書くのがちょっとばかりめんどくさくなりそうな気がしますし、やっぱり画面表示もしたいと思います。そこで、ここではとりあえず後者の方法で書いてみることにしました。
ということで、そのためにはキャプチャした画像をテクスチャとしてマッピングするポリゴンを用意しなければなりません。んで、これはクリッピング空間の xy 平面いっぱいの大きさ (-1 ≤ x, y ≤ 1) の正方形を描くのが定石みたいになっているような気がします。
// 頂点配列オブジェクト
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
// 頂点バッファオブジェクト
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
// [-1, 1] の正方形
static const GLfloat position[][2] =
{
{ -1.0f, -1.0f },
{ 1.0f, -1.0f },
{ 1.0f, 1.0f },
{ -1.0f, 1.0f }
};
static const int vertices(sizeof position / sizeof position[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof position, position, GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0);
glEnableVertexAttribArray(0);
テクスチャを準備する
このポリゴンにマッピングするテクスチャを準備します。OpenCV でキャプチャした画像を、このテクスチャに転送しますので、ここではテクスチャメモリの確保だけを行います。internal format は GL_RGB にしておきます。
// テクスチャを準備する
GLuint image;
glGenTextures(1, &image);
glBindTexture(GL_TEXTURE_RECTANGLE, image);
glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGB, capture_width, capture_height, 0, GL_BGR, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
このテクスチャの target は GL_TEXTURE_RECTANGLE にします。私はこれまで二次元テクスチャでは意地でも GL_TEXTURE_2D を使ってきましたけど、GL_TEXTURE_RECTANGLE も使ってみると結構便利だと思いました。GL_TEXTURE_RECTANGLE が GL_TEXTURE_2D と異なるのは、次のようなところなのかなあと思います。
- テクスチャ空間の大きさが [0, 1] ではなく画像サイズと同じになる
- したがってテクスチャ座標が画素位置になる
- MIPMAP が使えない
- ラッピングモードに
GL_REPEATやGL_MIRRORED_REPEATが指定できない
テクスチャ空間の大きさが [0, 1] ではなく画素位置になるというのは、キャプチャした画像から切り出したりするときなどに便利です。実際、これはそのための機能なんじゃないかと思えてきます。
MIPMAP が使えないのは、まあ、仕方ないんじゃないでしょうか。一方 GL_REPEAT や GL_MIRRORED_REPEAT が使えないのは、お遊び的にはつまんないですけど、実際には問題ないのとちがいますか(多分)。あと、テクスチャ空間が画像サイズと同じになるのは、画像を画素単位にいじくるときには使いやすいのではないかと思います。GL_TEXTURE_2D では、テクスチャ空間は [0, 1] の範囲でした。
画像をテクスチャに転送する
OpenGL の描画のループの中で、画像がキャプチャできたら、それをテクスチャメモリに転送します。OpenCV でキャプチャした画像は BGR の順に並んでいるみたいなので、format に GL_BGR を指定しています。
if (camera.grab())
{
// キャプチャ映像から画像を切り出す
cv::Mat frame;
camera.retrieve(frame, 3);
// 切り出した画像をテクスチャに転送する
glBindTexture(GL_TEXTURE_RECTANGLE, image);
glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, frame.cols, frame.rows, GL_BGR, GL_UNSIGNED_BYTE, frame.data);
}
が
これだと、ちょっと残念な結果になってしまいました。

これは、キャプチャした画像が、
- メモリ上に連続して配置されていない(走査線ごとに隙間がある)ことがある
- 一般に画像の原点は左上にあるが OpenGL のテクスチャの原点は左下にある
ということが理由なんだろうと思います。もしかしたら一つ目は、Mac に HomeBrew で入れた OpenCV 2.4.4 の問題かもしれません。Windows / Linux で 2.4.9 で試したら起こりませんでした。二つ目の上下反転の問題だけならテクスチャ座標をいじくることで解決できますけど、ここではキャプチャした画像を「走査線ごとに取り出して上下方向に逆順に詰め込む」という処理を行うことにします。これは、たとえば次のような手続きになります。
// 切り出した画像をテクスチャに転送する
glBindTexture(GL_TEXTURE_RECTANGLE, image);
for (int y = 0; y < frame.rows; ++y)
{
glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, frame.rows - y - 1, frame.cols, 1,
GL_BGR, GL_UNSIGNED_BYTE, frame.data + frame.step * y);
}
あるいは、flip() を使ってコピーを作るという方法もあります。
// 切り出した画像をテクスチャに転送する
cv::Mat flipped;
cv::flip(frame, flipped, 0);
glBindTexture(GL_TEXTURE_RECTANGLE, image);
glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, frame.cols, flipped.rows, GL_BGR, GL_UNSIGNED_BYTE, flipped.data);
ポリゴンを描画する
このテクスチャをバインドして先ほど準備したポリゴンを描画します。
// テクスチャユニットとテクスチャの指定
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_RECTANGLE, image);
// 描画に使う頂点配列オブジェクトの指定
glBindVertexArray(vao);
// 図形の描画
glDrawArrays(GL_TRIANGLE_FAN, 0, vertices);
// 頂点配列オブジェクトの指定解除
glBindVertexArray(0);
シェーダ
描画に使うシェーダプログラムは、めっちゃ単純です。まず、バーテックスシェーダです。頂点位置は pv っていう in 変数に渡すことにします。クリッピング空間の xy 平面ぴったりのポリゴンを描くので、座標変換は必要ありません。
#version 330
layout (location = 0) in vec4 pv;
void main(void)
{
gl_Position = pv;
}
フラグメントシェーダもテクスチャをサンプリングした値をそのままカラーバッファに描き込みます。GL_TEXTURE_RECTANGLE のテクスチャなので、サンプラに sampler2DRect を使います。このテクスチャ座標は画像と一致しますので、ディスプレイ上の画素の位置、すなわち gl_FragCoord の xy 成分をテクスチャ座標として使ってテクスチャをサンプリングすれば、キャプチャした画像と等しいサイズの画像を表示することができます。
#version 330
uniform sampler2DRect image;
layout (location = 0) out vec4 fc;
void main(void)
{
fc = texture(image, gl_FragCoord.xy);
}
こんな感じ。
