2D プロット・グラフの目盛数字とラベルの認識 (その3)

データ・マークから数値に変換するには、 グラフの縦軸・横軸の目盛に打ってある数字列を読み取って数を求めておく必要があります。 さらに、 そのデータがどのような物理量かを知るためには縦軸・横軸のラベルを読んでおかなければいけません。 ラベルに単位が記入されているので、 本来なら単位も解釈できた方が良いのですが、そこは後回しにして、 今回は単に文字列として読むところまでを試します。

画像中の文字列を探して赤い四角で囲んでいます。 文字かどうかは出現場所で判別しており、 流行りの文字画像の特徴検出を使っていません。 opencv 3 には文字画像の特徴検出が contrib に収録してあるので、 そのうち試してみるつもりです。 検出できたそれぞれの領域を OCR で文字認識させて出力させます。 OCR ライブラリには、 tesseract を使うことにします。 opencv 3 には、 tesseract を opencv から使うアダプタが配布されていますが、 opencv 2.4 にはないので、 直接 tesseract ライブラリの BasicAPI を使うことにします。 tesseract への言語の指定を eng で試しているため、 Å を A とみなしてしまっています。 この辺は言語を追加することで良いのかどうか、 試してみないといけないようです。

$ clang++ -std=c++11 `pkg-config --cflags opencv` plot2dlabels.cpp -o plot2dlabels `pkg-config --libs opencv` -lm -ltesseract
$ ./plot2dlabels plot.png
y-axis label="Tc/K"
x-axis label="d/A"
(636, 893) "50"
(1103, 893) "100"
(178, 893) "0"
(119, 776) "2.0"
(118, 84) "3.0"

今回から、 2D プロットを解釈する途中のコンテキストを保持する構造体を使うことにします。 また、 opencv 3 への乗り換えを考慮して、 opencv2 ヘッダを使うようにします。

#include <opencv2/opencv.hpp>
#include <tesseract/baseapi.h>
#include <cstdlib>
#include <string>
#include <iostream>
#include <algorithm>

struct plot2d_type {
    cv::vector<cv::vector<cv::Point> > contours;
    cv::vector<cv::Vec4i> hierarchy;
    cv::vector<cv::Rect> bound;
    cv::vector<cv::Rect> frame;
    cv::vector<int> frame_idx;
    cv::Rect yaxis_label_rect;
    cv::Rect xaxis_label_rect;
    std::vector<cv::Rect> scale_label_rect;
    std::string yaxis_label;
    std::string xaxis_label;
    std::vector<std::string> scale_label;

    void detect_frame (cv::Mat& bin_img);
    void check_frame (cv::Mat& img, int const i);
    void detect_yaxis_label_rect (cv::Mat& gray_img);
    void check_yaxis_label_rotation (void);
    void detect_xaxis_label_rect (cv::Mat& gray_img);
    void detect_scale_label_rect (cv::Mat& gray_img);
    void read_yaxis_label (tesseract::TessBaseAPI& tess, cv::Mat& gray_img);
    void read_xaxis_label (tesseract::TessBaseAPI& tess, cv::Mat& gray_img);
    void read_scale_label (tesseract::TessBaseAPI& tess, cv::Mat& gray_img);

    std::string chomp (std::string const& s);
    void tesseract_call (tesseract::TessBaseAPI& api,
                         cv::Mat& img, cv::Rect& clip,
                         std::string& output,
                         std::string const& configfile);
};

int
main (int argc, char* argv[])
{
    plot2d_type plot2d;
    cv::Mat gray_img;
    cv::Mat bin_img;

    if (argc != 2) {
        std::cerr << "usage: " << argv[0] << " image-file-name" << std::endl;
        return EXIT_FAILURE;
    }
    cv::Mat img = cv::imread (argv[1], cv::IMREAD_UNCHANGED);
    if (! img.data)
        return EXIT_FAILURE;

    cv::cvtColor (img, gray_img, CV_BGR2GRAY);
    cv::threshold (gray_img, bin_img, 0, 255, cv::THRESH_BINARY|cv::THRESH_OTSU);
    bin_img = ~bin_img;

    plot2d.detect_frame (bin_img);
    if (plot2d.frame.size () != 2) {
        std::cerr << "cannot detect plot frame" << std::endl;
        return EXIT_FAILURE;
    }
    plot2d.detect_yaxis_label_rect (gray_img);
    plot2d.detect_xaxis_label_rect (gray_img);
    plot2d.detect_scale_label_rect (gray_img);

    tesseract::TessBaseAPI tess;
    plot2d.read_yaxis_label (tess, gray_img);
    plot2d.read_xaxis_label (tess, gray_img);
    plot2d.read_scale_label (tess, gray_img);

    cv::Mat dst = img.clone ();
    if (! plot2d.yaxis_label.empty ()) {
        std::cout << "y-axis label=\"" << plot2d.yaxis_label << "\"" << std::endl;
        cv::rectangle (dst, plot2d.yaxis_label_rect, cv::Scalar (0, 0, 255), 1, 8);
    }
    if (! plot2d.xaxis_label.empty ()) {
        std::cout << "x-axis label=\"" << plot2d.xaxis_label << "\"" << std::endl;
        cv::rectangle (dst, plot2d.xaxis_label_rect, cv::Scalar (0, 0, 255), 1, 8);
    }
    for (int i = 0; i < plot2d.scale_label.size (); ++i) {
        std::cout << "(" << plot2d.scale_label_rect[i].x << ", "
                         << plot2d.scale_label_rect[i].y << ") "
                  << "\"" << plot2d.scale_label[i] << "\"" << std::endl;
        cv::rectangle (dst, plot2d.scale_label_rect[i], cv::Scalar (0, 0, 255), 1, 8);
    }
    cv::namedWindow ("SCALE-LABEL", cv::WINDOW_NORMAL);
    cv::imshow ("SCALE-LABEL", dst);
    cv::waitKey ();

    return EXIT_SUCCESS;
}

detect_framecheck_frame は、 前回まで関数だったものをメソッドにして取り込んだものです。 画像中の輪郭を抽出し、 輪郭の外接四角形を求めてから、 それらの中から枠を探します。 そして輪郭と枠をメンバへ書き込んでおきます。 グラフに理論曲線が含まれているとき、 枠の内線を cv::approxPolyDP で求めることができない場合があることに気がついたので、 その 1 のやりかたから変更し、 ネストしている輪郭の外接四角形を足し合わせるやりかたに変更しました。

void
plot2d_type::detect_frame (cv::Mat& bin_img)
{
    cv::Mat ctr_img = bin_img.clone ();
    cv::findContours (ctr_img, contours, hierarchy, CV_RETR_CCOMP, CV_CHAIN_APPROX_SIMPLE);
    for (int i = 0; i >= 0; i = hierarchy[i][0]) {
        bound.push_back (cv::boundingRect (contours[i]));
        check_frame (bin_img, i);
    }
}

void
plot2d_type::check_frame (cv::Mat& img, int const i)
{
    double area = cv::contourArea (contours[i], false);
    if (area <= img.cols * img.rows / 4)
        return;
    cv::vector<cv::Point> approx;
    double epsilon = 0.01 * cv::arcLength (contours[i], true);
    cv::approxPolyDP (cv::Mat (contours[i]), approx, epsilon, true);
    if (approx.size () != 4)
        return;
    if (hierarchy[i][2] < 0)
        return;
    // 枠の長方形部の外辺
    cv::Rect outer_rect = cv::boundingRect (approx);
    // 枠の長方形部の内辺
    cv::Rect inner_rect;
    for (int i1 = hierarchy[i][2]; i1 >= 0; i1 = hierarchy[i1][0]) {
        cv::Rect r = cv::boundingRect (contours[i1]);
        // 枠には外向きの目盛やマークが重なっていることがあるため、
        // ネストしている輪郭は、 長方形の外辺からはみだしている箇所がありえます。
        // なので、 内辺を求めるには、 長方形の外辺の内側にあるものだけを使います。
        if ((outer_rect & r) == r) {
            if (inner_rect.width == 0)
                inner_rect = r;
            else
                inner_rect |= r;
        }
    }
    // 枠の線は細いはずなので、 外辺と内辺はほとんど同じでないといけません。
    if (outer_rect.width - inner_rect.width > 24 || outer_rect.height - inner_rect.height > 24) {
        return;
    }
    // 枠が見つかったと信じて、 目盛やマークの座標計算のための中心線を求めます。
    cv::Rect center_rect ((outer_rect.x + inner_rect.x) / 2,
        (outer_rect.y + inner_rect.y) / 2,
        (outer_rect.width + inner_rect.width) / 2,
        (outer_rect.height + inner_rect.height) / 2);
    // frame[0] は、 枠とそれに上書きされている目盛とマークを含めた外接長方形
    frame.push_back (cv::boundingRect (contours[i]));
    // frame[1] は、 枠の長方形部分の中心線。
    frame.push_back (center_rect);
    // frame[0] にどの輪郭を使ったか記録しておきます。
    frame_idx.push_back (i);
    frame_idx.push_back (hierarchy[i][2]);
#if 0
    for (int i1 = hierarchy[i][2]; i1 >= 0; i1 = hierarchy[i1][0]) {
        double area1 = cv::contourArea (contours[i1], false);
        if (area1 <= img.cols * img.rows / 4)
            continue;
        cv::vector<cv::Point> approx1;
        double epsilon1 = 0.01 * cv::arcLength (contours[i1], true);
        cv::approxPolyDP (cv::Mat (contours[i1]), approx1, epsilon1, true);
        if (approx1.size () == 4) {
            frame.push_back (cv::boundingRect (contours[i]));
            cv::Rect r0 = cv::boundingRect (approx);
            cv::Rect r1 = cv::boundingRect (approx1);
            cv::Rect r ((r0.x + r1.x) / 2, (r0.y + r1.y) / 2,
                        (r0.width + r1.width) / 2, (r0.height + r1.height) / 2);
            frame.push_back (r);
            frame_idx.push_back (i);
            frame_idx.push_back (i1);
            return;
        }
    }
    return;
#endif
}

目盛の数字列も軸のラベルも枠の外にあることを利用して、 検出した輪郭のうち枠の外にあるものを調べて、 ラベルの文字列を囲む長方形、 目盛の数字列を囲む長方形を検出します。

これらのテキスト領域は、 縦軸のラベルが反時計回りに π/2 回転しているのを除くと、 他は素直な横書きになっています。 そこで、 最初に縦軸のラベルを囲む長方形領域の検出から始めることにします。 検出方法は縦方向へピクセルごとにヒストグラムを求めて、 最も左側の山を選ぶ方法と同じで、 それを輪郭外接四角形の重ね合わせでシミュレートします。 輪郭外接四角形を縦方向に重ね書きしていき、 もっとも左にある重ね書きされた長方形を選びます。

void
plot2d_type::detect_yaxis_label_rect (cv::Mat& gray_img)
{
    std::vector<cv::Rect> vbox;
    for (int i = 0; i < bound.size (); ++i) {
        // 枠の内側にある輪郭を枠を含めて除外します
        if ((frame[0] & bound[i]) == bound[i])
            continue;
        int found = -1;
        for (int j = 0; j < vbox.size (); ++j) {
            if (std::max (vbox[j].x, bound[i].x) < std::min (bound[i].br ().x, vbox[j].br ().x)) {
                found = j;
                break;
            }
        }
        if (found < 0) {
            vbox.push_back (bound[i]);
        }
        else {
            vbox[found] |= bound[i];
        }
    }
    for (int i = 0; i < vbox.size (); ++i) {
        if (yaxis_label_rect.width == 0 || vbox[i].x < yaxis_label_rect.x)
            yaxis_label_rect = vbox[i];
    }
    check_yaxis_label_rotation ();
}

ただし、 一つ考慮しておかなければならない特殊例がありえます。 縦軸のラベルがなく、 縦軸の数字列だけがあり、 しかも数字列が 1 桁のとき、 目盛の数字列をラベルと誤認してしまいます。 これを除外するために、 ラベルの可能性がある長方形領域に含まれている輪郭の外接四角形を調べて、 グリフが縦向きのときはラベルではないと判断することにします。

void
plot2d_type::check_yaxis_label_rotation (void)
{
    if (yaxis_label_rect.width <= 0)
        return;
    for (int i = 0; i < bound.size (); ++i) {
        if (frame[0].x <= bound[i].x)
            continue;
        if ((yaxis_label_rect & bound[i]) == bound[i]) {
            int w = bound[i].width;
            int h = bound[i].height;
            // 縦向きのグリフが一つでもあるか?
            if (h < 2 * w && h > w) {
                yaxis_label_rect.x = yaxis_label_rect.y = 0;
                yaxis_label_rect.width = yaxis_label_rect.height = 0;
                break;
            }
        }
    }
}

これで、 縦軸のラベル領域の外接四角形が求まったので、 続いて、 縦横を入れ替えて同じやり方で横軸のラベル領域を求めます。 これも、 横軸のラベルか目盛の数字列の一方しかない場合がありえますが、 多くの論文では横軸には両方を持っているため、 今回は横軸には両方があるとして処理します。 一方しかない場合の扱いは今後の課題とします。 処理は縦軸のラベルとほぼ同じですが、 追加で縦軸のラベルに含まれる輪郭を認識済みとして飛ばすようにしています。

void
plot2d_type::detect_xaxis_label_rect (cv::Mat& gray_img)
{
    std::vector<cv::Rect> hbox;
    for (int i = 0; i < bound.size (); ++i) {
        // 枠の中の輪郭を除外
        if ((frame[0] & bound[i]) == bound[i])
            continue;
        // 縦軸のラベルを除外
        if (yaxis_label_rect.width > 0 && (yaxis_label_rect & bound[i]) == bound[i])
            continue;
        int found = -1;
        for (int j = 0; j < hbox.size (); ++j) {
            if (std::max (hbox[j].y, bound[i].y) < std::min (bound[i].br ().y, hbox[j].br ().y)) {
                found = j;
                break;
            }
        }
        if (found < 0) {
            hbox.push_back (bound[i]);
        }
        else {
            hbox[found] |= bound[i];
        }
    }
    for (int i = 0; i < hbox.size (); ++i) {
        if (xaxis_label_rect.y < hbox[i].y)
            xaxis_label_rect = hbox[i];
    }
}

これで、 枠の外側でラベル以外には目盛の数字列だけが残っているはずです。 つまり、 近接する輪郭をつなげていくことで、 目盛の数字列の外接四角形をすべて求めることができるはずです。 近接しているかどうかは、 グリフの高さと同じ幅で重ね合わせができるかどうかで判定していますが、 もっと良い方法に差し替えるかもしれません。

void
plot2d_type::detect_scale_label_rect (cv::Mat& gray_img)
{
    std::vector<cv::Rect> hbox;
    int grif_height = 0;
    for (int i = 0; i < bound.size (); ++i) {
        // 枠の中の輪郭を除外
        if ((frame[0] & bound[i]) == bound[i])
            continue;
        // 縦軸のラベルを除外
        if (yaxis_label_rect.width > 0 && (yaxis_label_rect & bound[i]) == bound[i])
            continue;
        // 横軸のラベルを除外
        if (xaxis_label_rect.width > 0 && (xaxis_label_rect & bound[i]) == bound[i])
            continue;
        grif_height = std::max (grif_height, bound[i].height);
        cv::Rect sqrect (bound[i].x, bound[i].y, grif_height, bound[i].height);
        int found = -1;
        for (int j = 0; j < hbox.size (); ++j) {
            cv::Rect a = sqrect & hbox[j];
            if (a.width > 0 && a.height > 0) {
                found = j;
                break;
            }
        }
        if (found < 0) {
            hbox.push_back (sqrect);
            scale_label_rect.push_back (bound[i]);
        }
        else {
            hbox[found] |= sqrect;
            scale_label_rect[found] |= bound[i];
        }
    }
}

これで、 ラベルと目盛の数字列のそれぞれを囲む長方形が求まったので、 今度は、 その中を OCR で文字認識していきます。 横軸のラベルが最も単純で、 長方形領域を tesseract で処理します。

void
plot2d_type::read_xaxis_label (tesseract::TessBaseAPI& tess, cv::Mat& gray_img)
{
    if (xaxis_label_rect.width > 0) {
        tesseract_call (tess, gray_img, xaxis_label_rect, xaxis_label, "");
    }
}

続いて簡単なのは目盛の数字列です。 それぞれの長方形領域ごとに、 OCR で文字認識していきます。 その際、 tesseract の設定ファイルに digits を選んでおいて、 数字列へと認識させます。

void
plot2d_type::read_scale_label (tesseract::TessBaseAPI& tess, cv::Mat& gray_img)
{
    for (int i = 0; i < scale_label_rect.size (); ++i) {
        std::string s;
        tesseract_call (tess, gray_img, scale_label_rect[i], s, "digits");
        scale_label.push_back (s);
    }
}

縦軸のラベルは反時計回りに π/2 回転しているので、 画像を時計回りに π/2 回転させておいてから、 回転後の縦軸のラベル領域を指定して、 文字を認識させます。

void
plot2d_type::read_yaxis_label (tesseract::TessBaseAPI& tess, cv::Mat& gray_img)
{
    if (yaxis_label_rect.width > 0) {
        cv::Mat clockwise_gray_img;
        // 時計回りに π/2 回転した画像を作ります
        cv::transpose (gray_img, clockwise_gray_img);
        cv::flip (clockwise_gray_img, clockwise_gray_img, 1);
        // 長方形領域も π/2 回転したものを作ります
        cv::Rect clip (yaxis_label_rect.br ().y, yaxis_label_rect.tl ().x,
            yaxis_label_rect.height, yaxis_label_rect.width);
        tesseract_call (tess, clockwise_gray_img, clip, yaxis_label, "");
    }
}

tesseract ライブラリで文字認識をおこなうメソッドは、 tesseract (1) コマンドに類似した引数の順番にしてあります。 tesseract は 8 ビット・グレーか、 24 ビット・カラーにしか対応していないので、 グレー・スケール画像を渡すようにしています。 ゼロの文字だけからなるテキストを tesseract に認識させるため、 PSM_SINGLE_BLOCK を指定します。

void
plot2d_type::tesseract_call (tesseract::TessBaseAPI& api,
                             cv::Mat& img, cv::Rect& clip,
                             std::string& output,
                             std::string const& configfile)
{
    api.Init(nullptr, "eng");
    if (! configfile.empty ()) {
        api.ReadConfigFile (configfile.c_str ());
    }
    api.SetImage((unsigned char*)img.data, img.cols, img.rows, img.channels(), img.step1());
    api.SetPageSegMode (tesseract::PSM_SINGLE_BLOCK);
    api.SetRectangle (clip.x, clip.y, clip.width, clip.height);
    api.Recognize (nullptr);
    char* s = api.GetUTF8Text ();
    output.assign (chomp (s));
    delete[] s;
    api.Clear ();
}

tesseract が返す文字列は、 改行 2 つが付属しているので、 chomp でそれを削ります。

std::string
plot2d_type::chomp (std::string const& s)
{
    std::string t = s;
    while (! t.empty () && t.back () == '\n')
        t.pop_back ();
    return t;
}