アバターを顔から探しやすくするWebサービス「kaogaii」を支える技術

　[VRChat Advent Calendar 2020](https://adventar.org/calendars/5102)の4日目の記事です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/288125/367d9871-4e1d-7c05-9f97-9470c4ad7e51.png)

　[kaogaii](https://kaogaii.herokuapp.com/
)というWebサービスをリリースしました。

　BOOTHに登録されている中から選ばれた3,245種の商品（2020年11月末時点）アバター商品がメインだがそれ以外の物も含む。後述）からサムネイルを抽出し、ランダム選択　→　類似候補選択を繰り返すことによって、好みの商品にたどり着きやすくするサービスです。以下の章立てで話していきます。

- モチベーション
- スクレイピング
- 機械学習
- デプロイ

# モチベーション

　Boothには多数の3Dモデルが投稿されているため、数が多すぎるため、

- 一部のモデルに注目が集まる
- 自分好みのモデルを探そうと思っても探すのが大変

　という問題があります。これを解決するために、それぞれのアバターの顔に注目して、

- とりあえずランダムに探す
- 好みの顔があったら似た顔も探す

　ということを高速でできたら好みのアバターが見つけやすくなり、今まで目立たなかったアバターにも光が当たるのではないかという思いのもと、Webサービスを構築しました。

　実は2年ぐらい前に、Googleスプレッドシートを利用して人力でデータベースを構築しようとしていたのですが、100体を超えたぐらいですぐに限界がきて破綻しました。それ以来、人力ではなく機械の力でそういったデータベースを作れないかと考えていたというのもあります。

# スクレイピング

　まずはBOOTHから商品URLとサムネイルを取得します。以下のようなPythonスクリプトを組みました。

```python
import urllib.request as ur
from bs4 import BeautifulSoup
import requests
import csv
import cv2

def trim(img): #顔検出
    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    classifier = cv2.CascadeClassifier("lbpcascade_animeface.xml")
    faces = classifier.detectMultiScale(img_gray, minSize=(100, 100))
    if len(faces) == 0:
        return False, img
    x = faces[0][0]
    y = faces[0][1]
    w = faces[0][2]
    h = faces[0][3]
    face_image = img[y:y+h, x:x+w]
    face_image = cv2.resize(face_image,(256,256))
    return True, face_image

def word_check(title): #NGワードを弾く
    words = ['専用','衣装','水着','シャツ','テクスチャ','着物','コスチューム','ドレス',
            'ワンピース','ヘア','髪型','VRoid','Vroid','VROID',]
    for w in words:
        if w in title:
            return False
    return True

def img_save(img_url,page_url,title):
    global count
    response = requests.get(img_url)
    image = response.content
    with open("tmp.png", "wb") as o:
        o.write(image)
    img = cv2.imread("tmp.png")
    faceimage = trim(img)
    if faceimage[0] and word_check(title):
        print(title,page_url)
        with open('imglist_exclude.csv',encoding='utf-8') as f:
            reader = csv.reader(f)
            nowcsv = [row for row in reader]
        exist = False
        for n in nowcsv:
            if n[1] == page_url:
                print("exist!")
                return
        count = len(nowcsv)
        file_name = "{}.png".format(count)
        cv2.imwrite("exclude_data/{}".format(file_name),faceimage[1])
        with open("imglist_exclude.csv", "a", newline="", encoding='utf-8') as f:
            w = csv.writer(f, delimiter=",")
            w.writerow([count,page_url,title])

def img_search(url):
    html = ur.urlopen(url)
    soup = BeautifulSoup(html, "html.parser")
    title = str(soup.title.text)
    print("{}...".format(title[:15]),end="")
    char_list = '\/:*?"<>|~'
    for c in char_list:
        title = title.replace(c,"")
    for s in soup.find_all("img"):
        if str(s).find("market") > 0:
            img_url = s.get("src")
            if img_url is not None:
                img_save(img_url,url,title)
                break

def page_access(page_number):
    url = page_number
    html = ur.urlopen(url)
    soup = BeautifulSoup(html, "html.parser")
    for s in soup.find_all("a"):
        if str(s).find("item-card__title-anchor") > 0:
            url = s.get("href")
            img_search(url)

for i in range(430,515):
    print("{}ページ目を検索中...".format(i))
    url = "dummyurl?page=" + str(i) #試したい方は実際のURLを入力してください
    page_access(url)
```

　「3Dモデル」タグを含む商品を探すと膨大になるため、まずOpenCVによる顔認識を行い、それにヒットしたものを候補としていきます。顔認識はアニメ顔専用のライブラリである[lbpcascade_animeface](https://github.com/nagadomi/lbpcascade_animeface)が公開されており、かなり助かりました。このあたりは[自分の過去記事](https://qiita.com/hibit/items/16e0e71f9438f05a19ae)で詳しく触れています。

　また、これだけではアバター以外の商品であってサムネに顔を含むものも検出されてしまいます（衣装等のサムネイルはサンプル画像として他アバターの顔を含むものが多いです）。そのため、原始的ではすが、明らかに衣装やアクセサリーであろう単語をタイトルに含むアイテムは除外させてもらうことにしまいた。具体的なNGワードは以下の通りです。

```python
    words = ['専用','衣装','水着','シャツ','テクスチャ','着物','コスチューム','ドレス',
            'ワンピース','ヘア','髪型','VRoid','Vroid','VROID',]
```

　このスクレイピングを回す時に、画像の番号とURLを紐付けたcsv（上のコードだと`exclude_data.csv`）を生成しておきます。

# 機械学習

　無事に顔画像データが3,253枚取得できたので、今度はこれらの間の類似度を計算していきます。計算方法としては主成分分析を用いました。

```python
import csv
import cv2
import numpy as np
from sklearn.decomposition import PCA

with open('imglist_exclude.csv',encoding='utf-8') as f:
    reader = csv.reader(f)
    data = [row for row in reader]

max_range = 3253
n_comp = 30

mat = np.zeros((max_range,196608))

for i in range(max_range):
    img = cv2.imread('exclude_data/{}.png'.format(i))
    img = np.array(img)/256
    img = np.reshape(img,(256*256*3))
    mat[i] = img

pca = PCA(n_components=n_comp,whiten=True)
pca.fit(mat)
pca_res = pca.transform(mat)

for i in range(max_range):
    org = pca_res[i]
    dist_list = []
    for j in range(max_range):
        dist = np.linalg.norm(org-pca_res[j])
        dist_list.append([dist,j])
    dist_list.sort()
    for j in range(9):
        data[i].append(dist_list[j][1])

with open('new_data.csv', 'w', encoding='utf-8', newline='') as f:
    writer = csv.writer(f)
    writer.writerows(data)
```

　さきほど生成されたcsvに、類似度が最も近い9個の画像へのリンクを追記して新たにcsv（上のコードだと`new_data.csv`）として書き出します。

　画像の類似度を計算する一番プリミティブな手段は、画像データをベクトルとみなしてそれらの間のユークリッド距離を計算する方法ですが、これだと同じ画像が平行移動しているような場合でも大きな距離の違いと認識してしまい、人間の目から見て自然な類似候補の選択ができなくなってしまう恐れがあります。

　そのため、主成分分析を用いて主成分（画像を構成するおおまかな成分）を比較することで、画像の「雰囲気」を比較できるようにしています。一応、オートエンコーダを利用したニューラルネットワークによる潜在空間での比較も試みたのですが、結果（類似度）にあまり違いが見られないというかむしろ推薦精度が下がるような印象を受けたので、主成分分析で済ませました。主成分分析を用いた画像の特徴量については、これもまた自分の過去記事で恐縮ですが[これ](https://qiita.com/hibit/items/8f0525ab1b616061c630)などが参考になるかもです。

# デプロイ

　類似度が計算できたので、今度はこれをWebアプリとして構築しデプロイしていきます。

　今回はWebフレームワークとしてFlaskを、プラットフォームとしてHerokuを用いました。

　フォルダ構成。（上の`new_data.csv`を`datalist.csv`として入れております）

```
kaogaii
│  .gitignore
│  app.py
│  Procfile
│  requirements.txt
├─models
├─static
|     （大量の画像）
│      datalist.csv
│      index.css
└─templates
       faq.html
       index.html
```
　ルーティング等を司る`app.py`。

```python:app.py
from flask import Flask,render_template,request
import numpy as np
import csv

app = Flask(__name__)

@app.route('/')
def index():
    choice = request.args.get("choice")
    if choice is None:
        choice = "init"
        title = "title"
        url = "url"
        nums = []
        for i in range(9):
            nums.append(np.random.randint(0,3253))
        row1 = nums[0:3]
        row2 = nums[3:6]
        row3 = nums[6:9]
    else:
        with open('static/datalist.csv',encoding='utf-8') as f:
            reader = csv.reader(f)
            l = [row for row in reader]
        url = l[int(choice)][1]
        title = l[int(choice)][2]
        recos = []
        for i in range(9):
            recos.append(l[int(choice)][3+i])
        row1 = recos[0:3]
        row2 = recos[3:6]
        row3 = recos[6:9]
    return render_template("index.html", 
                            row1=row1,
                            row2=row2,
                            row3=row3,
                            choice=choice,
                            title=title,
                            url=url,
                            )

@app.route('/sample')
def sample():
    return render_template("index.html", 
                        row1=[0,1,2],
                        row2=[3,4,5],
                        row3=[6,7,8],
                        choice="init",
                        title=None,
                        url=None,
                        )

@app.route('/faq')
def faq():
    return render_template("faq.html")

if __name__ == '__main__':
    app.run()
```

　メインページとなる`index.html`。

```html:index.html
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="icon" href="static/favicon.jpg" />
    <link rel="stylesheet" href="static/index.css" />
    <title>kaogaii</title>
</head>

<body>
    <div class="center">
        <h1 class="center">BOOTHアイテム紹介サービス「kaogaii」</h1>
        <p>商品を購入の際には、<u>商品ページで利用規約等を確認した上で</u>購入お願いします。</p>
        <p>アバター商品を選ぶようにしていますが、技術的な仕様上、それ以外のモデルも交じっています。ご容赦ください🙇</p>
        {% if choice == "init" %}
        <h2>サムネイルをクリックしてください。</h2>
        {% else %}
        <h2>現在選ばれている商品（クリックすると商品ページに飛びます）</h2>
        <a href="{{url}}" target="_blank">
            <img class="img-selected" src="static/{{choice}}.png" width="512" height="512">
        </a>
        <h3>{{title}}</h3>
        {% endif %}
        <p><input type="button" value="Reset" onclick="location.href='./'"></p>
        {% if choice != "init"%}
        <h3>↓↓この顔に似ている候補↓↓</h3>
        {% endif %}
        <p>
            {% for row in row1 %}
            <img class="img-choise" src="static/{{row}}.png" onclick="location.href='./?choice={{row}}'" width="256" height="256">
            {% endfor %}
        </p>
        <p>
            {% for row in row2 %}
            <img class="img-choise" src="static/{{row}}.png" onclick="location.href='./?choice={{row}}'" width="256" height="256">
            {% endfor %}
        </p>
        <p>
            {% for row in row3 %}
            <img class="img-choise" src="static/{{row}}.png" onclick="location.href='./?choice={{row}}'" width="256" height="256">
            {% endfor %}
        </p>
        <p>作者：<a href="https://twitter.com/hibit_at" target="_blank">@hibit_at</a></p>
        <p><a href="./faq">FAQと更新履歴</a></p>
    </div>
</body>

</html>
```

　上を見てわかる通り、画像データや類似度のデータについては、本当は他にストレージやDBサーバーを立てた方が良いのですが、面倒臭かったので**すべてのデータ（3,000枚超の顔画像を含む）をstaticフォルダにぶちこむ**という男気あるソリューションを実行しました。さすがに容量が足りないかなと思いましたが、なんと360MBぐらいだったら（警告は出ましたが）デプロイできました。ありがとうHeroku。

　Flaskは初めて使ってみたのですが、Djangoより手軽でいいですね。Pythonだけでミニマムサービスを作るなら最良の選択肢かなと思います。以下の記事に大いに助けられました。

[Webアプリ初心者のFlaskチュートリアル](https://qiita.com/kiyokiyo_kzsby/items/0184973e9de0ea9011ed)

# 今後に向けて

　以下の点を改良したいです。

## ノイズが多い

　顔検出だけではアバター以外のモデルも大量に拾ってしまいます。NGワード方式で対処しているとはいえ、もう少しS/N比を上げたいです。手軽かつ確実なのは人力でNGリストを作ることですが、細かく対処していくとキリがないし、人力ではまだいつか限界を迎えそうなので、なるべく機械的に処理できるテクニックを考えたいです。

## 推薦の袋小路がある

　上記のアルゴリズムだと、白っぽい画像や淡い画像が出やすいで、どのランダム候補から出発してもそのようなモデルに収束しやすいです。色々なモデルを紹介するという観点からは良くないので、うまく推薦先がばらけるようなアルゴリズムが必要になりそうです。

# まとめ

　今後とも[kaogaii](https://kaogaii.herokuapp.com/)をよろしくお願いします。また、色々なアバターを買ってモデラーさん達を応援しましょう！
　
　提案や要望は[GitHubリポジトリ](https://github.com/hibit-at/kaogaii)までプルリクまたはイシューの形でお願いします。