kivantium活動日記

プログラムを使っていろいろやります

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

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

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

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

動画のダウンロード

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

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

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

顔画像の切り出し

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

#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 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<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;
    }
        return 0;
}

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

Call Me AI

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

次にニューラルネットワークの構築ですが、せっかくなのでOpenCV標準のライブラリを使ってみました。
ニューラルネットワークについて知りたい人は第3回 多層パーセプトロン · levelfour/machine-learning-2014 Wiki · GitHubなどを参照してください。
ソースコードhttp://www.nithinrajs.in/ocr-artificial-neural-network-opencv-part-3final-preprocessing/のほぼコピペです。

#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

次はこの分類結果から動画を作ります。

動画のためなら出力する

だんだん見出しが適当になってきました……。
分類結果を利用して元動画の顔領域にキャラに応じた色の枠をつけた動画を作ります。
OpenCVのVideoWriterという機能を使えば動画を出力できるのですが、(参考:c++ - OpenCV doesn't save the video - Stack Overflow)僕の環境では元動画のH.264で出力できなかったので、別のコーデックを使っています。(Ubuntu 14.10ではaptでffmpegが入れられないあたりが関係ありそうですがよくわかりません)ソースはこちら。再利用する気がないのでグローバル変数とか使っちゃってますね。枠の色はごちうさ部のアイコンから色情報を抜き出して使いました。

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

using namespace std;
using namespace cv;
#define ATTRIBUTES 64
#define CLASSES 6
void detectAndDisplay(Mat image);

CascadeClassifier face_cascade;

//XMLを読み込んでニューラルネットワークの構築
CvANN_MLP nnetwork;
CvFileStorage* storage;
CvFileNode *n;
VideoWriter output;

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

    //ネットワークのロード
    storage = cvOpenFileStorage( "param.xml", 0, CV_STORAGE_READ );
    n = cvGetFileNodeByName(storage,0,"DigitOCR");
    nnetwork.read(storage,n);
    cvReleaseFileStorage(&storage);
    
    //カスケードのロード
    face_cascade.load("lbpcascade_animeface.xml");

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

    //コーデック名の表示
    int ex = static_cast<int>(input.get(CV_CAP_PROP_FOURCC));
    char EXT[] = {ex & 0XFF , (ex & 0XFF00) >> 8,(ex & 0XFF0000) >> 16,(ex & 0XFF000000) >> 24, 0};
    cout << "original codec: " << EXT << endl;

    // 出力先の設定
    output.open(argv[2],  
            //input.get(CV_CAP_PROP_FOURCC),としたいところだがコーデックの関係上無理
            CV_FOURCC_MACRO('D','X','5','0'),    //このコーデックなら出力できた。
            input.get(CV_CAP_PROP_FPS), 
            Size(input.get(CV_CAP_PROP_FRAME_WIDTH), input.get(CV_CAP_PROP_FRAME_HEIGHT)));
    if (!output.isOpened()){
        cout  << "Could not open the output video" << endl;
        return -1;
    }
    
    for(;;){
        framenum++;
        input >> frame;
        if (frame.empty()) {
            cout << "End of video" << endl;
            break;
        };
        detectAndDisplay(frame);
        //出力結果を見たければ以下を追加
        //if(waitKey(1)=='q') break;
    }
    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(50,50));
    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));
        Mat norm(Face.size(), Face.type());
        int index;
        float train[64];
        for(int j=0; j<64; j++) train[j] = 0;
        normalize(Face, norm, 0, 255, NORM_MINMAX, CV_8UC3);
        for(int y=0; y<norm.rows; y++){
            for(int x=0; x<norm.cols; x++){
                index = y*norm.step+x*norm.elemSize();
                int color = (norm.data[index+0]/64)+
                    (norm.data[index+1]/64)*4+
                    (norm.data[index+2]/64)*16;
                train[color]+=1;
            }
        }
        float pixel = norm.cols * norm.rows;
        for(int j=0; j<64; j++){
            train[j] /= 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;
            }
        }
        Scalar color;
        //分類結果の表示
        switch(maxIndex){
            case 0:
                color = Scalar(255,150,79);
                break;
            case 1:
                color = Scalar(190,165,245);
                break;
            case 2:
                color = Scalar(147,88,120);
                break;
            case 3:
                color = Scalar(111,180,141);
                break;
            case 4:
                color = Scalar(161,215,244);
                break;
            case 5:
                color = Scalar(0,0,0);
                break;
        }
        //枠の描画
        rectangle(image, Point(faces[i].x, faces[i].y), Point(faces[i].x+faces[i].width, faces[i].y+faces[i].height), color, 5);
    }
    //出力結果を見たければ以下を追加
    //imshow("Output", image);
    output << image;
}

createという出力ファイルの場合

./create input.mp4 output.avi

のようにして実行します。

これで出力される動画には音声がないのでavidemuxでinput.mp4から音声を抜き出してoutput.aviに追加し、H.264MPEG-4 AVC)で出力すれば完成です。

出来上がった動画がこちら

ココアと悪意なき誤検出

出力結果から目立つ間違いを集めてみました。
f:id:kivantium:20141127094237p:plain:h200
思いっきり窓を顔だと検出してしまっています。顔検出器の改善が必要です。

f:id:kivantium:20141127094315p:plain:h200
今回使用した顔検出器では顔全てが写っていないと検出できないようです。

f:id:kivantium:20141127094414p:plain:h200
ココアがチノだと判定されています。ココアの顔部分にチノの髪が写り込んでいるのが原因で青部分があるからチノと判定されたのでしょう。

f:id:kivantium:20141127094531p:plain:h200
顔が小さすぎる上に光で色がかなり変わっているのでその他の人物だと認識されてしまったようです。

君のためなら改良する

今回の実験では色情報しか使っていないので、金髪キャラが2人いるきんいろモザイクのような作品には適用できないことが予想されます。また、日替わり応援イラストごちうさ部のアイコンのような人間が見れば分かるのに正しく判定できない画像も多くあります。

今後の改良としては、

  • 顔検出器の改良
    • 顔の一部しか映らなくても検出できるようにする
    • 目をつぶっていても検出できるようにする
  • 判定部分の改良
    • 輪郭や髪型、顔パーツの配置なども特徴量として利用する

などが考えられます。


などの先行研究を参考に改良したいと思います。

今回の記事は以上です。ソースコードにはバッドノウハウがたくさんあると思うのでブコメやコメントで指摘していただけると幸いです。ご覧いただきありがとうございました。

追記

やっつけですがアップロードした画像に対して認識結果を出すサイトを作りました。が今はもうないです。
f:id:kivantium:20141127224317p:plain:w640

*1:元ネタ→ドドドドドド初心者のためのc++勉強会 - connpass ブコメでの言及が多いため追記