読者です 読者をやめる 読者になる 読者になる

kivantium活動日記

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

ご注文はDeep Learningですか?

OpenCV 機械学習

先日Deep Learningでラブライブ!キャラを識別するという記事が話題になっていました。この記事で紹介されている
SIG2D 2014を知り合いから貸してもらったので参考にしながら、ご注文は機械学習ですか?のDeep Learning版を作ってみました。

Caffeなど必要なソフトのインストール

Ubuntu 14.04の場合は過去記事を参照してください。これ以外にもpython-opencvなどを使いますが、依存関係の全ては把握できていないのでエラーが出たら適宜インストールしてください。

データの準備

Deep Learningでは大量の学習データが必要になると言われているので、まずは大量のデータを用意します。参考記事では6000枚のラブライブ画像を使ったということなので対抗して12000枚以上のごちうさ画像を用意したいと思います。それだけのデータを手動で分類するとそれだけで時間がかかるので、現在できているニューラルネットワークを使った分類器である程度分類しておきたいと思います。元画像はアニメから取ります。ソースコードはこんな感じです。

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

using namespace std;
using namespace cv;
#define ATTRIBUTES 64
#define CLASSES 6
//話ごとに添字を変える
int imagenum=0;
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;
    }
    
    for(;;){
        framenum++;
        input >> frame;
        if (frame.empty()) {
            cout << "End of video" << endl;
            break;
        };
        if(framenum%20==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(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++){
                //Vec3b tmp = norm.at<Vec3b>(y, 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;
            }
        }
        stringstream name;
        name.str("");
        //分類結果ごとにフォルダ分け
        switch(maxIndex){
            case 0:
                name << "chino/chino" << setw(5) << setfill('0') << imagenum << ".png";
                break;
            case 1:
                name << "cocoa/cocoa" << setw(5) << setfill('0') << imagenum << ".png";
                break;
            case 2:
                name << "rise/rise" << setw(5) << setfill('0') << imagenum << ".png";
                break;
            case 3:
                name << "chiya/chiya" << setw(5) << setfill('0') << imagenum << ".png";
                break;
            case 4:
                name << "sharo/sharo" << setw(5) << setfill('0') << imagenum << ".png";
                break;
            case 5:
                name << "other" << setw(5) << setfill('0') << imagenum << ".png";
                break;
        }
        imwrite(name.str(), Face);
        imagenum++;
    }
}

約13000枚の顔画像を切り出したあと誤識別を手動で直した結果、チノ2839枚・ココア3307枚・リゼ2124枚・千夜1542枚・シャロ1486枚・その他1647枚のデータを得ることが出来ました。ある程度の負例が必要らしいので非ごちうさ顔画像も約2万枚用意しました。

正規化はImageMagickを使って、

for file in `ls`; do convert ${file} -equalize ${file}; done

という感じでやりました。

LevelDBデータセットの準備

用意した画像をCaffeで読み込むLevelDBという形式に変換する必要があります。変換スクリプトはSIG2Dに掲載されているものを使いました。(ApacheライセンスにしてくださったSIG2Dさん、ありがとうございます!)

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright 2014 SIG2D
# Licensed under the Apache License, Version 2.0.
# changed by kivantium

import os
import shutil
import subprocess
import sys

from caffe.proto import caffe_pb2
import leveldb
import numpy as np
from PIL import Image
import random

THUMBNAIL_SIZE = 32


def make_thumbnail(image):
  image = image.convert('RGB')
  square_size = min(image.size)
  offset_x = (image.size[0] - square_size) / 2
  offset_y = (image.size[1] - square_size) / 2
  image = image.crop((offset_x, offset_y,
                      offset_x + square_size, offset_y + square_size))
  image.thumbnail((THUMBNAIL_SIZE, THUMBNAIL_SIZE), Image.ANTIALIAS)
  return image


def make_datum(thumbnail, label):
  return caffe_pb2.Datum(
      channels=3,
      width=THUMBNAIL_SIZE,
      height=THUMBNAIL_SIZE,
      label=label,
      data=np.rollaxis(np.asarray(thumbnail), 2).tostring())


def create_leveldb(name):
  path = os.path.join(os.environ['HOME'], 'caffe/examples/cifar10', name)
  try:
    shutil.rmtree(path)
  except OSError:
    pass
  print 'opening', path
  return leveldb.LevelDB(
      path, create_if_missing=True, error_if_exists=True, paranoid_checks=True)


def main():
  train_db = create_leveldb('cifar10_train_leveldb')
  test_db = create_leveldb('cifar10_test_leveldb')

  filepath_and_label = []
  for dirpath, _, filenames in os.walk('.'):
    try:
      label = int(dirpath.split('/')[1])
    except Exception:
      continue
    for filename in filenames:
      if filename.endswith(('.png', '.jpg')):
        filepath_and_label.append((os.path.join(dirpath, filename), label))

  random.shuffle(filepath_and_label)

  for seq, (filepath, label) in enumerate(filepath_and_label):
    print seq, label, filepath
    #読み込みエラーがあると止まるため改変
    #image = PIL.Image.open(filepath)
    try:
        image = Image.open(filepath)
    except:
        continue
    thumbnail = make_thumbnail(image)
    datum = make_datum(thumbnail, label)
    db = test_db if seq % 10 == 0 else train_db
    db.Put('%08d' % seq, datum.SerializeToString())


if __name__ == '__main__':
  sys.exit(main())

各画像はラベル名のディレクトリに入れ(例:チノ画像は0/)ラベル名のディレクトリのあるディレクトリでこのスクリプトを実行します。(0/ 1/ 2/... と同じ階層)このとき、このディレクトリには学習データとスクリプト以外のファイルは入れないようにします。

学習器の設定

基本的にはcaffe/example/cifar10/にある学習器の設定を流用しますが、結果ラベルの種類だけは変更する必要があります。cifar10_quick_train_test.prototxtとcifar10_quick.prototxtの出力結果のラベル数を表すip2レイヤーのnum_outputを10から主要キャラ5人+その他の6に変更しました。

........
layers {
  name: "ip2"
  type: INNER_PRODUCT
  bottom: "ip1"
  top: "ip2"
  blobs_lr: 1
  blobs_lr: 2
  inner_product_param {
    num_output: 10      ←ここを6に変更
    weight_filler {
      type: "gaussian"
      std: 0.1
    }
    bias_filler {
      type: "constant"
    }
  }
}

また、最近のCaffeのバージョン変更に伴い、最初の方でデータベースの読込方法を指定している部分をLMDBからLEVELDBに書き換えて、ファイル名も*lmdbから*leveldbに変更する必要があります。

(参考ページ: きんいろDeepLearning

平均画像の生成

(2015/8/12追記。やらなくても十分な精度が出ていたので忘れていました。)
caffeディレクトリ内で

build/tools/compute_image_mean -backend leveldb examples/cifar10/cifar10_train_leveldb examples/cifar10/mean.binaryproto

とすれば自前の平均画像を生成することができます。

cd $CAFFE_ROOT/data/cifar10
./get_cifar10.sh
cd $CAFFE_ROOT
./examples/cifar10/create_cifar10.sh

とすればcifar10の例で使った平均画像がダウンロードできるようです。

学習の実行

caffeディレクトリ内で

build/tools/caffe train --solver examples/cifar10/cifar10_quick_solver.prototxt

とすれば学習してくれます。CPUのみでやるとかなり時間がかかります。

動画の作成

これで学習器が完成したので動画を作ります。例によってC++で作りたいところですが、CaffeのPythonフロントエンドを使う関係上泣く泣くPythonを使います。PythonのPyはぴょんぴょんのPyということで勘弁してください。

#!/usr/bin/python
# -*- coding: utf-8 -*-
import cv2
import sys
import os.path
import caffe
from caffe.proto import caffe_pb2
import numpy as np

#AnimeFaceの読み込み
cascade = cv2.CascadeClassifier("lbpcascade_animeface.xml")
#元動画の読み込み
cap = cv2.VideoCapture("video.mp4")
# 出力動画の設定
fourcc = cv2.cv.CV_FOURCC(*'DIVX')
out = cv2.VideoWriter('output.avi', fourcc,
    int(cap.get(cv2.cv.CV_CAP_PROP_FPS)),
    (int(cap.get(cv2.cv.CV_CAP_PROP_FRAME_WIDTH)),
     int(cap.get(cv2.cv.CV_CAP_PROP_FRAME_HEIGHT))))
#顔に対する処理
def detect(frame):
    #顔の認識
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    gray = cv2.equalizeHist(gray)

    faces = cascade.detectMultiScale(gray,
                                     scaleFactor = 1.1,
                                     minNeighbors = 5,
                                     minSize = (24, 24))
    for (x, y, w, h) in faces:
        image = frame[y:y+h, x:x+w]
        cv2.imwrite("face.png", image)
        #pythonのOpenCVでうまく正規化ができなかったためこのような処理を取った
        os.system("convert face.png -equalize face.png")
        image = caffe.io.load_image('face.png')
        predictions = classifier.predict([image], oversample=False)
        pred = np.argmax(predictions)
        if pred == 0:
            cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 150, 79), 2)
        if pred == 1:
            cv2.rectangle(frame, (x, y), (x + w, y + h), (190, 165, 245), 2)
        if pred == 2:
            cv2.rectangle(frame, (x, y), (x + w, y + h), (147, 88, 120), 2)
        if pred == 3:
            cv2.rectangle(frame, (x, y), (x + w, y + h), (111, 180, 141), 2)
        if pred == 4:
            cv2.rectangle(frame, (x, y), (x + w, y + h), (161, 215, 244), 2)
    return frame

mean_blob = caffe_pb2.BlobProto()
with open('mean.binaryproto') as f:
    mean_blob.ParseFromString(f.read())
mean_array = np.asarray(
mean_blob.data,
dtype=np.float32).reshape(
    (mean_blob.channels,
    mean_blob.height,
    mean_blob.width))
classifier = caffe.Classifier(
    'cifar10_quick.prototxt',
    'cifar10_quick_iter_4000.caffemodel',
    mean=mean_array,
    raw_scale=255)
while(cap.isOpened()):
    ret, frame = cap.read()
    if ret == True:
        frame = detect(frame)
        out.write(frame)
    else:
        break
cap.release()
out.release()
cv2.destroyAllWindows()

僕の環境では4000回で学習が終了したのでcifar10_quick_iter_4000.caffemodelという学習器ができましたが、場合によっては名前が違うかもしれません。

また、前作と異なり主要5人以外には枠をつけていません。

前作との比較

三層パーセプトロン版で作った動画と比較してみます。
f:id:kivantium:20141127094237p:plain:w300f:id:kivantium:20150220212450p:plain:w300
前回(左)では窓を認識していますが、今回は認識していません。これは主要5人以外に枠をつけない措置を取ったためです。前作では認識力が低かったため認識に失敗しても顔と認識していることが分かるように枠をつけていたのですが、認識力が上がったため非顔画像に枠をつける処理を行う必要がなくなりました。

f:id:kivantium:20141127094315p:plain:w300f:id:kivantium:20150220212841p:plain:w300
前回も今回も顔が認識できていません。顔認識につかったAnimeFaceが全く同じためです。

f:id:kivantium:20141127094414p:plain:w300f:id:kivantium:20150220212955p:plain:w300
動画で見てもらうと分かりやすいのですが、三層パーセプトロン版ではチノの髪が写り込んだフレームでは誤認識しているのに対し、Deep Learning版では誤認識を起こさなくなりました。学習枚数が増えたためなのかDeep Learningのおかげなのかはよく分かりません。

f:id:kivantium:20141127094531p:plain:w300f:id:kivantium:20150220213132p:plain:w300
三層パーセプトロン版では識別できなかった顔が、Deep Learning版では識別できています。

f:id:kivantium:20150220213249p:plain:w300
f:id:kivantium:20150220213253p:plain:w300
どちらもDeep Learning版の画像です。設定上どちらの顔にも枠がつかないで欲しいのですが残念ながら違うキャラの色がついてしまっています。「その他」画像の種類が多すぎて逆に特徴がつかめなくなっていたのでしょうか、どう解釈すればいいのかよく分かりません。青山さん、マヤ、メグにも色を割り振るようにすれば改善できるような気がします。

感想

Deep LearningライブラリのCaffeを使って三層パーセプトロンでやったのと同じ処理を行い比較してみました。学習方法が全く違うので公平な比較にならないのですが、おおむねDeep Learningのほうが正確な認識を行っているように感じます。また、三層パーセプトロンによる結果も処理時間とのトレードオフと考えれば妥当だとも思いました。

こうなるとAnimeFaceの認識失敗が気になってくるので何らかの改善を行いたいところです。

追記

ご注文はDeep Learningですか?で試せるようになりました

参考リンク

SIG2D 今までに見た中で一番分かりやすいCaffeの解説でした。
Deep Learningでラブライブ!キャラを識別する
Getting Started with Videos PythonOpenCVでのビデオ処理はここを見ました
機械学習 - CaffeでDeep Learning つまずきやすいところを中心に - Qiita