聞きかじりめも

主にC++やメディア処理技術などに関して気付いたことを書いていきます.ここが俺のメモ帳だ!

GLSLでOpenCV歪み補正を実装

OpenCVの歪み補正関数はcv::initUndistortRectifyMap()で予め作成したマッピング画像を使ってcv::remap()で歪んだカメラ画像を処理する,というものですが,このcv::remap()関数はOpenGLにやらせることもできます.それもプログラマブルシェーダGLSLを使った並列演算です.つまり,このテクニックを使えばGLSL上で並列化画像処理を実装することができるというわけです.(ここでは画像表示部をOpenGL/GLFWに任せています)

いやOpenCV使えよ

なぜOpenCVをすでに使っているのにわざわざそんなことをするかって?OK,これには理由が2つある.
まず一つ目は,画像処理を並列化することで実行速度の更なる向上が期待できるからだ.OpenCVの関数は既に強力な最適実装がされているものが多いが,それでもまだまだ最適化されていない実装もあるし,それに自分で作った画素アクセスを含む画像処理をもっと高速化するには並列化がおススメの場合が多い.せっかくOpenGLを使って画像表示しているのなら,プログラマブルシェーダを使ってGPUレベルで画像処理させるのが最も効率的なはず.だからGLSLを使うのだ.
もう一つの理由は,このような特殊なGLSLの使い方をすることでGPGPUによる並列演算テクニックの入り口に立つことができるからだ.まあつまり,GPU並列演算の初歩を学ぶという勉強目的ってこと.リアルタイム処理が必要なAR技術では今後必要になるであろう技術なので,勉強がてらOpenCVの機能を肩代わりするのがどういうことなのかを学んでおくことには価値がある.それにまだGLSLのこと良く知らないし.

というわけで,今回はcv::remap()OpenGLレイヤーで実装することに挑戦しました.

実装

ソースコード

#pragma once

#include <OpenGLHeader.h>
#include <Shader.h>
#include <opencv2\opencv.hpp>

class Remapper
{
private:
    GLFWwindow *imgWindow;
    GLuint vao;
    GLuint vbo;
    GLuint imageObj, mapxObj, mapyObj;
    Shader s;
    int vertices;
    // バーテックスシェーダ
    const char *vertexSource =
        "#version 330 core \n"
        "layout(location = 0) in vec4 pv;\n"
        "void main(void)\n"
        "{\n"
        "    gl_Position = pv;\n"
        "}\n";
    // フラグメントシェーダ
    const char *fragmentSource =
        "#version 330 core \n"
        "uniform sampler2DRect image;\n"
        "uniform sampler2DRect map_x;\n"
        "uniform sampler2DRect map_y;\n"
        //"uniform vec2 resolution;\n"
        "layout(location = 0) out vec4 fc;\n"
        "void main(void)\n"
        "{\n"
        "   vec4 mapx = texture(map_x, gl_FragCoord.xy);\n"
        "   vec4 mapy = texture(map_y, gl_FragCoord.xy);\n"
        "    fc = texture(image, vec2(mapx.x, mapy.x));\n"
        "}\n";

public:

    Remapper()
    {
    }
    Remapper(GLFWwindow *window)
    {
        init(window);
    }
    void init(GLFWwindow *window)
    {
        int w, h;
        glfwMakeContextCurrent(window);
        glfwGetWindowSize(window, &w, &h);
        imgWindow = window;

        // 頂点配列オブジェクト
        glGenVertexArrays(1, &vao);
        glBindVertexArray(vao);

        // 頂点バッファオブジェクト
        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 }
        };
        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);

        // テクスチャ
        glGenTextures(1, &imageObj);
        // source image について設定
        glBindTexture(GL_TEXTURE_RECTANGLE, imageObj);
        glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGB8, w, h, 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);
        // map_x について設定
        glGenTextures(1, &mapxObj);
        glBindTexture(GL_TEXTURE_RECTANGLE, mapxObj);
        glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R32F, w, h, 0, GL_RED, GL_FLOAT, 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);
        // map_y について設定
        glGenTextures(1, &mapyObj);
        glBindTexture(GL_TEXTURE_RECTANGLE, mapyObj);
        glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R32F, w, h, 0, GL_RED, GL_FLOAT, 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);

        // シェーダのロード
        s.initInlineGLSL(vertexSource, fragmentSource);
    }
    void remap(cv::Mat src, cv::Mat &dst, cv::Mat map_x, cv::Mat map_y)
    {
        glfwMakeContextCurrent(imgWindow);
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        s.enable();// シェーダプログラムの使用開始
        GLint res[2] = { src.cols, src.rows };

        // srcをテクスチャ0に転送する
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_RECTANGLE, imageObj);
        glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_BGR, GL_UNSIGNED_BYTE, src.data);
        // map_xをテクスチャ1に転送する
        glActiveTexture(GL_TEXTURE1);
        glBindTexture(GL_TEXTURE_RECTANGLE, mapxObj);
        glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_RED, GL_FLOAT, map_x.data);
        // map_yをテクスチャ2に転送する
        glActiveTexture(GL_TEXTURE2);
        glBindTexture(GL_TEXTURE_RECTANGLE, mapyObj);
        glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_RED, GL_FLOAT, map_y.data);

        glUniform1i(glGetUniformLocation(s.program, "image"), 0);       // uniform変数"image"にテクスチャユニット0(GL_TEXTURE0)を指定
        glUniform1i(glGetUniformLocation(s.program, "map_x"), 1);
        glUniform1i(glGetUniformLocation(s.program, "map_y"), 2);
        //glUniform2fv(glGetUniformLocation(s.program, "resolution"), 1, res);

        glBindVertexArray(vao);// 描画に使う頂点配列オブジェクトの指定
        glDrawArrays(GL_TRIANGLE_FAN, 0, vertices);// 図形の描画
        glBindVertexArray(0);// 頂点配列オブジェクトの指定解除
        
        s.disable();// シェーダプログラムの使用終了
        glBindTexture(GL_TEXTURE_RECTANGLE, 0);

        // 読み込み
        dst = cv::Mat(src.size(), CV_8UC3);
        glReadBuffer(GL_BACK);
        glReadPixels(0, 0, dst.cols, dst.rows, GL_BGR, GL_UNSIGNED_BYTE, dst.data);

        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    }
    ~Remapper()
    {
    }
};

例によって以前の記事で挙げたShaderクラスを使っています.OpenGLHeader.hにはOpenGLの他にGLFWとGLEW,GLMが含まれています.使い方は前回のGLImageクラスとほぼ同じです.Remapper::remap()のパラメータは順に,ソース画像(8bit,3ch),出力画像(8bit,3ch),マッピング画像(32bit,1ch)2枚,という構成で,ほぼcv::remap()と同様に使えます.

解説

フラグメントシェーダは,次のように書いてます.

 const char *fragmentSource =
        "#version 330 core \n"
        "uniform sampler2DRect image;\n"
        "uniform sampler2DRect map_x;\n"
        "uniform sampler2DRect map_y;\n"
        //"uniform vec2 resolution;\n"
        "layout(location = 0) out vec4 fc;\n"
        "void main(void)\n"
        "{\n"
        "   vec4 mapx = texture(map_x, gl_FragCoord.xy);\n"
        "   vec4 mapy = texture(map_y, gl_FragCoord.xy);\n"
        "    fc = texture(image, vec2(mapx.x, mapy.x));\n"
        "}\n";

やってることは簡単で,map_xmap_yの画素値には浮動小数点で変形先の座標値が格納されているので,texture()で現在座標gl_FragCoord.xyの値(=移動先の画素値)を呼び出し,新たに得たリマップ先の座標値の画素値vec2(mapx.x, mapy.x)を本来の画像テクスチャimageからサンプルする,という実装になっています.ここのテクスチャサンプラsampler2DRect imageはサンプリング時に自動で線形補間するようにRemapper::init()内で設定していますので,これだけですべての画素について滑らかに変形できます.
ちなみに,わざわざ\nで改行するのは,コンパイルエラーが起きた場合に行数を把握しやすくするためです.\nを省略することもできますが,その場合ソースコードを1行で書いたとみなされてしまい,吐き出されるコンパイルエラーも行数が分からなくなってしまいます.

Remapper::remap()では,一度レンダリングしてからSwapせずに,直接Swap前のバッファにアクセスして画像データを読み込み,cv::Matに書き出しています.その後,バッファを全クリアして終了です.つまり,この一連の流れでcv::Mat型からcv::Mat型に変換する画像処理をシェーダレベルで実行したことになります.

なお,OpenGLOpenCVでは画像テクスチャのY軸が逆ですが,ここでは反転処理を書いていません.なぜなら,今回の処理ではOpenGLに描かせた後にまたOpenCVに渡すので,Y軸が2回反転して結局元に戻るからです.もちろん,反転処理を入れればそれだけ遅くなります.GLSL上で反転させればそんなに速度変わらんかもしれんけど・・・

実装で苦労したところ

なぜかmap_xmap_yテクスチャが転送できないという問題に陥りました.ほぼ前回のGLImageと同じだから,正しくソースコードが書けているはずなのに・・・
しかし,原因は(やっぱりって感じだけど)glTexImage2D()及びglTexSubImage2D()の設定にありました.本来ならば

     glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R32F, w, h, 0, GL_RED, GL_FLOAT, NULL);
        glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_RED, GL_FLOAT, map_x.data);

と書くべきところを,

     glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R, w, h, 0, GL_R, GL_FLOAT, NULL);
        glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_R, GL_FLOAT, map_x.data);

とか

     glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_LUMINANCE, w, h, 0, GL_LUMINANCE, GL_FLOAT, NULL);
        glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_LUMINANCE, GL_FLOAT, map_x.data);

とか書いていたわけです.間違えどころはinternal format(正しいコードでGL_R32F)とformat(正しいコードでGL_RED)の値の組み合わせで,推奨される正しい組み合わせはOpenGLのリファレンスに書いてあります.GL_RとかGL_LUMINANCEとかはここには使えないくせに,こんなGL_REDとほぼ同じ意味のグローバル定数を別の値として用意しているOpenGLに俺は文句を言いたい.更に言うと,3chの画像でGL_RGB2chの画像(某掲示板の事じゃないゾ!)GL_RGって書くなら普通1chならGL_Rかなーとか思いこんじゃうでしょ…

くそが!!!!!

ふぅ,すっきりした.

結果

補正前

f:id:Mzawa2:20160809155714p:plain

OpenCVで補正(cv::remap())

f:id:Mzawa2:20160809155757p:plain

OpenGL(GLSL)で補正(Remapper::remap())

f:id:Mzawa2:20160809155838p:plain

おお!ちゃんとできてる!これで並列化した高速画像処理演算ができるように…ん?

OpenCV: 0.014ms / 71.6fps

OpenGL: 0.016ms / 61.1fps

OpenCVの方が圧倒的に速いじゃんッッッ!!!

というわけで,まる2日かけて書いた今回の実装は完全に徒労に終わりました.でもきっと,今回の経験は将来の役に立つでしょう…

参考文献

床井研究室 - GLSL で画像処理 (1) 画像を取り込む

床井研究室 - GLSL で画像処理 (3) ワーピング

実は今回の記事は↑の丸パクリリファインです.床井先生いつも参考にさせてもらってます.ありがとうございます.

追記(2016/09/07)

この記事によるとどうやらglPixelRead()がめちゃ遅いらしく,PixelBufferObject(PBO)を活用するともっと高速化できそうです.PBOは4K映像も30FPSいけるくらい速いらしいし,今度試してみよう.