ページ

2015-07-21

OpenCvSharpで表紙の折り目を検知してみた

いわゆる本の自炊をする際、表紙は切らず長尺読みをし、それを分割して使う。その分割作業をサポートするために作ったのがKiritoriMageなのだけど、この切り取る座標を決定するのを自動化できるんじゃね?ってことでやってみた。



詳しくは以下に記述するが、要は折られている以上折り目が存在するので、それを検知すれば、どこで切ればいいかわかるという作戦。その折り目を見つけるにも、印刷されているほうを使うと大変なので、一緒にスキャンされる裏側を使うことにした。

それを実装したのがKiritoriMage 0.0.2で、切りたい画像とセットになる裏側を一緒にドラッグアンドドロップしてあげればこの機能が動作する。試してみたい方はこちらからどうぞ。
https://github.com/sunnyone/kiritorimage/releases/tag/v0.0.2


処理の詳細

OpenCvSharpの使い方例として、処理内容を解説していく。わかりやすさのために、順番を差し替えたり、定数をリテラルにしたりしている。また、OpenCvSharpなので実際の呼び出しとは異なるが、解説が詳しいので、opencv.jpのC/C++のコードの説明をリンクしている。

今よりずっとWPFを使えないときに書いたために、ソースはだいぶきれいではないのでKiritoriMageを読むのはお勧めしないが、この処理自体はFoldDetectUtil.csにある。

今回の例に使う画像を縮小(+α)したものがこちら。実際の表紙を使うと面倒なので、適当な表紙を作った。
この偽表紙は実際の表紙と異なり普通紙でガサガサなので、裏にはちょっとNRをかけてある。


Phase1 折り目候補の洗い出し

画像に手を加えることで、折り目らしき部分を抽出する。

1-1. 画像の読み込み

簡単。Matクラスのコンストラクタにファイル名を渡すだけ。
var matSrc = new Mat(backFilename);

1-2. AdaptiveThresholdによる2値化

これが全体の中で一番肝だと言っていい。最初使ったときは感動した。

ただの2値化は固定値か全体の値を見て白か黒かにするという感じだけど、AdaptiveThresholdを使うとピクセルの周りを見て白か黒か選んでくれる(参考: OpenCvSharpをつかう その15(適応的閾値処理))。全体を見てしまうとこの紙の裏の画像はほとんど差がないので、自動的にやろうとするとどうしても「全部白!」or「全部黒!」となってしまうのだけど、折り目境界部分は比較的違いがはっきりしているので折り目をはっきりと浮かび上がらせてくれる。

コード的には、CvtColorでグレースケールに変換した後、AdaptiveThresholdを呼べばOK。KiritoriMageでは、blockSizeは幅の1/200を奇数にしたもの(4000なら21)としている。
var mat = matSrc.CvtColor(ColorConversion.BgrToGray)
    .AdaptiveThreshold(255, AdaptiveThresholdType.MeanC, ThresholdType.Binary, blockSize, 1);

後述の輪郭検出の都合が良いので、反転しておく。
mat = ~mat;

1-3. 高さ1にリサイズ後、平滑化をかけて2値化

折り目は縦一線にできるので、Resizeで思い切って高さ1にする。この処理ではInterpolation.Areaが一番よかった。
Cv2.Resize(mat, mat, new Size(matSrc.Width, 1), interpolation: Interpolation.Area);
(高さ1ピクセルでは見えないので、この状態で高さを戻すリサイズをかけた状態はこんな感じ)

そのままでは黒と白が細かく散っているので、平滑化する。輪郭を残してくれそうなので、MedianBlurをチョイス。KiritoriMageではksizeはさっきのblockSizeと一緒にしている(動かしてて困らない数値だっただけで、一緒にしたいという意図はない)。
mat = mat.MedianBlur(ksize)

(高さ1は見えないので、高さを戻した状態の画像)

終わったら再度2値化する。ここでは大津の2値化を使った。
mat = mat.Threshold(0, 255, OpenCvSharp.ThresholdType.Otsu);

(再び2値化後の高さを戻した状態の画像)

1-4. 高さを作り輪郭検出

1次元のデータなのでこのまま回して分割していってもいいのだけど、だるいので高さを作ってOpenCVの輪郭検出FindContoursにまかせて、その輪郭を囲む矩形の座標をもらう。…なんて無駄なのでしょう!遅くて使えなかったら本気出す。まぁここでFindCoutoursの例を出すために使ったと言っても過言ではない。なお、FindContoursはmatを破壊するので注意。
Cv2.Resize(mat, mat, new Size(matSrc.Width, 3), interpolation: Interpolation.Cubic);
var rects = mat
    .FindContoursAsArray(ContourRetrieval.External, ContourChain.ApproxSimple)
    .Select(x => Cv2.BoundingRect(x))
    .OrderBy(x => x.X)
    .ToArray();

(選択できたものをCv2.Rectangleで書いた画像)

ここで折り目候補ができたが、ここまでで90%、成否が決まっているといっても過言ではない。候補が大量にできていてはうまくいかないし、そもそも各折り目がどれか検出されなかった時点でアウト。

本当はここで処理を完了したかったのだけど、そんな調子なので、折り目だけを検出するようにパラメータを構成するのは難しく、どれかの折り目が検出されなくなってしまうことがほとんどなので、候補から適切なものを選択することにした。

Phase2 折り目候補から折り目4つを選択

「表紙の紙の折り目」である、という特徴を使って、一番選択肢として妥当なものを選択する。

2-1. 折り目候補の一覧から4つを選ぶ全ての組み合わせの算出

いわゆる順列/組み合わせ。候補から4つを選ぶ組み合わせを列挙する。コードは省略。そこまで長くはないし、実際自前で実装しているんだけど、NuGetでパッとインストールできるこの手の処理が集まってるライブラリの都合のいいやつがないのよね。LINQに入っていてもおかしくない汎用さなんだけど。

2-2. 座標、幅をピクセル数から全体幅との比率に変換

まず各組み合わせについて、画像のサイズは毎回違っていて扱いにくいので、全体の幅で割って割合にする。書いていて思ったが先にやっておいたほうが計算量は少ないね。
var widthRatios = rectCands.Select(r => new { 
    Left = r.X / imageWidth, 
    Right = (r.X + r.Width) / imageWidth,
    Width = r.Width / imageWidth }).ToArray();

2-3. 幅が3%以上なら組み合わせを却下

組み合わせのひとつひとつを見て、折り目が太すぎるもの、具体的には全体幅の3%越えが存在するものはおかしいのでその組み合わせは却下する。過去データを見る限り、1%くらいだったので、余裕を持って3%。
rectSets = rectSets.Where(r => r.WidthRatios.All(x => x.Width < 0.03)).ToArray();

2-4. 細い同士、太い同士の割合(thinRatio, fatRatio)を計算

表紙の紙の特徴として、基本的に表紙と裏表紙(「太いほう」)は同じ幅だし、折り込まれた表紙の内側と裏表紙の内側(「細いほう」)も同じ幅のはず。そのため、表紙の幅/(表紙の幅+裏表紙の幅)という感じで太いほう、細いほうそれぞれの片側が占める割合を計算して(thinRatio, fatRatio)とする。
double leftThin = widthRatios[0].Left;
double rightThin = 1 - widthRatios[3].Right;
double thinRatio = leftThin / (leftThin + rightThin);

double leftFat = widthRatios[1].Left - widthRatios[0].Right;
double rightFat = widthRatios[3].Left - widthRatios[2].Right;
double fatRatio = leftFat / (leftFat + rightFat);
その(thinRatio, fatRatio)と(0.5, 0.5)の距離を計算して、一番近かったものを選んで、これを答えとする。
double thinFatDistance = Math.Sqrt(Math.Pow(fatRatio - 0.5, 2) + Math.Pow(thinRatio - 0.5, 2));

var bestMatchRectSet = rectSets.OrderBy(x => x.ThinFatDistance).First();

これで選ばれたものがこちら。


あとは切るX座標として0, 1, 3番目の右側を選んで終了。

いくつか手法を試して、結果的にわりとシンプルなやりかたに落ち着いた。太いほう:細いほうの比はあまり極端ではないはずなので、そこを基準にフィルタしてもいいかなと思っているけども、使い込んでいないのでまぁ様子を見てという感じ。一晩でやれたらいいかなくらいだったものを、だいぶ頑張って実装してしまった感があるので、ここで一区切りかな。

0 件のコメント:

コメントを投稿