Chainerで表情認識器を構築してみた

#はじめに
Chainer（というかPython）の勉強の一環と、表情の強度（微笑は弱い笑顔...のように）を考慮できる(無料の)表情認識器を作ってみたくなったので、実装しました。

#概要
顔画像を入れたら、その顔における表情の割合を認識できるようなモデルを作りました。
![図1.png](https://qiita-image-store.s3.amazonaws.com/0/276869/62d615f1-5d54-f57f-7619-5f5b1dd4fe9d.png)

#環境
Windows 10
python 3.5.5
Chainer 3.1.0
Cuda 9.0
OpenCV 3.1.0
#Github
Githubにソースをあげました。
https://github.com/melos9/fer_1

#学習で利用したデータセット
[Fer+](https://github.com/Microsoft/FERPlus)
(上の場所には画像に対する感情ラベルのみが存在し、画像本体は[fer2013](https://www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge/data))
このデータセットは、fer2013という、35000ほどの顔画像が含まれたデータセットに対し、10人の人間が8種の感情(neutral、happiness、surprise、sadness、anger、disgust、fear、contempt)の再ラベリングを行ったものです。(厳密には、8種の感情 + unknown + NA)
fer2013では、満面の笑顔と微笑は区別されることなく、どちらも happiness に分類されていましたが、Fer+では、前者は happiness=10、後者は happiness=6,neutral=4というように強度を反映したラベルがつけられる傾向があります。
#実装
ファイル読み込み→モデル構造→学習→テストです。
###・読み込み
Csvファイルであるデータセットから、データを読み込み、
train、publictest(validation)privatetest(test)とします。
(注意:このファイルは、fer2013のcsvのUsage,pixels,とFer+のneutral,happiness,...unknown,NFを連結させた物です。また、Fer+にはNA=10、つまりラベルがついていない画像が存在します。プログラムに入れる前に除去してください。連結と除去はExcelを使うと手っ取り早いです。)

```python:dataFromCsv.py
import numpy as np
import pandas as pd
import cv2
from chainer.datasets import tuple_dataset
from random import getrandbits 

#学習に関する基本情報の定義
NUM_SHAPE = 48 #画像一辺の長さ
TRAIN_DATA_SIZE_MAG = 2 #水増しで元のデータサイズの何倍の量まで増やすか

#Csvファイルから画像とラベルを読み込む
def dataFromCsv(csvfile):

    data = pd.read_csv(csvfile,delimiter=',')

    train_data = data[data['Usage']=='Training']
    publictest_data = data[data['Usage']=='PublicTest']
    privatetest_data = data[data['Usage']=='PrivateTest']

    #1行のデータを画像のカタチ(画像枚数、1=モノクロ、縦、横)にする
    train_x = pixelsToArray_x(train_data) 
    publictest_x = pixelsToArray_x(publictest_data)
    privatetest_x = pixelsToArray_x(privatetest_data)

    #ラベルは["neutral","happiness","surprise","sadness","anger","disgust","fear","contempt","unknown,NA"]
    #NA以外をyに入れる
    #各画像へのラベルは合計10になるので、10で割って0から1にする
    train_y = np.array(train_data.iloc[:,2:11],dtype=np.float32)/10
    publictest_y = np.array(publictest_data.iloc[:,2:11],dtype=np.float32)/10
    privatetest_y = np.array(privatetest_data.iloc[:,2:11],dtype=np.float32)/10

    #水増し
    train_x,train_y = augmentation(train_x,train_y)

    #tuple化
    train = tuple_dataset.TupleDataset(train_x,train_y)
    publictest = tuple_dataset.TupleDataset(publictest_x,publictest_y)
    privatetest = tuple_dataset.TupleDataset(privatetest_x,privatetest_y)
    
    return train,publictest,privatetest
```
pandasでdataとして読み取った時には、画像データは(10 20 50 40...)のように、空白で分けられた一行の文字列なので、48*48に直します。

```python
#1行のデータを画像の形にする
def pixelsToArray_x(data):
    np_x = np.array([np.fromstring(image,np.float32,sep=' ')/255 for image in np.array(data['pixels'])])
    np_x.shape =(np_x.shape[0],1,NUM_SHAPE,NUM_SHAPE)
    return np_x
```
データ数を増やすための水増しは、標準化、ノイズ付与、左右反転、Scale Augmentation（和訳が見つからない）の4つを適用しました。水増しされたデータセットは、
元々のデータ+変換データです。（全ての変換が必ず行われるわけではないです。詳しくは後述。）

```python
#水増し(holizontal Flip,Scale augmentation)
def augmentation(x_array,y_array,train_data_size_mag = TRAIN_DATA_SIZE_MAG): 

    #データ変換の処理4つ
    #関数が適用されるかはランダム
    def normalization(img):
        return (img - np.mean(img))/np.std(img)
    
    def gausianNoise(img):
        MEAN = 0
        SIGMA = 15

        gaussfilter = np.random.normal(MEAN,SIGMA,(img.shape))
        return img + gaussfilter
          
    def holizontalFlip(img):
        return img[:,::-1]
        
    def scaleAugmentation(img):
        SCALE_MIN = 50
        SCALE_MAX = 80

        #拡大処理、入力された画像サイズ48*48に対して、50*50~80*80まで拡大
        SCALE_SIZE = np.random.randint(SCALE_MIN,SCALE_MAX)

        #リサイズ
        scale_img = cv2.resize(img,(SCALE_SIZE,SCALE_SIZE))
      
        top = np.random.randint(0,SCALE_SIZE-NUM_SHAPE)
        left = np.random.randint(0,SCALE_SIZE-NUM_SHAPE)
        bottom = top + NUM_SHAPE
        right = left + NUM_SHAPE

        return scale_img[top:bottom,left:right]

    #変換処理対象データをtrain_data_size_mag-1用意(1セットは元の画像にするため-1)
    changed_x_array = np.concatenate([x_array]*(train_data_size_mag-1),axis=0)

    #変換の種類ごとにactivateAugmentFforArrayを適用して、画像の変換（もしくは無変換）を行う
    #各activatePはフィーリングです。
    changed_x_array = activateAugmentFforArray(normalization,changed_x_array,0.2)
    changed_x_array = activateAugmentFforArray(gausianNoise,changed_x_array,0.2)
    changed_x_array = activateAugmentFforArray(holizontalFlip,changed_x_array,1)
    changed_x_array = activateAugmentFforArray(scaleAugmentation,changed_x_array,0.2)

    return np.concatenate([x_array,changed_x_array],axis=0).astype(np.float32),np.concatenate([y_array]*train_data_size_mag,axis=0)

```

各画像の変換はactivatePの確率で行われます。
上記の関数は、「一枚の画像に対する変換」だったので、この関数で、全ての色チャネル、全ての画像列に対して変換を行います。（ただし、FER+はモノクロなので色チャネル数=1ですが。）

```python


     def activateAugmentFforArray(f,x_array,activateP):
        
       #変換用関数fを画像に適用させるかどうかをランダムに決める
        def randActivateF(f,img):
            if np.random.rand()>activateP:
                return img 
            return f(img) 

        imglist = []
        #x_arrayは[データ数,色数,縦,横]なので2回ループして画像毎の関数を(ランダムに)適用
        for imgC in x_array:
            imglist.append([randActivateF(f,img) for img in imgC])
                
        return np.array(imglist)

```

###・モデル
モデルはResnetを参考にしました。
Resnetの論文 → https://arxiv.org/abs/1512.03385
Resnetを日本語で解説 → [[Survey]Deep Residual Learning for Image Recognition](https://qiita.com/supersaiakujin/items/935bbc9610d0f87607e8)

```python:Resnet.py
import chainer
import chainer.functions as F
from chainer import initializers
import chainer.links as L


#Block内の最初のPlainアーキテクチャ。
class PlainA(chainer.Chain):
    def __init__(self, in_size, out_size, stride=2):
        super(PlainA, self).__init__()
        initialW = initializers.HeNormal()

        with self.init_scope():
            self.conv1 = L.Convolution2D(in_size, out_size, 1, stride, 0, initialW=initialW)
            self.bn = L.BatchNormalization(out_size)
            self.conv2 = L.Convolution2D(out_size, out_size, 3, 1, 1, initialW=initialW)

    def __call__(self, x):
        h1 = F.relu(self.bn(self.conv1(x)))
        h1 = F.relu(self.bn(self.conv2(h1)))
        h1 = self.bn(self.conv2(h1))
        h2 = self.bn(self.conv1(x))

        return F.relu(h1 + h2)

#Block内の２番目からのPlainアーキテクチャ。
class PlainB(chainer.Chain):
    def __init__(self, ch):
        super(PlainB, self).__init__()
        initialW = initializers.HeNormal()

        with self.init_scope():  
            self.conv = L.Convolution2D(ch, ch, 3, 1, 1, initialW=initialW)
            self.bn = L.BatchNormalization(ch)
            
    def __call__(self, x):
        h = F.relu(self.bn(self.conv(x)))
        h = F.relu(self.bn(self.conv(h)))
        return F.relu(h + x)
        
class Block(chainer.Chain):
    def __init__(self, layer, in_size, ch, out_size, stride=2):
        super(Block, self).__init__()
        self.add_link('a', PlainA(in_size,out_size, stride))

        for i in range(1, layer):
            self.add_link('b{}'.format(i), PlainB(ch))
        
        self.layer = layer

    def __call__(self, x):
        h = self.a(x)
        for i in range(1, self.layer):
            h = self['b{}'.format(i)](h)
        return h





class ResNet(chainer.Chain):
    #ResNet18を改変
    def __init__(self,class_labels=9):
        super(ResNet, self).__init__()
        with self.init_scope():
            self.conv1 = L.Convolution2D(1, 64, 7, 2, 3, initialW=initializers.HeNormal())
            self.bn1 = L.BatchNormalization(64)
            self.res1 = Block(2,64,64,64,1)
            self.res2 = Block(2,64,128,128)
            self.res3 = Block(2,128,256,256)
            self.fc = L.Linear(256,class_labels)

    def __call__(self, x):

        h = self.bn1(self.conv1(x))
        h = F.max_pooling_2d(F.relu(h), 3, stride=2)
        h = self.res1(h)
        h = self.res2(h)
        h = self.res3(h)
        h = F.average_pooling_2d(h, 3, stride=1)
        h = self.fc(h)

        return F.softmax(h)
```
###・学習
学習の実装では、1iterate毎に、順伝播、誤差の計算、誤差逆伝播を行い、epoch毎にlossを出力しました。

評価関数は定義付けが難しいので今回は省きました。
（上位3表情の組のaccuracyを求めるとかも考えましたが、いくつの表情要素が重要なのかは画像によって異なるのでやめました。）

今回、誤差関数は単純なクロスエントロピーでしたが、
それに加えて、正解ラベルが neutral:0.4,happiness:0.6の場合、予測として
neutral:0.3,happiness:0.7,他:0>neutral:0.4,happiness:0.5,他:0.1
といった感じで正解ラベルに含まれる物だけ出力すると良い、などや、
neutral:0.3,happiness:0.7>neutral:0.5,happiness:0.5
みたいに元々の大小関係を反映している方が良い、といった風に定義できればなあと考えています。


学習の実装においては、cnn学習を取り扱ったこちらの記事が分かりやすく、参考とさせていただきました。
[ChainerでCNNしたった_v1](https://qiita.com/yoyoyo_/items/14f243c058f5a1e7044b)

```python:learn.py
import chainer.functions as F
from chainer import serializers,iterators,optimizer
from chainer.dataset import convert


#学習に関する基本情報の定義
EPOCH = 100 #学習回数
BATCH = 20 #バッチサイズ
NUM_SHAPE = 48 #画像一辺の長さ
LEARN_RATE = 0.001
WEIGHT_DECAY =1e-4
GPU = 0

def learn(csvfile):

    train,publictest,_= dataFromCsv.dataFromCsv(csvfile)
  
    train_iter = iterators.SerialIterator(train,batch_size=BATCH,shuffle=True)
    publictest_iter = iterators.SerialIterator(publictest,batch_size=BATCH,repeat=False,shuffle=False)
    
    #学習したいモデルを決定、ただしモデルの出力はsoftmaxである必要がある
    model = Resnet.ResNet(class_labels=9)

    #GPU設定、GPUを使わない場合はコメントアウト可能
    chainer.cuda.get_device(GPU).use
    model.to_gpu()

    #最適化設定
    optimizer = chainer.optimizers.MomentumSGD(LEARN_RATE)
    optimizer.setup(model)
    optimizer.add_hook(chainer.optimizer.WeightDecay(WEIGHT_DECAY))

    #保存するモデル
    saved_model = model

     while train_iter.epoch < EPOCH:
        
        batch = train_iter.next()

        trainLossList = []
        
        x_array, y_array = convert.concat_examples(batch,GPU)

        x = chainer.Variable(x_array)
        y = chainer.Variable(y_array)
        m = model(x)

        loss_train = myCrossEntropyError(m,y)

        model.cleargrads()

        loss_train.backward()

        optimizer.update()

        trainLossList.append(chainer.cuda.to_cpu(loss_train.data))

        
        if  train_iter.is_new_epoch:
             
            testLossList = []
            
            #毎エポック後のmodelの精度を求め、publictest適用において最良のmodelを保存
            for batch in publictest_iter:
                x_array, y_array = convert.concat_examples(batch, GPU)
                x = chainer.Variable(x_array)
                y = chainer.Variable(y_array)
                m = model(x)

                loss_test = myCrossEntropyError(m, y)
                testLossList.append(chainer.cuda.to_cpu(loss_test.data))

                
                if loss_test.data == np.min(testLossList):
                    saved_model = model
            
            publictest_iter.reset()

        
            print("epo:" + str(train_iter.epoch) + " train_loss:" + str(np.mean(trainLossList)) + " test_loss:" + str(np.mean(testLossList)))
            
    serializers.save_npz(SAVE_MODEL, saved_model)

    return
```

chainerで用意されているクロスエントロピーは、ある1つのラベルに対応する出力のみが1、他が0となること想定されており、今回のように複数のラベルが0から1になる場合に適した物が無かったので、自作しました。

```python
def myCrossEntropyError(m,y):
    DELTA = 1e-7 # マイナス無限大を発生させないように微小な値を追加する
    return -F.sum(y*F.log(m+DELTA)+(1-y)*F.log(1-m+DELTA))

```
###・テスト

ここまでで作成したモデルをテストします。
適応対象の画像は、fer2013のprivatetestです。
(当たり前ですが、学習時には使用していないです。)

最初はテストのlossを出すことも考えましたが、lossの値が出たところで、表情認識器としてどのぐらい機能しているのかは分からないので、感情ラベル出力を出すことにしました。

```python:test.py
#学習に関する基本情報の定義
CLASS_LIST = ["neutral","happiness","surprise","sadness","anger","disgust","fear","contempt","unknown"]
GPU = 0

def test(csvfile,m):

    _,_,privatetest = dataFromCsv.dataFromCsv(csvfile)
    model = Resnet.ResNet()

    chainer.serializers.load_npz("saved_model/myresnet.npz",model)
    chainer.cuda.get_device(0).use() 
    model.to_gpu()

    x_array, y_array = convert.concat_examples(privatetest,GPU)

    #memoryに乗り切らないので最初の100の画像についてtest
    x = chainer.Variable(x_array[0:100,:,:])
    y = chainer.Variable(y_array[0:100,:])

    m = model(x)
    
    for (mm,yy) in zip(m,y):
        for i in range(len(CLASS_LIST)):
            print("{0:<10} ... pred:{1:<15} ans:{2:<5}".format(CLASS_LIST[i] ,str(mm[i].data),str(yy[i].data)))

        print("------------------------------")
    
```
#結果
いくつかの画像の表情割合を出しました。
（合計が1にならないのは、unknown「よく分からない」という要素を省いてるからです。）

まずは、こちらのニッコニコの女性
![PrivateTest_32308.jpg](https://qiita-image-store.s3.amazonaws.com/0/276869/4de05f5c-e980-0124-0911-f03541f6cdeb.jpeg)

|emo|pred|ans|
|:--|:--|:--|
neutral|0.0009524885|0.0
happiness|0.9959545|1.0
surprise|0.0027372676|0.0
sadness|5.8004705e-05|0.0
anger|0.00016713586|0.0
disgust|5.2080264e-08|0.0
fear|4.5544526e-05|0.0
contempt|1.2859846e-05|0.0

成功ですね。



続いては、こちらの怖い男性
![PrivateTest_32298.jpg](https://qiita-image-store.s3.amazonaws.com/0/276869/ae6eccc8-814a-29bc-14aa-023b234d1e93.jpeg)

|emo|pred|ans|
|:--|:--|:--|
|neutral|0.72755253|0.0|
|happiness|0.027106937|0.0|
|surprise|2.9497405e-05|0.0|
|sadness|0.004325121|0.0|
|anger|0.13503884|0.2|
|disgust|0.047576107|0.1|
|fear|6.568828e-05|0.0|
|contempt|0.04499509|0.7|
|unknown|0.013310221|0.0|

この顔がneutralとは...
鋼の心臓を持っていますね。

最後はこちらの男性
![PrivateTest_32223.jpg](https://qiita-image-store.s3.amazonaws.com/0/276869/8388766a-ec0d-e038-dd9b-312c97762520.jpeg)

|emo|pred|ans|
|:--|:--|:--|
|neutral|0.42924777|0.8|
|happiness|0.0030400495|0.0|
|surprise|0.14479072|0.0|
|sadness|0.08204043|0.1|
|anger|0.005027909|0.0|
|disgust|0.009544116|0.0|
|fear|0.00080701115|0.0|
|contempt|0.14622773|0.0|

まあ、どっちの言い分も分かります。
ansとは違いますが、この判定もアリかと。
(ansのラベルだってぶっちゃけ主観の集まり...)


印象としては、この認識器は、happinessが得意で、disgustとcontemptが画像から読み取りづらそうです。(データセットの表情の分布に偏りがあるのかもしれません。)

#感想

microsotfの表情認識APIほどではないですが、一応それっぽくはなりました。
今回はデータセット内の画像にしか適用していませんが、普通の動画像にも使えそうです。