PythonとOpenCVを使った笑顔認識


<!--
# 下準備：Raspberry PiにOpenCVをインストール
以下のコマンドで手軽にインストール

```
$ sudo apt-get install libopencv-dev
$ sudo apt-get install python-opencv
```

参考
https://qiita.com/jh3rox/items/be803f9171db8fe737de
-->

# まずはOpenCV使ってカメラからの画像取得

```python:CamCap.py
import cv2

capture = cv2.VideoCapture(0)
capture.set(3,320)# 320 320 640 720　横の長さ
capture.set(4,240)#180 240  360 405　縦の長さ

while True:
        ret, img = capture.read()
        img = cv2.flip(img,1)#鏡表示にするため．
        cv2.imshow('img',img)
        # key Operation
        key = cv2.waitKey(10) 
        if key ==27 or key ==ord('q'): #escまたはqキーで終了
                break
capture.release()
cv2.destroyAllWindows()
print("Exit")     
```
`ord()`は引数のアスキーコードを返す．ちなみに`chr()`はアスキーコードに対する文字を返す．

#OpenCV使って顔認識
OpenCVでの笑顔認識は，前処理として顔認識が必要（顔認識した領域に対して，さらに笑顔認識を掛ける，という仕組み）．
なので，顔認識をする．

```python:FaceDetect.py
import cv2

capture = cv2.VideoCapture(0)
capture.set(3,320)# 320 320 640 720
capture.set(4,240)#180 240  360 405

face_cascade = cv2.CascadeClassifier('./haarcascade_frontalface_default.xml')

while True:
        ret, img = capture.read()
        img = cv2.flip(img,1)#鏡表示にするため．
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

        faces = face_cascade.detectMultiScale(gray, 1.1, 5)
        for (x,y,w,h) in faces:
                cv2.circle(img,(int(x+w/2),int(y+h/2)),int(w/2),(0, 0, 255),2) # red

        cv2.imshow('img',img)
        # key Operation
        key = cv2.waitKey(10) 
        if key ==27 or key ==ord('q'): #escまたはqキーで終了
                break
capture.release()
cv2.destroyAllWindows()
print("Exit")     
```
`haarcascade_frontalface_default.xml`は，OpenCVをWin10のPCにインストール（というかDLして解凍してWorkに置いただけ）したときに，./opencv/sources/data/haarcascades/からコピーした．他の入手法としてGithubで検索掛ければすぐにGetできる．
これをカレントディレクトリに置いた．

`face_cascade.detectMultiScale`の引数について，1.1というのはscaleFactorといって，公式マニュアルでは，"Parameter specifying how much the image size is reduced at each image scale."とある．正直よくわからない．なので，いろいろ調べてたら，参考資料2と3に挙げてるページで丁寧に説明してくれてた．要はパターンマッチングするときに，モデルのサイズを徐々に上げながら何度も画像内を探索してるんだけど，モデルのサイズを上げる（逆に言えば画像のサイズを下げる）時の比率のことらしい．だとすると，この値は1以上でないとダメなのは当然なのだけど，比率を上げれば上げるほど，探索は早く終わるが，見逃しが多くなり，誤検出は減る．逆に比率が1に近いほど，探索は時間がかかるが，見逃しは減り，誤検出は増える．うーむ・・・

5はminNeighborsで，公式マニュアルでは"Parameter specifying how many neighbors each candidate rectangle should have to retain it."とある．モデルのサイズを変えて何度も探索するときに，その矩形領域がモデルのサイズを変えても安定的にマッチしてるかどうかってこと？よー分からん．とりあえず値を下げると見逃し率は下がるが，誤検出が増える感じ．

`detectMultiScale`のアウトプットは，顔と認識した矩形領域の左上のx,y座標と幅w・高さhのリスト．なので，それを一個ずつ開いて，顔領域に円形を描く．

あと，`int()`については，場合によっては座標値や半径が小数になることがあり，「整数でないとだめ」といわれることがあるので型キャスト．


参考：
１）公式マニュアル
https://docs.opencv.org/4.0.1/d1/de5/classcv_1_1CascadeClassifier.html#aaf8181cb63968136476ec4204ffca498
２）認識処理の中身の概説記事．わかりやすいし，リンクも素晴らしい．
https://qiita.com/FukuharaYohei/items/ec6dce7cc5ea21a51a82
３）ScaleFactorの説明
http://workpiles.com/2015/04/opencv-detectmultiscale-scalefactor/　

#OpenCV使って笑顔認識
顔認識ができたので，今度は笑顔認識．処理の流れとしては，顔認識した領域に対して笑顔認識処理を掛けるという流れ．

```python:SmileDetect.py
import cv2

capture = cv2.VideoCapture(0)
capture.set(3,640)# 320 320 640 720
capture.set(4,360)#180 240  360 405

face_cascade = cv2.CascadeClassifier('./haarcascade_frontalface_default.xml')
smile_cascade = cv2.CascadeClassifier('./haarcascade_smile.xml')

while True:
        ret, img = capture.read()
        img = cv2.flip(img,1)#鏡表示にするため．
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

        faces = face_cascade.detectMultiScale(gray, 1.1, 5)
        for (x,y,w,h) in faces:
                cv2.circle(img,(int(x+w/2),int(y+h/2)),int(w/2),(255, 0, 0),2) # blue
  
                roi_gray = gray[y:y+h, x:x+w] #Gray画像から，顔領域を切り出す．
                smiles= smile_cascade.detectMultiScale(roi_gray,scaleFactor= 1.2, minNeighbors=10, minSize=(20, 20))#笑顔識別
                if len(smiles) >0 :
                        for(sx,sy,sw,sh) in smiles:
                                cv2.circle(img,(int(x+sx+sw/2),int(y+sy+sh/2)),int(sw/2),(0, 0, 255),2)#red
  
        cv2.imshow('img',img)
        # key Operation
        key = cv2.waitKey(5) 
        if key ==27 or key ==ord('q'): #escまたはeキーで終了
                break
capture.release()
cv2.destroyAllWindows()
print("Exit")    
```
`haarcascade_smile.xml`は`haarcascade_frontalface_default.xml`と同様にGet.
やってるのは，先に示した顔認識器にかけた後，顔領域を切り出して笑顔認識器にかけて，笑顔と認識された領域に赤い丸を付けるというもの．
`detectMultiScale`の中身で，今回は引数にはっきりと変数名もつけてみた．`minSize=(20,20)`は認識するうえでの閾値（これ以下の領域は認識処理をしない）．

やってみると，顔を認識すると青い円が表示され，笑顔を作ると赤い円が表示される．
やってみるとどうも口元の形でマッチングしてるのがわかる．

参考：
https://github.com/mwyau/python-opencv-smile-detector/blob/master/smiledetector.py

#笑顔の強度指標
笑顔の強度に応じて反応するようにしたい．ということで，笑顔の強度指標を考えてみる．
参考に挙げている強度指標のソースに書き込まれてるコメントから，The number of detected neighbors(detectMultiScaleが吐き出すリストの長さ）が笑顔の強度を測る材料になるらしい．ただ，これは探索してる画像のサイズや輝度にも依存してるので，その部分を正規化する必要がある．

参考にあるCPPの笑顔認識強度指標では，この点について，「同じ画像から取った近傍矩形数」を基準にすることでサイズの影響を回避しようとしてるっぽい．
つまり，最初に認識した笑顔の近傍矩形数を最小，また過去の認識した中で最も近傍矩形数が多いときを最大として，現在の近傍矩形数-最小と最大―最小の比を出してる（分母が0になるのを回避するために⁺1してる）．

確かにこうすると，画像のサイズや輝度の影響は受けにくくなるけど，これだと近傍矩形数が高くなるごとに，最大が更新されていってしまい，だんだん認識のハードルが上がっていってしまう．それに，人それぞれでの判定ということもできない．

なので，これをそのまま使うわけにはいかない．

ということで考えた方針
（１）サイズに対しては，顔の領域を切り出した後，その画像のリサイズして，統一する．
（２）輝度に対しては，切り出した顔領域のグレースケール値について，最大値と最小値を取得して，それらで正規化．

この方針に基づいて，以下のように強度指標を実装してみた．
加工した顔領域画像を笑顔認識処理にかけて，得られた近傍矩形数をもとに強度を計算．
変域を0から1の間にしたいので，その範囲に来るようにLVという係数を設定．ただ，単純に係数をかけるだけなので，近傍数をたくさん認識してる場合には1を超えることがある．なので場合には，強制的に1にする．こうすると，LVは単なる係数というだけでなく，笑顔認識の難易度パラメータにもなる．

なお，顔の領域の統一のために，顔領域の認識の際に，100x100を最小領域値に設定．

さらに，得られた強度指標をもとに円の表示色も変えてみた．

````python:SmileIntensity.py
import cv2
import numpy as np

capture = cv2.VideoCapture(0)
capture.set(3,640)# 320 320 640 720
capture.set(4,480)#180 240  360 405

face_cascade = cv2.CascadeClassifier('./haarcascade_frontalface_default.xml')
smile_cascade = cv2.CascadeClassifier('./haarcascade_smile.xml')

while True:
        ret, img = capture.read()
        img = cv2.flip(img,1)#鏡表示にするため．
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

        faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(100,100))
        for (x,y,w,h) in faces:
                cv2.rectangle(img,(x,y),(x+w,y+h),(255, 0, 0),2) # blue
                #Gray画像から，顔領域を切り出す．
                roi_gray = gray[y:y+h, x:x+w] 

                #サイズを縮小
                roi_gray = cv2.resize(roi_gray,(100,100))
                #cv2.imshow("roi_gray",roi_gray) #確認のためサイズ統一させた画像を表示
                
                 # 輝度で規格化
                 lmin = roi_gray.min() #輝度の最小値
                 lmax = roi_gray.max() #輝度の最大値
                 for index1, item1 in enumerate(roi_gray):
                         for index2, item2 in enumerate(item1) :
                                 roi_gray[index1][index2] = int((item2 - lmin)/(lmax-lmin) * item2)
                #cv2.imshow("roi_gray2",roi_gray)  #確認のため輝度を正規化した画像を表示

                smiles= smile_cascade.detectMultiScale(roi_gray,scaleFactor= 1.1, minNeighbors=0, minSize=(20, 20))#笑顔識別
                if len(smiles) >0 : # 笑顔領域がなければ以下の処理を飛ばす．#if len(smiles) <=0 : continue でもよい．その場合以下はインデント不要
                        # サイズを考慮した笑顔認識
                        smile_neighbors = len(smiles)
                        #print("smile_neighbors=",smile_neighbors) #確認のため認識した近傍矩形数を出力
                        LV = 2/100
                        intensityZeroOne = smile_neighbors  * LV 
                        if intensityZeroOne > 1.0: intensityZeroOne = 1.0 
                        #print(intensityZeroOne) #確認のため強度を出力
                        for(sx,sy,sw,sh) in smiles:
                                cv2.circle(img,(int(x+(sx+sw/2)*w/100),int(y+(sy+sh/2)*h/100)),int(sw/2*w/100), (255*(1.0-intensityZeroOne), 0, 255*intensityZeroOne),2)#red
  
        cv2.imshow('img',img)
        # key Operation
        key = cv2.waitKey(5) 
        if key ==27 or key ==ord('q'): #escまたはeキーで終了
                break
capture.release()
cv2.destroyAllWindows()
print("Exit")    
```

ちなみに，輝度の正規化をするときに，浮動小数点の値のままだとdetectMultiScaleが受け付けてくれないので，配列に格納されている値を整数型にする必要があるのだが，単純の行列演算をint()でくくるだけではキャストしてくれない（これも多分下に記述してるPythonの配列の仕様によるものと考えられる）．なので，一個一個取り出して整数型にしてやる必要があった．（本当はもっとスマートな別の方法があるのかもしれないが，私の力では見つけられなかった）

そこで，ちょっと嵌まったのが，pythonとC系列の言語での配列の違い．
Cとかだと，2次元配列といっても，1次元ベクトルと互換性があるので（正しくはメモリ上でそのまま値がざーっと並んでるだけなので，h行w列の配列の場合，A[m][n]=A[m*w+n]），そのままベクトルとして演算できたり，あるいは各要素にアクセスするのにfor文を1回回すだけですんだのだが，Pythonの場合は2次元配列は「1次元配列のリスト」という形で表現されていて，メモリ上ですべてのデータが並んでいるわけではないらしい．

ところで，やっていて面白いことに気がづいた．顔を認識するときに，矩形領域を示すベクトルとして，x, y, w, hが出力されているが，どうも切り出しているのは長方形ではなく正方形らしい．つまりw==h．

あと，輝度のコントロールは実はあんまり意味がないかもしれない．

参考
1) OpenCV公式のCascade_Classifyerのマニュアル（英語）
https://docs.opencv.org/2.4/modules/objdetect/doc/cascade_classification.html
2) Haar Cascadesを使った顔検出（日本語）
http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_objdetect/py_face_detection/py_face_detection.html#face-detection
3) 強度指標も書いてあるソース（CPP）
https://github.com/opencv/opencv/blob/master/samples/cpp/smiledetect.cpp
4) 配列（Array）の最大，最小
https://www.lisz-works.com/entry/numpy-argmax
https://deepage.net/features/numpy-max.html
5) Pythonでの2次元配列について
http://delta114514.hatenablog.jp/entry/2018/01/02/233002

#ちょっとした遊びを入れる
n人以上が同時に一定強度以上の笑顔を一定時間維持できるとクリアとなるゲームにしてみる．
仕様としては，
１）それぞれの顔領域を囲む矩形の色は，笑顔の強度に応じて変化する．（赤がMax,青がMin）
２）規定の人数の笑顔を認識すると，画面上でカウントダウンを表示する
３）クリアすると音が鳴る
４）規定の人数，規定の時間は定数として冒頭で設定

```python:smilegame.py
import cv2
import numpy as np
import time
import pygame.mixer

capture = cv2.VideoCapture(0)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT,720)#180 240  360 405
capture.set(cv2.CAP_PROP_FRAME_WIDTH, 960)# 320 320 640 720
cv2.namedWindow('img', cv2.WND_PROP_FULLSCREEN)
print( capture.get(cv2.CAP_PROP_FPS) ) 
print( capture.get(cv2.CAP_PROP_FRAME_WIDTH) )
print( capture.get(cv2.CAP_PROP_FRAME_HEIGHT) )

LV_GAIN = 5/100 #Smile_neighborからIntensityに変換するときのGain
TH_SMILE_NUM= 8 #所定の笑顔認識数　今は8人に設定
TH_SMILE_TIME= 5#維持しないといけない秒数

#笑顔強度に応じたRGBの色を返す
def Intensity2RGB( intensityZeroOne ):
        if intensityZeroOne < 0.1: return (255, 0, 0)
        elif intensityZeroOne < 0.2: return (255, 127, 0)
        elif intensityZeroOne < 0.3: return (255, 255, 0)
        elif intensityZeroOne < 0.4: return (127, 255, 0)
        elif intensityZeroOne < 0.5: return (0, 255, 0)
        elif intensityZeroOne < 0.6: return (0, 255, 127)
        elif intensityZeroOne < 0.7: return (0, 255, 255)
        elif intensityZeroOne < 0.8: return (0, 127, 255)
        else : return (0, 0, 255)

face_cascade = cv2.CascadeClassifier('./haarcascade_frontalface_default.xml')
smile_cascade = cv2.CascadeClassifier('./haarcascade_smile.xml')

#時間計測のための変数
f_timecount = False
t_starttime = 0

#ゲームクリアしたかどうか
f_clear = False

#効果音を鳴らすための処理
pygame.mixer.init()
pygame.mixer.music.load("chaim.mp3")

while True:
        if f_clear ==False:
                ret, img = capture.read()
                img = cv2.flip(img,1)#鏡表示にするため．
                gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

                faces = face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(50,50))

                #笑顔カウンタをリセット
                smilecount = 0

                for (x,y,w,h) in faces:
                        #顔領域切り出し
                        roi_gray = gray[y:y+h, x:x+w] 

                        #サイズを規格化
                        roi_gray = cv2.resize(roi_gray,(100,100))

        #                # 輝度で規格化
        #                lmin = roi_gray.min() #輝度の最小値
        #                lmax = roi_gray.max() #輝度の最大値
        #                for index1, item1 in enumerate(roi_gray):
        #                        for index2, item2 in enumerate(item1) :
        #                                roi_gray[index1][index2] = int((item2 - lmin)/(lmax-lmin) * item2)

                        #笑顔認識
                        smiles= smile_cascade.detectMultiScale(roi_gray,scaleFactor= 1.1, minNeighbors=0, minSize=(20, 20))

                        #笑顔強度の算出
                        smile_neighbors = len(smiles)
                        intensityZeroOne = smile_neighbors  * LV_GAIN 
                        #if intensityZeroOne > 1.0: intensityZeroOne = 1.0 

                        #顔領域に矩形描画（色は強度に応じて）
                        cv2.rectangle(img,(x,y),(x+w,y+h), Intensity2RGB( intensityZeroOne ) ,2) # blue

                        #笑顔強度が0.8以上の場合，笑顔としてカウント
                        if intensityZeroOne >= 0.8 : smilecount+=1

                print("笑顔数=",smilecount) #認識した笑顔数を表示

                #時間計測
                if smilecount >= TH_SMILE_NUM: # もし笑顔数が閾値超えてたら
                        if f_timecount == False: #もしまだカウントダウンが始まってなかったら
                                f_timecount=True
                                t_start = time.time()
                        else :#カウントダウンが始まってたら
                                if time.time() - t_start < TH_SMILE_TIME: #もしカウントダウンが閾値超えてないなら
                                        tremain = TH_SMILE_TIME - (time.time() - t_start) #残り時間の算出
                                        cv2.putText(img, str(np.ceil(tremain)), (50,50),cv2.FONT_HERSHEY_SIMPLEX, 2, (0,255,0))
                                else:#もしカウントダウンが閾値超えたら
                                        cv2.putText(img, "OK!!", (50,50), cv2.FONT_HERSHEY_SIMPLEX, 2, (0,255,0)) #成功の記載
                                        #ゲームクリアのフラグ
                                        f_clear= True 
                               

                else : #笑顔数が閾値を下回ったら
                        f_timecount = False # 時間計測をストップ
                        t_start = 0.0

                cv2.imshow('img',img)
                
                if f_clear == True : #ゲームクリアのフラグが立ってたら
                        #音を鳴らすコード
                        pygame.mixer.music.play(3)
                        time.sleep(1)
                        pygame.mixer.music.stop()

        # key Operation
        key = cv2.waitKey(5) 
        if key ==27 or key ==ord('q'): #escまたはeキーで終了
                break            
        elif key == ord('r'): #ゲームをリスタート
                f_clear=False

capture.release()
cv2.destroyAllWindows()

print("Exit")    

```

https://qiita.com/week/items/ab190474eeb7c1fe9fc2

で，どうしても現時点で解決できないのが，カメラの解像度設定が，4:3しか設定できず，また960*720までこと．
色々とググって，多くの人が同じ問題にぶち当たってるみたいなんだけど，解決できない．
ちなみに使っているUSBカメラはLogicoolのC615．Logicool製のC270でも4:3に制限されるのは変わらない．
ノートパソコンの内臓カメラだとちゃんと16:9で設定できるのだが．
また，Win10標準のカメラアプリだと，USBカメラのほうもきちんと16:9で設定できるのだが．
OpenCVの問題なのかな．．．