ご注文は機械学習ですか?

先日書いたOpenCVでアニメ顔検出をやってみた - kivantium活動日記の続編です。アニメ顔を検出するところまではうまくいったので、今度はキャラの分類をやってみようと思います。環境はUbuntu 14.10です。

ひと目で、尋常でない検出器だと見抜いたよ

まずは分類に使う学習用データを用意します。投稿から半年以上経つのにまだランキング上位に残っている驚異の動画ご注文はうさぎですか? 第1羽「ひと目で、尋常でないもふもふだと見抜いたよ」 ‐ ニコニコ動画:GINZAを使います。

動画のダウンロード

Ubuntuならaptで入れられるnicovideo-dlというツールを使います。

sudo apt-get install nicovideo-dl
nicovideo-dl www.nicovideo.jp/watch/1397552685

その後avidemuxでOP部分だけの動画を作り、videoという名前で保存しました。

顔画像の切り出し

C++を使います。なぜかって?「cpp=心ぴょんぴょん」だからです。

#include <opencv2/opencv.hpp>
#include <string>
#include <sstream>
#include <iomanip>

using namespace std;
using namespace cv;

void detectAndDisplay(Mat image);

CascadeClassifier face_cascade;

int imagenum = 0;

int main(int argc, char* argv[]){
    int framenum = 0;

    //カスケードのロード
    face_cascade.load("lbpcascade_animeface.xml");

    //動画の読み込み
    Mat frame;
    VideoCapture video("video");
    if(!video.isOpened()){
        cout << "Video not found!" << endl;
        return -1;
    }

    for(;;){
        framenum++;
        video >> frame;
        if (frame.empty()) {
            cout << "End of video" << endl;
            break;
        };
        //全フレーム切りだすと画像数が増え過ぎるので10フレームごとに検出
        if(framenum%10==0) detectAndDisplay(frame);
    }
    return 0;
}

//認識と表示を行う関数
void detectAndDisplay(Mat image)
{
    vector<Rect> faces;
    Mat frame_gray;
    stringstream name;

    //画像のグレースケール化
    cvtColor(image, frame_gray, COLOR_BGR2GRAY );
    //ヒストグラムの平坦化
    equalizeHist(frame_gray, frame_gray);
    //顔の認識 小さい顔は除外
    face_cascade.detectMultiScale(frame_gray, faces, 1.1, 3, 0, Size(80,80));
    for(int i = 0; i<faces.size(); i++){
        //顔部分に注目したMatをROIで作る
        Mat Face = image(Rect(faces[i].x, faces[i].y,faces[i].width, faces[i].height));
        //連番のファイル名を作る。参考:http://www.geocities.jp/eneces_jupiter_jp/cpp1/013-001.html
        name.str("");
        name << "image" << setw(3) << setfill('0') << imagenum << ".png";
        imwrite(name.str(), Face);
        imagenum++;
    }
}

結果はこんな感じ
f:id:kivantium:20141125230608p:plain
image000.pngからimage126.pngまでの127個の画像が生成されました。

この切り出した画像から特徴を抽出して分類器を作ろうと思います。ここで問題になるのが特徴ベクトルを何にするかです。

僕はキャラ名を覚えてなくても「あの緑の髪の毛の子」みたいな認識をしているので、キャラの髪の毛の色は人間のキャラ認識でかなり重要な要素になっていることが想像されます。幸いごちうさは主要キャラ5人(全国の青山ブルーマウンテン派の皆様ごめんなさい)の髪の色がすべて違うので色情報だけで識別できそうです。切り出した画像の色分布を特徴量にしてニューラルネットワークに放り込んでみようと思います。

光を愛した少女と闇に愛された少女

同じキャラでも光の当たり具合によって見え方がずいぶん違うことがあります。

f:id:kivantium:20141125234825p:plain:h80f:id:kivantium:20141125234831p:plain:h80

これを補正するために画像の正規化を行います。(コードは後述)その結果がこちらです。

f:id:kivantium:20141125234944p:plain:h80f:id:kivantium:20141125235028p:plain:h80

かなり色合いが近づいたことが分かると思います。

ラッキーカラーは赤と緑と青

今回は8bit3チャンネルの画像を利用しているので、色の種類が256^3=16777216もあります。さすがにこれでは分類が大変なので64色にまで減色します。

f:id:kivantium:20141125235924p:plain:h80f:id:kivantium:20141126000813p:plain:h80f:id:kivantium:20141126000821p:plain:h80f:id:kivantium:20141126000828p:plain:h80f:id:kivantium:20141126000834p:plain:h80
64色でも十分見分けられそうな気がします。

特徴ベクトルを生成するお話

以上を考慮して特徴ベクトルを生成するプログラムがこちら

#include <opencv2/opencv.hpp>
#include <string>
#include <sstream>
#include <iomanip>

using namespace std;
using namespace cv;
int convert(int value){
    return (value/64)*64;
}

int main(int argc, char* argv[]){
    int index;
    float train[64];
    stringstream name;
    //今回はimage000.pngからimage126.pngまでの画像を使用する
    const int files = 126;
    for(int filenum=0; filenum<=files; filenum++){
        name.str("");
        name << "images/image" << setw(3) << setfill('0') << filenum << ".png";
        Mat src = imread(name.str());

        if(src.empty()){
            cout << "Image not found!" << endl;
            return -1;
        }
        for(int i=0; i<64; i++) train[i] = 0;
        Mat norm(src.size(), src.type());
        Mat sample(src.size(), src.type());
        normalize(src, norm, 0, 255, NORM_MINMAX, CV_8UC3);
        /*for(int y=0; y<src.rows; y++){
            for(int x=0; x<src.cols; x++){
                for(int c = 0; c < src.channels(); ++c){
                    index = y*src.step+x*src.elemSize()+c;
                    sample.data[index] = convert(norm.data[index]);
                }
            }
        }*/
        for(int y=0; y<sample.rows; y++){
            for(int x=0; x<sample.cols; x++){
                index = y*sample.step+x*sample.elemSize();
                int color = (norm.data[index+0]/64)+
                            (norm.data[index+1]/64)*4+
                            (norm.data[index+2]/64)*16;
                train[color]+=1;
            }
        }
        int pixel = sample.cols * sample.rows;
        for(int i=0; i<64; i++){
           train[i] /= pixel;
           cout << train[i] << " ";
        }
        cout << endl;
        /*imshow("normalized", norm);
        imshow("original", src);
        imshow("sample", sample);
        waitKey(0);*/
    }
        return 0;
}

この結果をcsvに出力してニューラルネットワークに放り込みます。

Call Me AI

train.csvという名前で訓練データを保存し、各行の最後の列に目視で分類結果を書き込みます。
(チノ→0, ココア→1, リゼ→2, 千夜→3, シャロ→4, その他→5という数字を割り当てました。番号はアニメ登場順です。)

次にニューラルネットワークの構築ですが、せっかくなのでOpenCV標準のライブラリを使ってみました。
ニューラルネットワークについて知りたい人は第3回 多層パーセプトロン · levelfour/machine-learning-2014 Wiki · GitHubなどを参照してください。
ソースコードOpenCV Optical Character Recognition using Neural Networkのほぼコピペです。

#include "opencv2/opencv.hpp"
#include "opencv2/ml/ml.hpp"
#include <stdio.h>
#include <fstream>
using namespace std;
using namespace cv;

#define TRAINING_SAMPLES 127    //訓練データの数
#define ATTRIBUTES 64           //入力ベクトルの要素数
#define TEST_SAMPLES 127        //テストデータの数
#define CLASSES 6               //ラベルの種類,チノ・ココア・リゼ・千夜・シャロ・その他の6つ

/* csvを読み込む関数
 * 各行が一つのデータに対応
 * 最初のATTRIBUTES列がデータ、最後の列がラベル */
void read_dataset(char *filename, Mat &data, Mat &classes,  int total_samples)
{
    int label;
    float pixelvalue;
    FILE* inputfile = fopen( filename, "r" );

    for(int row = 0; row < total_samples; row++){
        for(int col = 0; col <=ATTRIBUTES; col++){
            if (col < ATTRIBUTES){
                fscanf(inputfile, "%f,", &pixelvalue);
                data.at<float>(row,col) = pixelvalue;
            }
            else if (col == ATTRIBUTES){
                fscanf(inputfile, "%i", &label);
                classes.at<float>(row,label) = 1.0;
            }
        }
    }
    fclose(inputfile);
}

int main( int argc, char** argv ) {
    //訓練データを入れる行列
    Mat training_set(TRAINING_SAMPLES,ATTRIBUTES,CV_32F);
    //訓練データのラベルを入れる行列
    Mat training_set_classifications(TRAINING_SAMPLES, CLASSES, CV_32F);
    //テストデータを入れる行列
    Mat test_set(TEST_SAMPLES,ATTRIBUTES,CV_32F);
    //テストラベルを入れる行列
    Mat test_set_classifications(TEST_SAMPLES,CLASSES,CV_32F);

    //分類結果を入れる行列
    Mat classificationResult(1, CLASSES, CV_32F);
    //訓練データとテストデータのロード
    read_dataset(argv[1], training_set, training_set_classifications, TRAINING_SAMPLES);
    read_dataset(argv[2], test_set, test_set_classifications, TEST_SAMPLES);

    // ニューラルネットワークの定義
    Mat layers(3,1,CV_32S);          //三層構造
    layers.at<int>(0,0) = ATTRIBUTES;    //入力レイヤーの数
    layers.at<int>(1,0)=16;              //隠れユニットの数
    layers.at<int>(2,0) =CLASSES;        //出力レイヤーの数

    //ニューラルネットワークの構築
    CvANN_MLP nnetwork(layers, CvANN_MLP::SIGMOID_SYM,0.6,1);

    CvANN_MLP_TrainParams params(                                  
            // 一定回数繰り返すか変化が小さくなったら終了
            cvTermCriteria(CV_TERMCRIT_ITER+CV_TERMCRIT_EPS, 1000, 0.000001),
            // 訓練方法の指定。誤差逆伝播を使用
            CvANN_MLP_TrainParams::BACKPROP, 0.1, 0.1);

    // 訓練
    printf("\nUsing training dataset\n");
    int iterations = nnetwork.train(training_set, training_set_classifications, Mat(), Mat(),params);
    printf( "Training iterations: %i\n\n", iterations);

    // 訓練結果をxmlとして保存
    CvFileStorage* storage = cvOpenFileStorage("param.xml", 0, CV_STORAGE_WRITE );
    nnetwork.write(storage,"DigitOCR");
    cvReleaseFileStorage(&storage);

    // テストデータで訓練結果を確認
    Mat test_sample;
    int correct_class = 0;
    int wrong_class = 0;

    //分類結果を入れる配列
    int classification_matrix[CLASSES][CLASSES]={{}};

    for (int tsample = 0; tsample < TEST_SAMPLES; tsample++) {
        test_sample = test_set.row(tsample);
        nnetwork.predict(test_sample, classificationResult);
        // 最大の重みを持つクラスに分類
        int maxIndex = 0;
        float value=0.0f;
        float maxValue=classificationResult.at<float>(0,0);
        for(int index=1;index<CLASSES;index++)
        {   value = classificationResult.at<float>(0,index);
            if(value>maxValue)
            {   maxValue = value;
                maxIndex=index;

            }
        }

        //正解との比較
        if (test_set_classifications.at<float>(tsample, maxIndex)!=1.0f){
            cout << tsample << endl;
            wrong_class++;
            //find the actual label 'class_index'
            for(int class_index=0;class_index<CLASSES;class_index++) {
                if(test_set_classifications.at<float>(tsample, class_index)==1.0f){
                    classification_matrix[class_index][maxIndex]++;// A class_index sample was wrongly classified as maxindex.
                    break;
                }
            }
        } else {
            correct_class++;
            classification_matrix[maxIndex][maxIndex]++;
        }
    }

    printf( "\nResults on the testing dataset\n"
            "\tCorrect classification: %d (%g%%)\n"
            "\tWrong classifications: %d (%g%%)\n", 
            correct_class, (double) correct_class*100/TEST_SAMPLES,
            wrong_class, (double) wrong_class*100/TEST_SAMPLES);
    cout<<"   ";
    for (int i = 0; i < CLASSES; i++)
    {
        cout<< i<< "\t";
    }
    cout<<"\n";
    for(int row=0;row<CLASSES;row++)
    {cout<<row<<"  ";
        for(int col=0;col<CLASSES;col++)
        {
            cout << classification_matrix[row][col] << "\t";
        }
        cout<<"\n";
    }
    return 0;
}

trainという実行ファイルを作ったとすると次のように実行します。

./train train.csv test.csv

本来テストデータは訓練データと違うものを用意するべきなのですが、今回は訓練データをそのまま使いました。その結果がこちら。
f:id:kivantium:20141126171117p:plain:h200
各色の占める割合という適当なデータでも100%の分類ができました。
かがくのちからってすげー

対クラス分類用決戦部隊、通称ニューラルネットワーク

訓練データを100%分類できても過学習が起きている可能性があるので、訓練データ以外の画像でテストする必要があります。
画像を与えて分類を行うのがこのプログラム

#include "opencv2/opencv.hpp"
#include "opencv2/ml/ml.hpp"
#include <stdio.h>
#include <fstream>
#define ATTRIBUTES 64
#define CLASSES 6
using namespace std;
using namespace cv;
int main( int argc, char** argv )
{
    //XMLを読み込んでニューラルネットワークの構築
    CvANN_MLP nnetwork;
    CvFileStorage* storage = cvOpenFileStorage( "param.xml", 0, CV_STORAGE_READ );
    CvFileNode *n = cvGetFileNodeByName(storage,0,"DigitOCR");
    nnetwork.read(storage,n);
    cvReleaseFileStorage(&storage);

    for(int hoge=1; hoge<argc; hoge++){
        //画像の読み込み
        Mat src = imread(argv[hoge]);
        if(src.empty()){
            cout << "Image not found!" << endl;
            return -1;
        }
        //特徴ベクトルの生成
        int index;
        float train[64];
        for(int i=0; i<64; i++) train[i] = 0;
        Mat norm(src.size(), src.type());
        Mat sample(src.size(), src.type());
        normalize(src, norm, 0, 255, NORM_MINMAX, CV_8UC3);
        imshow("normalized", norm);
        for(int y=0; y<sample.rows; y++){
            for(int x=0; x<sample.cols; x++){
                index = y*sample.step+x*sample.elemSize();
                int color = (norm.data[index+0]/64)+
                    (norm.data[index+1]/64)*4+
                    (norm.data[index+2]/64)*16;
                train[color]+=1;
            }
        }
        int pixel = sample.cols * sample.rows;
        for(int i=0; i<64; i++){
            train[i] /= pixel;
        }

        //分類の実行
        Mat data(1, ATTRIBUTES, CV_32F);
        for(int col=0; col<=ATTRIBUTES; col++){
            data.at<float>(0,col) = train[col];
        }
        int maxIndex = 0;
        Mat classOut(1,CLASSES,CV_32F);
        nnetwork.predict(data, classOut);
        float value;
        float maxValue=classOut.at<float>(0,0);
        for(int index=1;index<CLASSES;index++){
            value = classOut.at<float>(0,index);
            if(value > maxValue){
                maxValue = value;
                maxIndex=index;
            }
        }
        //分類結果の表示
        switch(maxIndex){
            case 0:
                cout << "チノ" << endl;
                break;
            case 1:
                cout << "ココア" << endl;
                break;
            case 2:
                cout << "リゼ" << endl;
                break;
            case 3:
                cout << "千夜" << endl;
                break;
            case 4:
                cout << "シャロ" << endl;
                break;
            case 5:
                cout << "その他" << endl;
                break;
        }
    }
}

訓練に使わなかったフレームから取った画像とその実行結果がこちら。
f:id:kivantium:20141126172641j:plain:h250f:id:kivantium:20141126172645j:plain:h250

次はこの分類結果から動画を作ります。(つづく)