虚苦心観察ブログ

ブログ管理者である虚苦心が私利私欲に基づいて書いているブログです。主にガジェットのレビューだったり、画像処理のことだったりを記事にしています。

へっぽこ知識で作る画像処理プログラム 「自炊本のページ抜けチェック」 3日目

はじめに

前回の2日目では小説の画像に二値化を行い、積分画像を生成しました。
生成したところ、なんだか文字のある領域とない領域が判別出来そうだ、というところまでわかりました。

今回は前回生成した積分画像から文字のある領域とない領域を実際に判別してみようと思います。

文字のない領域の見つけ方

2日目の復習

2日目でもある程度説明しましたが、作成した積分画像からは以下のことがわかります f:id:ysuzuki116:20160605232954p:plain:w250

積分画像の下端(青)の横方向の変化を調べ、値が変化していない領域を調べれば以下のようになり、
f:id:ysuzuki116:20160608213645p:plain:w200
積分画像の右端(赤)の縦方向の変化を調べ、値が変化していない領域を調べれば以下のようになるはずです。
f:id:ysuzuki116:20160608213719p:plain:w200

さらにこの2つの調べた結果を合わせると、
f:id:ysuzuki116:20160608214131p:plain
となって文字のある領域と文字のない領域を判別できるはず!

なんとなく出来そうなのでプログラムを書いてみましょう。 書かなきゃわからないですし、2日目の結果からそれっぽい結果も得られていますし、何とかなるでしょう。

はじめてのピクセル

2日目ではOpenCVに用意されている関数を組み合わせてプログラムを書いていました。 しかし今回は画像を構成しているピクセルを直接触ります。

ポインタを扱うため、アクセスする範囲に注意が必要です。

プログラム自体はとても簡単で、難しいアルゴリズムは使わず、配列の1個前の要素の値と現在の要素の値を比較するだけです。
1個前の要素の値と現在の要素の値を比較し、値が同じなら白(255)、値が異なれば黒(0)とします。 値が同じということは変化のない領域、つまり文字のない領域のはずです。逆に値が異なるということは変化がある、つまり文字のある領域となるはずです。

まずは横方向だけ調べてみる

早速ですがプログラムです。ピクセルにアクセスするプログラムは長く読みにくくなりがちなので関数を定義しています。

//横方向をしらべる
//値が同じ連続領域を255、それ以外を0とするcv::Matを返す。
cv::Mat findSameValueHorizontal(const cv::Mat& src)
{
    //積分画像が欲しい
    CV_Assert(src.type() == CV_32SC1);

    //結果は白黒でいいから8bitの1チャンネル。しかも1行だけ
    cv::Mat dst(1, src.cols, CV_8UC1);
    //積分画像はintなのでint型のポインタを取得。下端なので位置はsrc.rows - 1
    const int* srcLine = src.ptr<int>(src.rows - 1);
    //結果は白黒の8bitなのでそのまま受け取っている。1行しかないので位置は0
    unsigned char* dstLine = dst.ptr(0);

    //とりあえず結果画像の一番左だけは右隣と比較する
    dstLine[0] = (srcLine[0] == srcLine[1]) * 255;
    for (int i = 1; i < src.cols; i++){
        //左隣と比較
        //比較結果(0または1)なので255を掛けて見えるように
        dstLine[i] = (srcLine[i] == srcLine[i-1]) * 255;
    }

    return dst;
}

ポインタの理解が浅いと読むのが大変かもしれませんが、難しい処理はないと思います。
ポインタを取得するメソッドptrは引数に与えた行の先頭を指すポインタを返します。 難しく書いてしまっていますが、要は積分画像の下端の1行分の配列と結果画像の1行分の配列を取ってきている感じです。

そして取ってきたのは配列なのでfor文で値を端から端まで比較し、ひとつ前の値と同じだったら白(255)、違かったら黒(0)に結果画像のピクセルをするだけです。
結果画像は以下になります。わかりやすいように枠をつけています。

f:id:ysuzuki116:20160610233509p:plain

バグではありません。結果画像のdstは1行の画像なのでこうなってしまいます。さすがに分かりにくいですね。 修正したものが以下になります。パワポで引き伸ばしました。

f:id:ysuzuki116:20160610233525p:plain

処理している原画像はというとこれでした。

f:id:ysuzuki116:20160610230658j:plain

非常にそれっぽい!(?)成功してそうです。プログラムを修正して原画像に重ね合わせて表示してみましょう。

先ほど載せたプログラムは画像を作るのではなく、文字のない領域を返すように変更しました。

//横方向をしらべる
//文字のない範囲をvectorで返す
void findSameValueHorizontal(const cv::Mat& src, std::vector<Range>& ranges)
{
    //積分画像が欲しい
    CV_Assert(src.type() == CV_32SC1);

    //値が入っているかもしれないので空にする
    ranges.clear();

    //積分画像はintなのでint型のポインタを取得。下端なので位置はsrc.rows - 1
    const int* srcLine = src.ptr<int>(src.rows - 1);

    Range range;
    for (int i = 1; i < src.cols; i++){
        //左隣と同じ値
        bool sameValue = srcLine[i] == srcLine[i - 1];
        //左隣と同じ値 かつ 範囲のstartが初期値(-1)のとき
        if (sameValue && range.start < 0){
            //文字のない範囲の始まり
            range.start = i - 1;
        }
        //左隣と違う値 かつ 範囲のstartが代入済み
        else if (!sameValue && range.start >= 0){
            
            //文字のない範囲の終わり
            range.end = i - 1;
            //結果として保存
            ranges.push_back(range);
            //文字のない範囲を初期値に戻す
            range.start = -1;
            range.end = -1;
        }
    }
    //最後の範囲が画像の右端まである場合はfor文を抜けてから結果を保存する
    //文字のない範囲のstartは代入済み かつ 範囲のendは初期値のとき
    if (range.start >= 0 && range.end < 0){
        range.end = src.cols - 1;
        ranges.push_back(range);
    }
}

さらに文字のない範囲を使って原画像に色を塗ります。

 //文字のない範囲を受け取る変数
    vector<Range> horizontalRanges;
    findSameValueHorizontal(integral, horizontalRanges);

    //文字のない範囲を書き込む画像
    cv::Mat horizontalRangeDst;
    //1チャンネルの原画像から3チャンネルの画像を作る
    cv::Mat srcArray[] = {image, image, image};
    cv::merge(srcArray, 3, horizontalRangeDst);

    //文字のない範囲を3チャンネルの原画像に書き込む
    for (size_t i = 0; i < horizontalRanges.size(); i++){
        Range& r = horizontalRanges[i];
        //文字のない範囲を3チャンネルの原画像から切り出す
        cv::Rect rect(r.start, 0, r.end - r.start + 1, horizontalRangeDst.rows);
        cv::Mat roi(horizontalRangeDst, rect);
        //切り出した画像を1色で塗りつぶす
        roi = cv::Scalar(240, 176, 0);
    }

結果は。

f:id:ysuzuki116:20160611001819p:plain

出来てるみたいですね!

縦方向も調べてみる

同様に縦方向も調べてみます。

 //文字のない範囲を受け取る変数
    vector<Range> verticalRanges;
    findSameValueVertival(integral, verticalRanges);

    //文字のない範囲を書き込む画像
    cv::Mat verticalRangeDst;
    //1チャンネルの原画像から3チャンネルの画像を作る
    cv::merge(srcArray, 3, verticalRangeDst);

    //文字のない範囲を3チャンネルの原画像に書き込む
    for (size_t i = 0; i < verticalRanges.size(); i++){
        Range& r = verticalRanges[i];
        //文字のない範囲を3チャンネルの原画像から切り出す
        cv::Rect rect(0, r.start, verticalRangeDst.cols, r.end - r.start + 1);
        cv::Mat roi(verticalRangeDst, rect);
        //切り出した画像を1色で塗りつぶす
        roi = cv::Scalar(0, 0, 255);
    }

結果はこちら。

f:id:ysuzuki116:20160615205102p:plain

縦方向も大丈夫そう!

縦横の結果を重ねる

せっかくなので縦方向の結果と横方向の結果を重ね合わせてみましょう。 横方向の結果画像に縦方向の結果を上書きしてしまいましょう。

//横方向の結果と縦方向の結果を合わせる
    for (size_t i = 0; i < verticalRanges.size(); i++){
        Range& r = verticalRanges[i];
        //文字のない範囲を3チャンネルの原画像から切り出す
        cv::Rect rect(0, r.start, horizontalRangeDst.cols, r.end - r.start + 1);
        cv::Mat roi(horizontalRangeDst, rect);
        //切り出した画像を1色で塗りつぶす
        roi = cv::Scalar(0, 0, 255);
    }

結果はこちら。

f:id:ysuzuki116:20160615205803p:plain

色合いが悪くて目が痛くなりそうですが、うまくいってるみたいです。 よしよし。

3日目まとめ

今回は実際に文字のない領域を検出するところまでプログラムを作成することができました。 これが出来ないとページの抜けをチェックするためのページ番号の抜き出しができません。だからこれができることは重要です。 出来なかったら処理方法を見直さなければなりませんからね。

今回の成果物はこちら

github.com

予告

次回は実際にページ番号を切り抜くところまで作ってみようと思います。

今回までのプログラムをそのまま使って作れそうですが、そうもいかない気がしています。 まずはリファクタリングを行い、そのあとに本題のページ番号の切り抜きを行おうと思います。

更新2週間に一回になりそう。。