バーチャル試着アプリ

2020/06/09

AI CORE XスターターキットとOpenVINO™ですぐに始めるディープラーニング推論」シリーズの10回目記事です。

このシリーズは、「ディープラーニングとは何か」から始まり、「各種ツールの使い方」「プログラミング基礎」「プログラミング応用・実践」までをステップバイステップでじっくり学び、自分で理解してオリジナルのAIアプリケーションが作れるようになることを目指しています。

第10回目はこれまでの内容を使ってリアルタイムなバーチャル試着アプリを作成します!

目次

概要

バーチャル試着で、カメラ画像に対してpng画像を描画させる際に必要となる情報は以下の3つだけです。

スケールは元の画像に対して何倍の大きさにするかの設定、角度は画像の中心座標を基準としてどれだけ回転させるかの設定、座標は画像の中心座標を基準としてどの位置に表示させるかの設定です。この3つの情報が分かれば、バーチャル試着アプリが完成したも同然です。

この3つの情報を得るためには、ちょっとだけ算数や数学の式が必要になりますが、1つずつ丁寧に解説しますので安心してください。みなさんはコードで式を書くだけで、計算自体はPythonが行います。

EyePoint

前回のツールで赤い眼鏡(6629_trim_small.png)のEyePointを出しました。今回はそれぞれの座標の値を EPL_x, EPL_y, EPR_x, EPR_yという変数に代入して使います。数字は人によって多少違うと思いますので、皆さんがツールで出力した値を使っていただいて構いません。

# EyePoint情報(6629_trim_small.png) 
EPL_x = 137
EPL_y = 237
EPR_x = 381
EPR_y = 237

では、これから「スケール」「座標」「角度」の順に1つずつ対応してゆきます。

スケール

まず「スケール」のみの対応です。

アイテム画像の大きさは常に一定であるのに対し、カメラに映る顔の大きさはカメラとの位置関係によって変わってきます。ここでは、顔の大きさに合わせてアイテム画像の大きさを縮小もしくは拡大するために、掛け算する値のことをスケールと呼ぶことにします。

「アイテム画像」と「カメラに映る顔」で、共通なものに対し比較すればスケールが求まりそうです。今回は左右のEyePointの距離EP_distanceとカメラに映った左右の目の距離eye_distanceを比較し、スケールを決めることにしましょう

距離の計算

2点の座標が分かっているとき、座標2点間の距離は以下の式から求まります

これは三平方の定理(ピタゴラスの定理)から求まりますので、興味のある方は調べてみてください

pythonでルート(平方根)を計算するには masth.sqrtを使います。例えば√2 であれば math.sqrt(2)です。また、pythonで2乗を計算する場合は ** を使います。例えば10の2乗であれば10 ** 2です。

まずEyePoint側の距離EP_distanceは以下のコードで求まります

import math
 
# EyePoint距離 
EP_distance = math.sqrt((EPR_x - EPL_x) ** 2 + (EPR_y - EPL_y) ** 2)

次にカメラに映った目の距離eye_distanceですが、ディープラーニングでは以下の変数に目の座標を代入していますので、この変数を利用します。

先程と同様に以下のコードで求まります。

import math
 
# 目の距離 
eye_distance = math.sqrt((eye_right_x - eye_left_x) ** 2 + (eye_right_y - eye_left_y) ** 2)

これで、EyePoint、実際の目、それぞれの左右間の距離を得ることが出来ました。

比率の計算

スケール、つまり比率は以下の計算式で求まります。

比率 = 対象 / 基準

今回は対象が「カメラに映った目の距離」、基準が「EyePoint距離」なのでスケールitem_scaleは以下のコードで求まります

# アイテムのスケール 
item_scale =  eye_distance / EP_distance

アイテムを表示する位置はとりあえず仮固定で(300, 100)としましょう。frameに対して、item_scaleを適用したアイテム描画は以下で可能です

from pngoverlay import PNGOverlay
 
item = PNGOverlay('image/6629_trim_small.png')
 
# アイテム描画 
item.resize(item_scale)  # スケール 
item.show(frame, 300, 100) # 仮の座標 

これでスケール対応の準備が整いました。8回目記事の最後のコードから「目の位置に表示」部を削除して、これまでの内容を盛り込んでみて下さい。
全て盛り込んだソースコードは以下のリンク先にありますが、まずは自分で作成できるか挑戦してみてください。

バーチャル試着アプリ完成前コード(スケールのみ対応)

実行結果はこんな感じになります

どうでしょうか。位置は(300, 100)で固定ですが、カメラと顔の距離関係に合わせてアイテムの大きさが適切に変化していると思います。なお、顔や目がカメラからはみ出るとディープラーニングは正しく結果を出せないため、アイテム表示も正しくない表示になりますのでご注意ください

コードの解説です。
基本的には「スケール対応」と書かれた箇所に今回の内容を盛り込んでいます。全てwhile True:の中に書いても良いのですが、EyePoint座標やEyePoint距離などの数値はカメラ映像によって変わるものではないため、while Ture: の直前に書いています。またimport関連は最初にまとめています。

座標

次はアイテムを表示する位置を実際の目の位置に合わせたいと思います。
図のように、左目位置と左EyePoint位置を一致させる前提で進めます。

アイテム画像の座標

前回のツールでもお伝えしましたが、PNGOverlayは指定座標に対し、画像の中心が来るように描画します

item.show(frame, 300, 100)

例えば上記コードに記述した内容は下記の図の左側ではなく、右側になります

アイテム画像の左上を(300, 100)に描くのではなく、アイテム画像の中心を(300, 100)に描いています。

相対座標の計算

最終的に知りたい情報は、アイテム画像(中心)をカメラ画像frameのどこに表示するかです。
しかし、「アイテム画像(中心)」と「カメラ画像frame」には直接の関係性はないため、間接的な関連をたどって計算したいと思います。

アイテム画像(中心)について以下の順に求めてゆきます

EyePoint_left基準

「アイテム画像(中心)」と「EyePoint」の関連が分かれば求まります。

EyePointの各座標は、アイテム画像(左上)からの数値を表しています。
つまり、アイテム画像(左上)を基準とした場合の EyePoint_leftの座標は(EPL_x, EPL_y)です。
逆にEyePoint_leftを基準とした場合のアイテム画像(左上)の座標は(-EPL_x, -EPL_y)です

アイテム画像の幅と高さはitem.width, item.heightで取得できます。
よって、EyePoint_leftを基準とした場合のアイテム画像(中心)の座標は以下で求めることができます

# アイテム座標(EyePoint_left基準) 
item_x_EPL = item.width/2 - EPL_x
item_y_EPL = item.height/2 - EPL_y

eye_left基準

先程の座標(item_x_EPL, item_y_EPL)に「EyePoint」と「eye」の関連を加えれば求まります。

先程述べたように、EyePoint_left と eye_left の座標は同じです。
そのとき、 左目以外の座標はどうでしょうか?

スケールを考慮する必要があることが分かると思います
これを踏まえると、eye_leftを基準とした場合のアイテム画像(中心)の座標は以下で求めることとなります

# アイテム座標(左目基準) 
item_x_eyeleft = item_x_EPL * item_scale
item_y_eyeleft = item_y_EPL * item_scale

frame原点基準

先程の座標(item_x_eyeleft, item_y_eyeleft)に「frame」と「eye」の関連を加えれば求まります

eyeはframeをディープラーニングした結果の座標です。
つまり、eye_leftの位置はframe原点から座標です。
これを加えることにより、「frame」基準のアイテム座標位置が決まります

# アイテム座標 
item_x = item_x_eyeleft + eye_left_x
item_y = item_y_eyeleft + eye_left_y

これで座標対応の準備が整いました。先程の「バーチャル試着アプリ完成前コード(スケールのみ対応)」に今までの内容を盛り込んでみて下さい
全て盛り込んだソースコードは以下のリンク先にあります。

バーチャル試着アプリ完成前コード(スケール・座標対応)

実行結果はこんな感じになります

かなり完成に近づきました。ただし、首を傾けるなど「角度」があるとずれます。

コードの解説です。
EyePoint_left基準のアイテム座標計算は、カメラ画像によって影響を受けないため、While True:直前に追加、その他はWhile True:のアイテム描画前に加えています。またitem.showで座標item_x, item_yを指定する際に、整数である必要があるためint()で整数化しています。

 

角度

最後に「角度」対応したいと思います。
PNGOverlayは角度を与えると画像の中心で簡単に回転できます。

つまり、回転角度さえ分かれば良いということです
そして、目の傾きは、左目に対して右目がどれだけの角度があるかを調べれば良さそうです。角度を調べる際に三角関数が必要となりますので、さらっと説明します。

三角関数

三角関数 sinθ、cosθ、tanθは、角度θさえ分かれば直角三角形の2辺の比を求めることが出来る便利な計算式です。

これとは逆に直角三角形の2辺の比が分かることで角度θの値を求めることができます。
そのときに使われるのは「逆三角関数」で、arcsin, arccos, arctanと呼ばれています。数式で書く場合は-1を使って以下のように表記します

この式が示す通り、θを求める方法が3通りあるということです。今回はarctanを活用したいと思います。

三角関数や逆三角関数は直角三角形だけではなく、座標においても適用可能です。
例えば原点(0, 0)に対し、点の座標(a, b)が分かっている場合は、arctanを使って図の角度θを求めることが可能です。

Pythonプログラムでarctanを使う場合はmath.atan()もしくはmath.atan2()という関数を使います。今回のように-90~+90度の範囲を超える可能性がある場合はmath.atan2()の方を使います。
math.atan2は引数が2つあり、b, a の順に記述しますのでご注意下さい

math.atan2(b, a)

戻り値の角度ですが、単位は度(degree)ではなく、radianという単位です。
radianからdegreeへの変換はPythonの場合、math.degrees()という関数があるので、こちらを活用します

以下の画像のときの角度をPythonで新規コードを作成して確かめてみましょう

# 新規作成でatan2確認コード 
import math
 
angle = math.atan2(1.00, 1.73)
print(math.degrees(angle))

ほぼ30度で値が出たと思います。先程の図形はよく数学で出てくる辺の長さの比が1:2:√3という直角三角形です。√3 ≒ 1.73 です

これを実際の目eyeの座標に当てはめます。eye_leftを基準として考えた相対座標で求めます。

# 目の角度 
eye_angle = math.atan2(eye_right_y - eye_left_y, eye_right_x - eye_left_x)

これでeyeの角度が分かりました。

一方でEyePointは今までたまたま0度でしたが、もしかしたら角度を持ったEyePointも出てくる可能性があります。そのようなアイテム画像にも対応できるようにしたいと思います。

EyePointの角度も同様にコード化します

# EyePointの角度 
EP_angle = math.atan2(EPR_y - EPL_y, EPR_x - EPL_x)

先程の図のように、最初からEyePointが傾いている場合は、eyeに対して足りない角度だけ対応すればよさそうです。コードで書くと次のように単純な引き算となります

# アイテムの回転角度 
item_angle = eye_angle - EP_angle

これで角度が求まりました!

PNGOverlayの回転

OpenCVの座標系の回転は時計回りが正なのに対し、PNGoverlayの回転方向は反時計回りが正です。

実際に以下のコードを新規作成して実行してみて下さい
赤いメガネが画像中心を基準として反時計回りに60度回転しています

import cv2
import numpy as np
from pngoverlay import PNGOverlay
 
# 白画の生成 
img = np.zeros((6008003), np.uint8) + 255
 
# PNGOverlayインスタンス生成 
item = PNGOverlay('image/6629_trim_small.png')
 
# 透過PNGを描画 
item.rotate(60)
item.show(img, 400 , 300)
 
cv2.imshow('image', img)
cv2.waitKey(0)
cv2.destroyAllWindows()

これまでの座標系では、時計回りかつ角度の単位はradianですので、rotate()で引数を指定する際に変換が必要となります。では、今までの内容を盛り込んでみて下さい。

盛り込んだコードの回答例はこちらです

バーチャル試着アプリ完成前コード(スケール・座標・角度対応)

実行結果はこんな感じになります

メガネは確かに同じように回転しています。しかし、位置が少しずれていますね?
この後、この原因と対策を行いたいと思います。

コードの解説です。
EyePointの角度のみwhile True:の直前、その他はwhile True:の中に記述しています。
item.rotate(-math.degrees(item_angle))にて、-1を掛けることにより逆回転、math.degreesによりradianからdegreeに変換しているのがポイントです。
なお、角度関連のコードは「スケール」と「座標」の間に挿入しています。

回転移動の対応

先程のコードで位置がずれた原因です。
下の図を見てください。左側は「目」も「アイテム画像」もeye_leftを基準として回転していますが、右側は「アイテム画像」はアイテム画像の中心を基準として回転しています。つまり、角度としては合っていますが、どこを中心として回転するかでずれが生じるということです。

スライドアプリや描画ツールのように2つの画像をグループ化して回転することが出来れば簡単ですが、カメラ画像とアイテム画像をグループ化するようなことは出来ません。

では、どうすれば良いでしょうか?
頭に帽子を載せていて首を曲げたとき、左目を中心とすると帽子は単にその場で回転だけではなく、移動も伴うはずです。この左目を中心とする回転に対する移動量が分かれば良さそうです

緑の点がアイテム画像の中心を表しています。

回転移動

回転移動は数学で有名な式があります。
原点を中心に点(x1, y2)から角度θで回転したときの点(x2, y2)の座標は以下の式で表せます

sinやcosは少し前に出てきた三角関数です。なぜこの式になるのか興味のある方は回転行列などで調べてみて下さい
この式をPythonのコードで書くと以下のようになります。

# (x1, y1)をitem_angle回転させた座標 
x2 = x1 * math.cos(theta) - y1 * math.sin(theta)
y2 = x1 * math.sin(theta) + y1 * math.cos(thetae)

(x1, y1) は(item_x_eyeleft, item_y_eyeleft)に相当しますので、先程のコードの中の「アイテム座標(左目基準)」と「アイテム座標」の間で計算してあげる必要があります。

これを盛り込んだコードがこちらの最終コードです

#================================================== 
# 準備 
#================================================== 
# import 
import cv2
import numpy as np
from openvino.inference_engine import IENetwork, IEPlugin
import math
from pngoverlay import PNGOverlay
 
# ターゲットデバイスの指定 
plugin = IEPlugin(device='MYRIAD')
 
# モデルの読み込みと入出力データのキー取得(顔検出) 
net_face  = IENetwork(model='intel/face-detection-retail-0005/FP16/face-detection-retail-0005.xml', weights='intel/face-detection-retail-0005/FP16/face-detection-retail-0005.bin')
exec_net_face  = plugin.load(network=net_face)
input_blob_face = next(iter(net_face.inputs))
out_blob_face  = next(iter(net_face.outputs))
 
# モデルの読み込みと入出力データのキー取得(landmarks) 
net_landmarks = IENetwork(model='intel/landmarks-regression-retail-0009/FP16/landmarks-regression-retail-0009.xml', weights='intel/landmarks-regression-retail-0009/FP16/landmarks-regression-retail-0009.bin')
exec_net_landmarks = plugin.load(network=net_landmarks)
input_blob_landmarks = next(iter(net_landmarks.inputs))
out_blob_landmarks = next(iter(net_landmarks.outputs))
 
# カメラ準備 
cap = cv2.VideoCapture(0)
 
# PNGOverlayインスタンス生成 
item = PNGOverlay('image/6629_trim_small.png')
 
# EyePoint情報(6629_trim_small.png) 
EPL_x = 137
EPL_y = 237
EPR_x = 381
EPR_y = 237
 
# EyePoint距離 
EP_distance = math.sqrt((EPR_x - EPL_x) ** 2 + (EPR_y - EPL_y) ** 2)
 
# EyePointの角度 
EP_angle = math.atan2(EPR_y - EPL_y, EPR_x - EPL_x)
 
# アイテム座標(EyePoint_left基準) 
item_x_EPL = item.width/2 - EPL_x
item_y_EPL = item.height/2 - EPL_y
 
#================================================== 
# メインループ 
#================================================== 
while True:
    # キー押下で終了 
    key = cv2.waitKey(1)
    if key != -1:
        break
 
    # カメラ画像読み込み 
    ret, frame = cap.read()
 
    # 入力データフォーマットへ変換 
    img = cv2.resize(frame, (300300)) # HeightとWidth変更 
    img = img.transpose((201))      # HWC > CHW 
    img = np.expand_dims(img, axis=0)   # CHW > BCHW 
 
    # 推論実行 
    out = exec_net_face.infer(inputs={input_blob_face: img})
 
    # 出力から必要なデータのみ取り出し 
    out = out[out_blob_face]
 
    # 不要な次元を削減 
    out = np.squeeze(out)
 
    # 検出されたすべての顔領域に対して1つずつ処理 
    for detection in out:
        # conf値の取得 
        confidence = float(detection[2])
 
        # バウンディングボックス座標を入力画像のスケールに変換 
        xmin = int(detection[3] * frame.shape[1])
        ymin = int(detection[4] * frame.shape[0])
        xmax = int(detection[5] * frame.shape[1])
        ymax = int(detection[6] * frame.shape[0])
 
        # conf値が0.5より大きい場合のみLandmarks推論とバウンディングボックス表示 
        if confidence > 0.5:
           # 顔検出領域はカメラ範囲内に補正する。特にminは補正しないとエラーになる 
            if xmin < 0:
                xmin = 0
            if ymin < 0:
                ymin = 0
            if xmax > frame.shape[1]:
                xmax = frame.shape[1]
            if ymax > frame.shape[0]:
                ymax = frame.shape[0]
 
            #-------------------------------------------------- 
            #  ディープラーニングLandmarks推定 
            #-------------------------------------------------- 
            # 顔領域のみ切り出し 
            img_face = frame[ ymin:ymax, xmin:xmax ]
 
            # 入力データフォーマットへ変換 
            img = cv2.resize(img_face, (4848)) # HeightとWidth変更 
            img = img.transpose((201))       # HWC > CHW 
            img = np.expand_dims(img, axis=0)    # CHW > BCHW 
 
            # 推論実行 
            out = exec_net_landmarks.infer(inputs={input_blob_landmarks: img})
 
            # 出力から必要なデータのみ取り出し 
            out = out[out_blob_landmarks]
 
            # 不要な次元を削減 
            out = np.squeeze(out)
 
            # 目の座標を顔画像のスケールに変換し、オフセット考慮 
            eye_left_x = int(out[0] * img_face.shape[1]) + xmin
            eye_left_y = int(out[1] * img_face.shape[0]) + ymin
            eye_right_x = int(out[2] * img_face.shape[1]) + xmin
            eye_right_y = int(out[3] * img_face.shape[0]) + ymin
 
            #-------------------------------------------------- 
            # アイテムのスケール・座標・角度・回転移動対応 
            #-------------------------------------------------- 
            # 目の距離 
            eye_distance = math.sqrt((eye_right_x - eye_left_x) ** 2 + (eye_right_y - eye_left_y) ** 2)
 
            # アイテムのスケール 
            item_scale =  eye_distance / EP_distance
 
            # 目の角度 
            eye_angle = math.atan2(eye_right_y - eye_left_y, eye_right_x - eye_left_x)
 
            # アイテムの回転角度 
            item_angle = eye_angle - EP_angle
 
            # アイテム座標(左目基準) 
            item_x_eyeleft = item_x_EPL * item_scale
            item_y_eyeleft = item_y_EPL * item_scale
 
            # アイテム座標(左目基準)をitem_angle回転させた座標 
            x2 = item_x_eyeleft * math.cos(item_angle) - item_y_eyeleft * math.sin(item_angle)
            y2 = item_x_eyeleft * math.sin(item_angle) + item_y_eyeleft * math.cos(item_angle)
 
            # アイテム座標 
            item_x = x2 + eye_left_x
            item_y = y2 + eye_left_y
 
            # アイテム描画 
            item.resize(item_scale)  # スケール 
            item.rotate(-math.degrees(item_angle)) # 角度 
            item.show(frame, int(item_x), int(item_y)) # 座標 
 
    # 画像表示 
    cv2.imshow('frame', frame)
 
#================================================== 
# 終了処理 
#================================================== 
cap.release()
cv2.destroyAllWindows()

変更点はwhile True:ループ内の「アイテム座標(左目基準)をitem_angle回転させた座標」追加と「アイテム座標」の変更のみです。
実行結果はこんな感じになります

いかがでしょうか?
これで、首を傾けても合うようになりました!
ただし、90度近くまで回転させるとディープラーニングのランドマーク回帰自体が正しく検出できないため、表示も正しくなくなりますのでご注意ください。

お疲れさまでした!!
ぜひ、いろんなアイテム画像で試してみて下さい。その際、事前にツールを使ってEyePoint情報を取得しておくことをお忘れなく。


次回は、少し話題を変えて、OpenVINO™ツールキットの特徴の1つ「Model Optimizer」を使ったディープラーニングに挑戦します。
以上、「バーチャル試着アプリ」でした。