細い横線をImageMagickで検出する……のに失敗した

ドキュメントスキャナの画像に垂直な細い線が入ることがある。センサー部分にゴミや汚れが付着するためで、複数のページに渡って線が入り続ける。

スキャン後の確認の際に、疑いのある線を目立たせることができる確認が簡単になる。そこでImageMagickを使って画像処理する方法を考えてみた。なお、ここでは細い横線を検出対象とする。

元画像

元画像

処理結果

処理結果

ここまではできたところで実際の画像を処理してみたところ、まったく使いものにならないことが分かった。

  • ドットが水平に並んだスクリーントーンがあると誤抽出する
  • 水平、垂直の縞があると誤抽出する

要するに画像処理方法がまるで適当ではなかったのだが、最初のサンプル画像を用意する際に実際の用途に合わせておけば早いタイミングで気付けたと思う。

ただしImageMagickの使い方を少し勉強できたので、その記録としてさらしておく。


サンプルとなる元画像は以下により作成した。

convert \
  -size 300x300 canvas:white \
  -fill white -stroke black \
  -strokewidth 5 \
  -draw 'rectangle 10,10,290,290' \
  -strokewidth 2 \
  -draw 'rectangle 30,30 270,270' \
  -strokewidth 1 \
  -draw 'rectangle 50,50 250,250' \
  -draw 'circle 150,150 50,150' \
  -draw 'polygon 150,50 250,150 150,250 50,150' \
  \( \
     +clone -negate \
     \( canvas:white -fill black -draw 'polygon 0,0 299,299 0,299' \) \
     -alpha off -compose copy_opacity +composite \
  \) \
  -compose over -composite \
  sample.jpg

まず、太い線を除外して細い線だけ抽出する。太い線を選んで除去するのは難しそうなので以下のようにする。

  1. 細い線を除去した画像を作る
  2. 元画像と1を重ね合わせて細い線以外の部分を除去する

これは-morphologyのerodeとdilateで実現できる。

erodeは黒領域を太らせ、dilateは白領域を太らせる。両方を使ってerode→dilateとすると小さい白領域を塗り潰すのに似た結果を得られる。これにより白地の黒線はそのまま残り、黒地の白線が消える。

convert sample.jpg -morphology erode octagon -morphology dilate octagon out1.jpg

openでもほぼ同じ結果を得られる。

convert sample.jpg -morphology open octagon out1.jpg
元画像

元画像

黒地の白細線を除去

黒地の白細線を除去

ここで後の説明を分かりやすくするためにコマンドラインを整理しておく。

apply_mpg_open=(-morphology open octagon) # 細い線を除去する
convert sample.jpg "${apply_mpg_open[@]}" out1.jpg

続いて白地の黒線を消すため、元画像を白黒反転して同じことをする。

convert sample.jpg -negate "${apply_mpg_open[@]}" out2.jpg
元画像

元画像

色を反転して(元)白地の(元)黒細線を除去

色を反転して(元)白地の(元)黒細線を除去

両者を足し合わせると白地の黒線と黒地の白線を抽出できる。

convert sample.jpg \
  \( -clone 0         "${apply_mpg_open[@]}" \) \
  \( -clone 0 -negate "${apply_mpg_open[@]}" \) \
  -delete 0 \
  -compose plus -composite \
  out3.jpg
元画像

元画像

細線のみ残す

細線のみ残す

ImageMagickは内部でリストを保持している。引数で指定した入力ファイルはそのリストに加えられている。

(はリストに新たな要素の追加を開始する命令で、-clone 0は要素の実体として最初の画像を復製する。-delete 0は最初の要素をリストから削除し、-compositeはリストの要素を重ね合わせる。

番号 画像 終了時の状態
0 sample.jpg -delete 0で削除される
1 黒地の白細線を除去した画像 -compositeで要素2と重ねられる
2 色を反転して(元)白地の(元)黒細線を除去した画像 -compositeで要素1と重ねられる

整理しておく。

extract_thin_lines=(
  \( -clone 0         "${apply_mpg_open[@]}" \) # 元画像を復製して白地の黒細線を除去 [a]
  \( -clone 0 -negate "${apply_mpg_open[@]}" \) # 元画像を復製・反転して(元)黒地の(元)白細線を除去 [b]
  -delete 0                                     # 元画像を処理対象から外す
  -compose plus -composite                      # [a]と[b]を足し合わせる
)
convert sample.jpg "${extract_thin_lines[@]}" out3.jpg

ここまでのerodeにはoctagonというおおむね円形のカーネルを指定した。正確な表現ではないかもしれないがoctagonはある点の全周を処理対象とする。

これに対し、横長カーネルを使用すると全周ではなく水平方向のみ処理対象とすることができる。erodeの場合は水平方向に塗り漬すことになり、結果としてある程度の長さのある横線のみ抽出できる。

convert sample.jpg \
  "${extract_thin_lines[@]}" \
  -negate \
  -morphology erode rectangle:10x1 \
  out4.jpg
元画像

元画像

水平方向の細線のみ残す

水平方向の細線のみ残す

少しノイズが残るのでerodeを繰り返す。このサンプルでは3回がちょうどよい。(繰り返しすぎると抽出した横線が短くなってしまう。)

convert sample.jpg \
  "${extract_thin_lines[@]}" \
  -negate \
  -morphology erode:3 rectangle:10x1 \
  out5.jpg
元画像

元画像

水平方向の細線のみ残す(強め)

水平方向の細線のみ残す(強め)

整理。

extract_horizontal_thin_lines=(
  "${extract_thin_lines[@]}"
  -negate
  -morphology erode:3 rectangle:10x1            # 水平方向の線のみ残す
)
convert sample.jpg \
  "${extract_horizontal_thin_lines[@]}" \
  out5.jpg

こうして求めた横線を元画像にいい感じに重ねるためのパターンを作る。

convert sample.jpg \
  "${extract_horizontal_thin_lines[@]}" \
  -morphology dilate octagon \
  -threshold 60% \
  out6.jpg
元画像

元画像

水平方向の細線を目立たせるためのパターン

水平方向の細線を目立たせるためのパターン

重ねてみる。

convert sample.jpg \
  \( +clone \
     "${extract_horizontal_thin_lines[@]}" \
     -morphology dilate octagon \
     -threshold 60% \
     -fill '#808080' -opaque white \
     -background red \
     -alpha shape \
  \) \
  -colorspace rgb \
  -compose over -composite \
  out7.jpg

+cloneは画像リストの最後の要素を復製する。この場合は-clone 0と同じ意味になる。

元画像

元画像

水平方向の細線を目立たせる

水平方向の細線を目立たせる

実際のスキャン画像(600DPI、4,053×5,952)で試してみたところ、これくらいにしたほうがよさそうだった。

extract_horizontal_thin_lines=(
  "${extract_thin_lines[@]}"
  -negate
  -morphology erode:3 rectangle:30x1            # 水平方向の線のみ残す(強め)
)
convert sample.jpg \
  \( +clone \
     "${extract_horizontal_thin_lines[@]}" \
     -morphology dilate disk:10 \
     -threshold 60% \
     -fill '#C0C0C0' -opaque white \
     -background red \
     -alpha shape \
  \) \
  -colorspace rgb \
  -compose over -composite \
  out8.jpg
元画像

元画像

水平方向の細線を目立たせる(強め)

水平方向の細線を目立たせる(強め)

整理。

build_horizontal_overlay=(
  "${extract_horizontal_thin_lines[@]}"
  -morphology dilate disk:10                    # 抽出できた部分を目立たせるために太らせる
  -threshold 60%                                # 濃淡が出ないよう二値化する
  -fill '#C0C0C0' -opaque white                 # ほどよい濃さに調整する
  -background red -alpha shape                  # 透化する赤色のマーカーにする
)
convert sample.jpg \
  \( +clone "${build_horizontal_overlay[@]}" \) \
  -colorspace rgb \
  -compose over -composite \
  out8.jpg

他の画像で動作確認したところ、まず、地色を抜いていないときは二値化しておいたほうがよさそうだった。

convert input.jpg \
  \( +clone \
     -auto-threshold otsu \
     "${build_horizontal_overlay[@]}" \
  \) \
  -colorspace rgb \
  -compose over -composite \
  output.jpg

また、細かい網掛けがあるとうまくいかない。まんがのスクリーントーンよりも一般の書籍の表やコラムの背景色がある部分がダメで、軽く平滑化するとある程度は改善できた。

convert input.jpg \
  \( +clone \
     -blur 1 \
     -auto-threshold otsu \
     "${build_horizontal_overlay[@]}" \
  \) \
  -colorspace rgb \
  -compose over -composite \
  output.jpg

限界はあるもののそれなりに使える結果を得られた。


……などと思っていたのだが、実際の画像を処理してみるとぜんぜんダメだった。細い線を入れてみたらまるで検出できないし、スクリーントーンのドットを横線が横線になってしまう。

サンプル画像2

サンプル画像2

処理結果2

処理結果2

その上、現状でかなり遅い。600DPI、4,053×5,952の画像の処理に4.5秒かかった。200ページなら単純計算で15分もかかることになり実用的とは言えない。コマンドラインを変えながら動きを確認してみたところ-morphologyの処理に時間が大部分を占めており、したがってコマンドラインの書き方を工夫しても短縮は難しい。

そういわけで、OpenCVでの加工を考えてみた。ほとんどサンプルからのコピペだけど。

#include <iostream>
#include "opencv2/imgproc.hpp"
#include "opencv2/ximgproc.hpp"
#include "opencv2/imgcodecs.hpp"
#include "opencv2/highgui.hpp"

using namespace std;
using namespace cv;
using namespace cv::ximgproc;

int main(int argc, char** argv)
{
    CommandLineParser parser(
        argc, argv,
        "{@input|<none>|input filename}"
        "{@output||output filename}"
        "{skip-normalize s||skip normalize}"
        "{help h||show help message}"
    );
    if (parser.has("help")) {
        parser.printMessage();
        return 0;
    }
    if (!parser.has("@input")) {
      return -1;
    }

    string in = samples::findFile(parser.get<string>("@input"));
    string out = parser.get<string>("@output");
    Mat orig_image = imread(in, IMREAD_GRAYSCALE);
    if (orig_image.empty()) {
        return -1;
    }

    Mat image;
    if (parser.has("skip-normalize")) {
      image = orig_image;
    }
    else {
      Mat o_image, o_inv_image, inv_image;
      morphologyEx(orig_image, o_image, MORPH_OPEN, getStructuringElement(MORPH_ELLIPSE, Size(5, 5)));

      bitwise_not(orig_image, inv_image);
      morphologyEx(inv_image, o_inv_image, MORPH_OPEN, getStructuringElement(MORPH_ELLIPSE, Size(5, 5)));

      addWeighted(o_image, 1, o_inv_image, 1, 1, image);
    }

    int length_threshold = orig_image.cols / 50;
    float distance_threshold = 1.41421356f;
    double canny_th1 = 50.0;
    double canny_th2 = 50.0;
    int canny_aperture_size = 3;
    bool do_merge = false;
    Ptr<FastLineDetector> fld = createFastLineDetector(length_threshold,
            distance_threshold, canny_th1, canny_th2, canny_aperture_size,
            do_merge);

    vector<Vec4f> lines;
    fld->detect(image, lines);

    bool found = false;
    Mat result_image, fld_image;

    for (int i = 0; i < lines.size(); i++) {
      float x1, y1, x2, y2;
      x1 = lines[i][0];
      y1 = lines[i][1];
      x2 = lines[i][2];
      y2 = lines[i][3];
      if (abs(y1 - y2) < 2) {
        if (!found) {
          cvtColor(orig_image, orig_image, COLOR_GRAY2BGR);
          fld_image = orig_image.clone();
        }
        found = true;
        line(fld_image, Point(x1, y1), Point(x2, y2), Scalar(0, 0, 255), 10);
      }
    }

    if (!found) {
      return 1;
    }

    addWeighted(orig_image, 0.4, fld_image, 0.6, 0, result_image);
    if (out.empty()) {
      imshow("Result", result_image);
      waitKey();
    }
    else {
      imwrite(out, result_image);
    }

    return 0;
}
$ clang++ -std=c++11 $(pkg-config --cflags --libs opencv4) -Wall -o t t.cpp
$ ./t sample.jp out7_opencv.jpg
$ ./t sample2.jp out7_2_opencv.jpg
処理結果(OpenCV)

処理結果(OpenCV)

処理結果2(OpenCV)

処理結果2(OpenCV)