OpenCV3 C++ を用いた手書き数字の認識 (サポートベクタマシン)
[履歴] [最終更新] (2020/01/28 23:07:36)
ここは
趣味のプログラミングを楽しむための情報共有サービス。記事の一部は有料設定にして公開できます。 詳しくはこちらをクリック📝
最近の投稿
注目の記事

概要

サポートベクタマシン (SVM; Support Vector Machine) は分類アルゴリズムの一つです。二つのクラスに分類されたデータをもとに分類器を構成します。その分類器を用いると、未知のデータを二つのクラスに分類できます。OpenCV3 C++ に実装されている SVM アルゴリズムを利用して、手書き数字を 0-9 のいずれかに分類してみます。

digits.png

Uploaded Image

簡単な例

四つの点それぞれに 1 または -1 のラベルを付与します。これらを分類する直線を学習して分類器を構成します。平面内の点を分類器で塗り分けると以下のようになります。

Uploaded Image

#include <opencv2/opencv.hpp>

int main() {

    // 分類器を作成するためのデータを用意します。
    int labels[4] = {1, -1, -1, -1};
    float trainingData[4][2] = { {501, 10}, {255, 10}, {501, 255}, {10, 501} };
    cv::Mat trainingDataMat(4, 2, CV_32F, trainingData);
    cv::Mat labelsMat(4, 1, CV_32S, labels);

    // [SVM] 分類器を作成します。
    cv::Ptr<cv::ml::SVM> svm = cv::ml::SVM::create();
    svm->setType(cv::ml::SVM::C_SVC);
    svm->setKernel(cv::ml::SVM::LINEAR);
    svm->setTermCriteria(cv::TermCriteria(cv::TermCriteria::MAX_ITER, 100, 1e-6));
    svm->train(trainingDataMat, cv::ml::ROW_SAMPLE, labelsMat);

    // 分類器で 512 x 512 平面内の点を分類してみます。
    int width = 512, height = 512;
    cv::Mat image = cv::Mat::zeros(height, width, CV_8UC3);

    // 分類結果によって「緑」または「青」で色付けして可視化します。
    cv::Vec3b green(0,255,0), blue(255,0,0);
    for (int i = 0; i < image.rows; i++) {
        for (int j = 0; j < image.cols; j++) {
            cv::Mat sampleMat = (cv::Mat_<float>(1,2) << j,i);

            // [SVM] 分類
            float response = svm->predict(sampleMat);

            if (response == 1) {
                image.at<cv::Vec3b>(i,j) = green;
            }
            else if (response == -1) {
                image.at<cv::Vec3b>(i,j) = blue;
            }
        }
    }

    // 分かりやすさのため、訓練用のデータを描画します。
    int thickness = -1;
    cv::circle( image, cv::Point(501,  10), 5, cv::Scalar(  0,   0,   0), thickness );
    cv::circle( image, cv::Point(255,  10), 5, cv::Scalar(255, 255, 255), thickness );
    cv::circle( image, cv::Point(501, 255), 5, cv::Scalar(255, 255, 255), thickness );
    cv::circle( image, cv::Point( 10, 501), 5, cv::Scalar(255, 255, 255), thickness );

    // [SVM] サポートベクタを描画します。
    thickness = 2;
    cv::Mat sv = svm->getUncompressedSupportVectors();
    for (int i = 0; i < sv.rows; i++) {
        const float* v = sv.ptr<float>(i);
        cv::circle(image, cv::Point( (int) v[0], (int) v[1]), 6, cv::Scalar(128, 128, 128), thickness);
    }

    cv::imshow("SVM Simple Example", image);
    cv::waitKey(0);
    return 0;
}

cv::Ptr はスマートポインタです。C++11 スマートポインタと同様です。cv::TermCriteria は終了基準を表現するクラスです。<<cv::Mat を以下のように初期化します。

cv::Mat m = (cv::Mat_<float>(2, 2) << 1, 2, 3, 4);
std::cout << m << std::endl;

[1, 2;
 3, 4]

高次元の空間に写像して線形の分類器を見つける

訓練用のデータと同じ次元では線形の分類器を見つけられない場合は、カーネル空間とよばれる高次元空間に写像することを考えます。カーネル空間における線形分類器は、もとの低次元空間では線形であるとは限りません。以下に簡単な例を示します。

Uploaded Image

#include <opencv2/opencv.hpp>

int main() {

    // 分類器を作成するためのデータを用意します。
    int labels[4] = {1, -1, -1, 1};
    float trainingData[4] = {-200, -100, 150, 190};
    cv::Mat trainingDataMat(4, 1, CV_32F, trainingData);
    cv::Mat labelsMat(4, 1, CV_32S, labels);

    // [SVM] 分類器を作成します。
    cv::Ptr<cv::ml::SVM> svm = cv::ml::SVM::create();
    svm->setType(cv::ml::SVM::C_SVC);
    svm->setKernel(cv::ml::SVM::POLY);
    svm->setDegree(2);
    svm->setGamma(1.0);
    svm->setCoef0(0.0);
    svm->setTermCriteria(cv::TermCriteria(cv::TermCriteria::MAX_ITER, 100, 1e-6));
    svm->train(trainingDataMat, cv::ml::ROW_SAMPLE, labelsMat);

    // 分類器で 512 x 1 直線上の点を分類してみます。
    int width = 512, height = 512;
    cv::Mat image = cv::Mat::zeros(height, width, CV_8UC3);

    // 分類結果によって「緑」または「青」で色付けして可視化します。
    cv::Vec3b green(0,255,0), blue(255,0,0), black(0,0,0);
    for (int i = 0; i < image.rows; i++) {
        for (int j = 0; j < image.cols; j++) {

            if (i != 256) {
                image.at<cv::Vec3b>(i,j) = black;
                continue;
            }

            cv::Mat sampleMat = (cv::Mat_<float>(1,1) << j - 256);

            // [SVM] 分類
            float response = svm->predict(sampleMat);
            if (response == 1) {
                image.at<cv::Vec3b>(i,j) = green;
            }
            else if (response == -1) {
                image.at<cv::Vec3b>(i,j) = blue;
            }
        }
    }

    // 分かりやすさのため、訓練用のデータを描画します。
    int thickness = -1;
    cv::circle( image, cv::Point(-200 + 256, 256), 5, cv::Scalar(0, 0, 255), thickness );
    cv::circle( image, cv::Point(-100 + 256, 256), 5, cv::Scalar(255, 255, 255), thickness );
    cv::circle( image, cv::Point(150 + 256, 256), 5, cv::Scalar(255, 255, 255), thickness );
    cv::circle( image, cv::Point(190 + 256, 256), 5, cv::Scalar(0, 0, 255), thickness );

    // [SVM] サポートベクタを描画します。
    thickness = 2;
    cv::Mat sv = svm->getSupportVectors();
    std::cout << sv << std::endl;
    for (int i = 0; i < sv.rows; i++) {
        const float v = sv.at<float>(i);
        cv::circle(image, cv::Point( (int) v + 256, 256), 6, cv::Scalar(256, 0, 0), thickness);
    }

    cv::imshow("SVM Simple Example", image);
    cv::waitKey(0);
    return 0;
}

上記例では、多項式カーネルで二次元空間に写像しています。cv::ml::SVM::POLY

svm->setKernel(cv::ml::SVM::POLY);
svm->setDegree(2);
svm->setGamma(1.0);
svm->setCoef0(0.0);

例えば $(x, x^2)$ に写像して、{-3, 3}{-1, 1}{(-3, 9), (3, 9}{(-1, 1), (1, 1)} に変換すれば、二次元空間における線形の分類器が見つけられます。線形カーネルを用いると以下のようになり、うまく分類できません。

svm->setKernel(cv::ml::SVM::LINEAR);

Uploaded Image

手書き数字の分類

画像から分類に必要となる情報をうまく抽出して N 次元のベクトルで表現できれば、画像の分類を SVM で行うことができます。こちらのページに倣って、手書き数字の分類を行ってみます。

画像の情報として HOG (Histogram of Oriented Gradients) を利用しています。Sobel 微分で各点の x軸方向、y軸方向の勾配 Gradients を計算して、各方向 Oriented についてヒストグラムを計算します。このヒストグラムを画像の特徴量として扱い、SVM で利用します。HOG を計算するにあたり、事前に画像の傾きを補正しておきます。

CMakeLists.txt

cmake_minimum_required (VERSION 3.1)
find_package(OpenCV REQUIRED)
add_executable(main main.cpp)
target_link_libraries(main ${OpenCV_LIBS})

main.cpp

#include <opencv2/opencv.hpp>
#include <iostream>

int SZ = 20;

cv::Mat deskew(cv::Mat& img){
    cv::Moments m = cv::moments(img);
    if(abs(m.mu02) < 1e-2) {
        return img.clone();
    }
    float skew = m.mu11 / m.mu02;
    cv::Mat warpMat = (cv::Mat_<float>(2,3) << 1, skew, -0.5 * SZ * skew, 0, 1, 0);
    cv::Mat imgOut = cv::Mat::zeros(img.rows, img.cols, img.type());
    cv::warpAffine(img, imgOut, warpMat, imgOut.size(), cv::WARP_INVERSE_MAP|cv::INTER_LINEAR);
    return imgOut;
}

void loadTrainTestLabel(std::string &pathName,
                        std::vector<cv::Mat> &trainCells,
                        std::vector<cv::Mat> &testCells,
                        std::vector<int> &trainLabels,
                        std::vector<int> &testLabels) {
    cv::Mat img = cv::imread(pathName, cv::IMREAD_GRAYSCALE);
    int ImgCount = 0;
    for(int i = 0; i < img.rows; i = i + SZ) {
        for(int j = 0; j < img.cols; j = j + SZ) {
            cv::Mat digitImg = (img.colRange(j, j + SZ).rowRange(i, i + SZ)).clone();
            if(j < int(0.9 * img.cols)) {
                trainCells.push_back(digitImg);
            }
            else {
                testCells.push_back(digitImg);
            }
            ImgCount++;
        }
    }
    std::cout << "Image Count : " << ImgCount << std::endl;
    float digitClassNumber = 0;
    for(int z = 0; z < int(0.9 * ImgCount); z++) {
        if(z % 450 == 0 && z != 0) {
            digitClassNumber = digitClassNumber + 1;
        }
        trainLabels.push_back(digitClassNumber);
    }
    digitClassNumber = 0;
    for(int z = 0; z < int(0.1*ImgCount); z++) {
        if(z % 50 == 0 && z != 0) {
            digitClassNumber = digitClassNumber + 1;
        }
        testLabels.push_back(digitClassNumber);
    }
}

void CreateDeskewedTrainTest(std::vector<cv::Mat> &deskewedTrainCells,
                             std::vector<cv::Mat> &deskewedTestCells,
                             std::vector<cv::Mat> &trainCells,
                             std::vector<cv::Mat> &testCells){
    for(int i = 0; i < (int)trainCells.size(); i++) {
        cv::Mat deskewedImg = deskew(trainCells[i]);
        deskewedTrainCells.push_back(deskewedImg);
    }
    for(int i = 0; i < (int)testCells.size(); i++) {
        cv::Mat deskewedImg = deskew(testCells[i]);
        deskewedTestCells.push_back(deskewedImg);
    }
}

cv::HOGDescriptor hog(
        cv::Size(20,20), //winSize
        cv::Size(10,10), //blocksize
        cv::Size(5,5), //blockStride,
        cv::Size(10,10), //cellSize,
                 9, //nbins,
                  1, //derivAper,
                 -1, //winSigma,
                  0, //histogramNormType,
                0.2, //L2HysThresh,
                  0,//gammal correction,
                  64,//nlevels=64
                  1);

void CreateTrainTestHOG(std::vector<std::vector<float> > &trainHOG,
                        std::vector<std::vector<float> > &testHOG,
                        std::vector<cv::Mat> &deskewedtrainCells,
                        std::vector<cv::Mat> &deskewedtestCells) {
    for(int y = 0; y < (int)deskewedtrainCells.size(); y++) {
        std::vector<float> descriptors;
        hog.compute(deskewedtrainCells[y], descriptors);
        trainHOG.push_back(descriptors);
    }
    for(int y = 0; y < (int)deskewedtestCells.size(); y++) {
        std::vector<float> descriptors;
        hog.compute(deskewedtestCells[y], descriptors);
        testHOG.push_back(descriptors);
    }
}

void ConvertVectortoMatrix(std::vector<std::vector<float> > &trainHOG,
                           std::vector<std::vector<float> > &testHOG,
                           cv::Mat &trainMat,
                           cv::Mat &testMat) {
    int descriptor_size = trainHOG[0].size();
    for(int i = 0; i < (int)trainHOG.size(); i++){
        for(int j = 0;j<descriptor_size;j++){
            trainMat.at<float>(i,j) = trainHOG[i][j];
        }
    }
    for(int i = 0; i < (int)testHOG.size(); i++) {
        for(int j = 0;j<descriptor_size;j++){
            testMat.at<float>(i,j) = testHOG[i][j];
        }
    }
}

void getSVMParams(cv::Ptr<cv::ml::SVM> svm) {
    std::cout << "Kernel type     : " << svm->getKernelType() << std::endl;
    std::cout << "Type            : " << svm->getType() << std::endl;
    std::cout << "C               : " << svm->getC() << std::endl;
    std::cout << "Degree          : " << svm->getDegree() << std::endl;
    std::cout << "Nu              : " << svm->getNu() << std::endl;
    std::cout << "Gamma           : " << svm->getGamma() << std::endl;
}

cv::Ptr<cv::ml::SVM> SVMtrain(cv::Mat &trainMat, std::vector<int> &trainLabels) {
    cv::Ptr<cv::ml::TrainData> td = cv::ml::TrainData::create(trainMat, cv::ml::ROW_SAMPLE, trainLabels);
    cv::Ptr<cv::ml::SVM> svm = cv::ml::SVM::create();

    svm->setGamma(0.50625);
    svm->setC(12.5);
    svm->setKernel(cv::ml::SVM::RBF);
    svm->setType(cv::ml::SVM::C_SVC);
    svm->train(td);

    // 上記パラメータを自動で計算させることもできます。時間がかかります。
    // svm->trainAuto(td);

    svm->save("model.yml");
    getSVMParams(svm);
    return svm;
}

void SVMevaluate(cv::Ptr<cv::ml::SVM> svm, cv::Mat &testMat, std::vector<int> &testLabels, std::vector<cv::Mat> &deskewedTestCells){
    cv::Mat testResponse;
    svm->predict(testMat, testResponse);
    int count = 0;
    for(int i = 0; i < testResponse.rows; i++) {
        if(testResponse.at<float>(i, 0) == testLabels[i]) {
            count = count + 1;
        }
        else {
            std::cout << testResponse.at<float>(i,0) << " != " << testLabels[i] << std::endl;
            cv::imshow("prediction failed", deskewedTestCells[i]);
            cv::waitKey(0);
        }
    }
    float accuracy = (count / (float)testResponse.rows) * 100;
    std::cout << "Accuracy        : " << accuracy << "%" << std::endl;
}

int main() {

    // 手書き数字の画像
    std::string pathName = "digits.png";

    // 分類器を学習するためのデータと、分類器を検証するためのデータ
    std::vector<cv::Mat> trainCells;
    std::vector<cv::Mat> testCells;
    std::vector<int> trainLabels;
    std::vector<int> testLabels;
    loadTrainTestLabel(pathName, trainCells, testCells, trainLabels, testLabels);

    // 画像の傾きを補正します。
    std::vector<cv::Mat> deskewedTrainCells;
    std::vector<cv::Mat> deskewedTestCells;
    CreateDeskewedTrainTest(deskewedTrainCells, deskewedTestCells, trainCells, testCells);

    // 特徴量として HOG を計算します。
    std::vector<std::vector<float> > trainHOG;
    std::vector<std::vector<float> > testHOG;
    CreateTrainTestHOG(trainHOG, testHOG, deskewedTrainCells,deskewedTestCells);
    int descriptor_size = trainHOG[0].size();
    std::cout << "Descriptor Size : " << descriptor_size << std::endl;

    cv::Mat trainMat(trainHOG.size(), descriptor_size,CV_32FC1);
    cv::Mat testMat(testHOG.size(), descriptor_size,CV_32FC1);
    ConvertVectortoMatrix(trainHOG, testHOG, trainMat, testMat);

    // SVM 分類器を構成します。
    cv::Ptr<cv::ml::SVM> svm;
    svm = SVMtrain(trainMat, trainLabels);

    // SVM 分類器を評価します。
    SVMevaluate(svm, testMat, testLabels, deskewedTestCells);
    return 0;
}

実行例

Image Count : 5000
Descriptor Size : 81
Kernel type     : 2
Type            : 100
C               : 12.5
Degree          : 0
Nu              : 0
Gamma           : 0.50625
7 != 1
3 != 2
2 != 7
1 != 7
0 != 9
3 != 9
7 != 9
Accuracy        : 98.6%

7 と認識 (正しくは 1)

Uploaded Image

3 と認識 (正しくは 2)

Uploaded Image

2 と認識 (正しくは 7)

Uploaded Image

1 と認識 (正しくは 7)

Uploaded Image

0 と認識 (正しくは 9)

Uploaded Image

3 と認識 (正しくは 9)

Uploaded Image

7 と認識 (正しくは 9)

Uploaded Image

関連ページ
    概要 OpenCV3 C++ を用いて基本的な画像変換を行います。 サイズの変更 (resize) #include <opencv2/opencv.hpp> int main() { cv::Mat img = cv::imread("aaa.png", -1); if(img.empty()) { return -1; } cv::M
    概要 こちらのページで基本的な使い方を把握した PyTorch を用いて、手書き数字の分類を行ってみます。サポートベクターマシンを用いた場合は HOG などの特徴量を考える必要がありましたが、ディープラーニングでは十分な質の良いデータがあればその必要がありません。 MNIST データの読み込み 手書き数字のデータとして、