機械学習で女性のタイプ判定処理を行う

こんにちは、江口です。

前回に引き続き、pythonネタです。

皆さんは、同僚との会話で「この女優、どう?」と聞かれて困ったことはありませんか?
私はあります。そして、良いとも悪いとも思えず、なんと言いますか、興味が湧かない時が一番返答に困ります。

今回はズバリ「機械学習で好みの女性なのかどうか仕分ける!」です。
ネタではありますが、真剣です。これによりデータに基づいた返答ができるようになります。

▼概要
1. 自分好みの女優の画像とそうでない画像を収集する。
2. 収集した画像から顔だけを抽出し、100x100にリサイズして保存する。
3. SVM分類器にかけて、機械学習を行い、学習結果をpickleに漬け込む
4. 女優画像が自分の好みかどうか判定する

こんな流れになってます。
詳しい流れ、実装は後述するソース解説で紹介します。

▼使用技術とその用途
・python 3.6
・scikit-learn -> 機械学習ライブラリ
・GoogleCustomSearch API -> 画像取得に使用
・BeautifulSoup -> スクレイピングに使用
・SVM分類器 -> 実際に機械学習するアルゴリズム
・OpenCV -> 画像から顔・輪郭抽出するのに使用
・joblib->pickleファイル漬け込み

▼1. 自分好みの女優の画像とそうでない画像の収集

収集はsave_beautiful.pyで行います。


# -*- coding: utf-8 -*-
import requests
import re
import os
from PIL import Image as resizer
API_URL = "https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=%s &searchType=image &start=%s"
def getImageFromCustomAPI(start, end, word, kind):
    for i in range(start, end, 10):
        print(API_URL % (word, str(i)))
        response = requests.get(API_URL % (word + ",正面", str(i)))
        for j in range(len(response.json()["items"])):
            json = response.json()["items"][j]
            res = requests.get(json["link"], stream=True)
            if res.status_code == 200:
                with open("images/target_%s_%s.png" % (kind, (str(i) + "_" + str(j))), 'wb') as file:
                    for chunk in res.iter_content(chunk_size=1024):
                        file.write(chunk)
def resizeImages(dirname, regular):
    """
    images下の画像を2次元の特徴ベクトルに変換する為に100x100リサイズを行う
    """
    for image in os.listdir(dirname):
        if re.match(regular, image):
            print(image)
            img = resizer.open("images/" + image, 'r')
            img = img.resize((100, 100))
            img.save("images/" + image, 'png', quality=100, optimize=True)
    print("resized")
def main():
    # 「人名,正面」にすると、より多くの正面画像を拾える
    words = ['北川景子', '吉高由里子', '新垣結衣', '榮倉奈々', '安室奈美恵', '長澤まさみ', '西内まりや', '麻生久美子', '倉科カナ', '井上真央', '石原さとみ', 'ガッキー', '堀北真希']
    # words = ['フィーフィー', '安藤なつ', '澤穂希', '白鳥久美子', '光浦靖子', 'ブルゾンちえみ', 'おかずクラブオカリナ']
    for word in words:
        getImageFromCustomAPI(1, 92, word, "YES-" + word + "_")
    resizeImages("images", "target_")
if __name__ == '__main__':
    main()

解説・・・

画像の収集にはGoogleCustomSearchを利用しています。
qパラメータに自動で「,正面」という文字列を組み立て、searchインデックス(startパラメータ)に1~91までを10区切りで指定しています。
つまり、


https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=1
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=11
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=21
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=31
https://www.googleapis.com/customsearch/v1?key=[APIKEY] &cx=[ENGINE] &q=[新垣結衣] &searchType=image &start=91

このような具合で順繰りにリクエストを発行していきます。
一回にリクエストで10枚、一人の女優につき1~91でつまり100枚近くの画像を収集しています。
今回は無料枠で利用しているため、以下のような制約があります。

・100req/day(17:00にリセットされる)
・一回に取得できるデータ量は10count

取得した画像は残さずimages/下に


target_YES-[女優名]__81_1.png

の形で保存しています。YESがタイプで、NOがタイプでないことを表すようにしています。
また、なるべく正面を向いている画像を取得しようと試みています。

最後に100x100にリサイズして改めて保存しています。

こんな感じで、私のタイプの女優画像が蓄積されていきます!

▼2.収集した画像から顔だけを抽出して保存

好みの女優の系統を判定したいので、背景や服のような副次的情報は不要ですし、むしろ判断を難しくさせますので、純粋に顔だけを抽出しにいきます!

・pickup-faces.py


# -*- coding: utf-8 -*-
import os
import cv2
def main():
    """
    images下の美人画像一覧を読み出し、faces下に顔画像検出した結果を保存する
    """
    for image_path, _, files in os.walk('images'):
        if len(_):
            continue
        face_path = image_path.replace('images', 'faces')
        if not os.path.exists(face_path): os.makedirs(face_path)
        for filename in files:
            if not filename.startswith('.'):
                save_faces(image_path, face_path, filename)
def save_faces(image_path, face_path, filename):
    """
    真正面顔判定用のOpenCVファイルを使って、顔画像を切り出す
    """
    print(image_path, face_path, filename)
    # カスケード分類器を読み込む(正面顔の検出分類器)
    cascade = cv2.CascadeClassifier('haarcascades/haarcascade_frontalface_alt2.xml')
    image = cv2.imread('{}/{}'.format(image_path, filename))
    gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    faces = cascade.detectMultiScale(gray_image)
    # Extract when just one face is detected
    if (len(faces) == 1):
        (x, y, w, h) = faces[0]
        image = image[y:y+h, x:x+w]
        image = cv2.resize(image, (100, 100))
        cv2.imwrite('{}/{}'.format(face_path, filename), image)
    else:
        cv2.imwrite('{}/{}'.format(face_path + "/misses", filename), image)
        print("skipped.")
if __name__ == '__main__':
    main()

解説・・・

顔・輪郭の画像抽出にはOpenCVを利用しています。
imagesフォルダにあるYES or NO画像を全てOpenCVの顔抽出分類器にかけて、うまいこと輪郭画像が抽出できたら、faces下に顔画像を保存しています。

参考になるように、失敗した場合は、faces/misses下に保存しています。
missesを見ると大体が横顔画像だったり、複数人写っていたり、遠すぎたり、影りが多かったりとする画像が殆どでした。

faces下の画像はこんな感じです。

▼3. SVM分類器にかけて、機械学習を行い、学習結果をpickleに漬け込む

save_vector3.py


# -*- coding: utf-8 -*-
"""
Face画像特徴ベクトルに変換し、保存する
"""
from PIL import Image
import os
import re
import numpy as np
from sklearn.externals import joblib
from sklearn import svm
def convertImageVector3(img):
    """
    3次元(100x100x3(RGB))から1次元に変換する
    """
    s = img.shape[0] * img.shape[1] * img.shape[2]
    img_vector3 = img.reshape(1, s)
    return img_vector3[0]
def getDatas():
    """
    3次元ベクトル学習画像データと正解ラベルを対にして、pickleファイルにして保存する
    """
    files = ["faces/" + f for f in os.listdir("faces") if re.match("target_", f)]
    labels = []
    datas = []
    for image in files:
        as_arrayed_img = np.asarray(Image.open(image))
        # 3次元かどうか
        if (len(as_arrayed_img.shape) == 3):
            # RGBが3がどうか
            if (as_arrayed_img.shape[2] == 3):
                datas.append(convertImageVector3(np.asarray(Image.open(image))))
                # 「YES」と「NO」を抽出
                labels.append(image.split("faces/target_")[1].split("-")[0].replace("reversed_", ""))
            else:
                print("skip not rgb3")
    print("converted.")
    return (datas, labels)
def learn(datas, labels):
    """
    データを学習
    """
    clf = svm.LinearSVC()
    clf.fit(datas, labels)
    joblib.dump(clf, "pkls/beauty.pkl")
    print("learned.")
if __name__ == '__main__':
    datas, labels = getDatas()
    print(len(datas), len(labels))
    learn(datas, labels)

解説・・・

かいつまんで言いますと、画像を3次元のベクトルから1次元に変換をかけた形で、正解ラベル(YES or NO)と一緒にパターンを学習させるということを行います。
俗にいう「教師あり学習」を行なっています。

学習にはSVMというアルゴリズムを使用し、YESつまり「私のタイプである女性の画像はこのような3次元ベクトルを有しています。」また、「私のタイプでない女性の画像はこのような3次元ベクトルを有しています。」と識別基準を教え込ませています。

SVMにより、YESとNOの境界値最大マージンを取り、識別を行います。

scikit-learnでSVM使った学習は


clf = svm.LinearSVC()
clf.fit(datas, labels)

このようにdataと正解のlabelをセットすることで学習させることができますが、永続化されません。

そのため、Objectを直列化し、学習データをpickleと呼ばれる形式で漬物保存します。


joblib.dump(clf, "pkls/beauty.pkl")

こうすることで、予測実行時に毎回学習し直すことなく、pklファイルをロードすることで機械学習過程を引き継いで予測・判定を行うことが可能になります。

例えば、学習したその場で予測判定を行うケースは


    clf = svm.LinearSVC()
    clf.fit(datas, labels)
 res = clf.predict(t_image_vector3)

このような記載になるかと思われます。
しかし、学習は結構な計算負担があるため、毎回学習計算を走らせず、pklで学習記録を読み出すことができます。

その場合は以下のような記載になります。


    clf = joblib.load(learnedFile)
    # 予測開始
    res = clf.predict(t_image_vector3)

▼4. 女優画像が自分の好みかどうか判定する

いよいよ判定です。
今のところ、imagesに1980の画像と、facesにその半分の1100画像が格納されています。
(顔輪郭抽出時に多くが失敗し、データが半減しました。。)

これらを学習した結果、「ガッキー」は私のタイプなのかを判定します!

judge.py


# -*- coding: utf-8 -*-
import os
import io
import urllib.request
from PIL import Image
from sklearn.externals import joblib
import numpy as np
import cv2
from datetime import datetime as dt
import requests
from bs4 import BeautifulSoup as bs
import uuid
def convertImageVector3(img):
    """
    3次元(100x100x3(RGB))から1次元に変換する
    """
    s = img.shape[0] * img.shape[1] * img.shape[2]
    img_vector3 = img.reshape(1, s)
    return img_vector3[0]
def saveFaceImg(img):
    image = np.asarray(img)
    cascade = cv2.CascadeClassifier('haarcascades/haarcascade_frontalface_alt2.xml')
    faces = cascade.detectMultiScale(image)
    if (len(faces) == 1):
        (x, y, w, h) = faces[0]
        image = image[y:y+h, x:x+w]
        image = cv2.cvtColor(cv2.resize(image, (100, 100)), cv2.COLOR_BGR2RGB)
        cv2.imwrite('{}/{}'.format("targets", dt.now().strftime("%Y%m%d%H%M%S") + "_" + str(uuid.uuid1()) + ".png"), image)
        return image
    return ""
def main(imgs):
    # 試験データをhttp経由で取得し、100x100にリサイズ
    t_image_vector3 = []
    for img in imgs:
        target = saveFaceImg(Image.open(io.BytesIO(urllib.request.urlopen(img).read())).resize((100, 100)))
        if (len(target)):
            print(img)
            t_image_vector3.append(convertImageVector3(target))
    # 学習済みデータの取得
    learnedFile = os.path.dirname(__file__) + "/pkls/beauty.pkl"
    clf = joblib.load(learnedFile)
    # 予測開始
    res = clf.predict(t_image_vector3)
    for ret in res: print(ret)
if __name__ == '__main__':
    SEARCH_URL = r"https://www.google.co.jp/search?q=%s &source=lnms &tbm=isch &sa=X &biw=1439 &bih=780"
    TARGET_IMG_SRC_PATTERN = r"https://encrypted-"
    targets = []
    html = requests.get(SEARCH_URL % "ガッキー").text
    # BeautifulSoupでHTMLを解析
    soup = bs(html, 'html.parser')
    target_src = []
    images = soup.find_all('img')
    for img in images:
        targets.append(img['src'])
    main(targets)
    # main(['http://blog-imgs-42.fc2.com/m/e/m/memoriup/03267.jpg'])

解説・・・

Google画像検索をスクレイピングしています。
画像検索で「ガッキー」で検索させ、imgタグのsrc属性を抽出して、その画像URLの取得画像結果を分類器にかけています!
URLから画像を取得し、取得した画像をそのままresizeにかける記載は以下の通りです。


Image.open(io.BytesIO(urllib.request.urlopen("http://画像のURL").read())).resize((100, 100))

ソースでは、このresizeした画像をcv2モジュールで顔画像抽出した画像をtargets下に保存し、いよいよ判定コードにかけています。

それがこちらです。


    # 学習済みデータの取得
    learnedFile = os.path.dirname(__file__) + "/pkls/beauty.pkl"
    clf = joblib.load(learnedFile)
    # 予測開始
    res = clf.predict(t_image_vector3)
    for ret in res: print(ret)

上記で表示された結果を共有しますと。。。


NO
YES
YES
YES
NO
YES
NO
NO
YES
YES
YES
NO

となりました。
判定を行なった画像URLもログに出しているのですが、確かに微妙にガッキーではない画像も混じっていたため、
7,80%くらいの確度といった具合のように感じられます。

とはいえ、データが全然数が足りていないので、これから地道に学習データを増やしていこうと思います!!

学習データ量が圧倒的に足りないので、足していきつつも、次回はいよいよTensorflowで多重パーセプトロン、ディープラーニングによる分類・判定を行おうと思います!!

今回も拙い文章、ソースでしたが、お付き合いいただきありがとうございました。
ソースコードはGithubで管理(?)してますので、こちらをちょこちょこ修正していく形になるかと思いますが、ご了承ください。

https://github.com/yueguchi/asial-blog/tree/master/beauty-judge

<<追記>>
学習素材がやはり少ないのが悩みだったので、少し工夫しました。

faces下の画像を全てy軸に反転させることで素材を二倍に増やしました。

copy_faces.py


# -*- coding: utf-8 -*-
"""
女優の画像素材を増やすために、すでにあるfaces下の画像を反転させ、
画像素材として扱わせることで、二倍の素材が手に入る
"""
import os
import cv2
def main(targetDir):
    for path, _, files in os.walk(targetDir):
        for file in files: 
            if not file.startswith('.'):
                print(path + '/' + file)
                img = cv2.imread((path + '/' + file), cv2.IMREAD_COLOR)
                reversed_y_img = cv2.flip(img, 1)
                cv2.imwrite(path + '/' + file.replace('target_', 'target_reversed_'), reversed_y_img)
if __name__ == '__main__':
    main("faces")

実行後、pklを作り直してください。(save_vector3.pyを実行)
これで素材が二倍になります。

実際に反転させた画像はこんな感じです。

麻生久美子がy軸に反転した形で表示されています。

学習素材を増やしたあとに、今度は「ハーマイオニー」でGoogle画像検索の結果を判定器にかけたところ、100%でした。


    html = requests.get(SEARCH_URL % "ハーマイオニー").text

YES
YES
YES
YES
YES
YES
YES
YES

以上です。