ファッションサイト、WEARのスナップ写真の姿勢をクラスタリング

ファッションサイト[WEAR](https://wear.jp/)では、ファッションコーディネイトサイトで様々な人のスナップ写真がアップロードされています。見てみると、モデルの方々は正面に直立不動でただ立っているというよりは、少し横に向いたり、服が見えやすいように少し片足上げるなど、立ち方に工夫があります。今回はそのスナップ写真のモデルの姿勢推定を行い、その推定結果をクラスタリングしてみました。

# 姿勢推定モデル
https://github.com/ildoonet/tf-pose-estimation

こちらのモデルを利用させていただきました。以下の関数にて、画像のパスを与えると身体の各点（`body_parts`）のx座標とy座標を結合したリストを返します。すべての画像においてすぺての点の推定できるわけではないので、目や耳を除いた骨格の部分をすべて（`max_n_body_parts`）推定できたものについてのみ結果を返しています。

```python

from tf_pose import common
import cv2
import numpy as np
from tf_pose.estimator import TfPoseEstimator
from tf_pose.networks import get_graph_path, model_wh

# 先にモデルをロードしておく
model = 'mobilenet_thin'
resize = '432x368'

w, h = model_wh(resize)
if w == 0 or h == 0:
    e = TfPoseEstimator(get_graph_path(model), target_size=(432, 368))
else:
    e = TfPoseEstimator(get_graph_path(model), target_size=(w, h))

def img2vec(estimator, w, h, img_path, resize=resize, local_file=True):
    max_n_body_parts = 14 # 目や耳を省く
    resize_out_ratio = 4.0

    if local_file:
        image = common.read_imgfile(img_path, None, None)
        if image is None:
            image = requests.get(img_path).text
    else:
        res = requests.get(img_path)
        image = np.array(Image.open(BytesIO(res.content)).convert('RGB'))
        
    humans = estimator.inference(image, resize_to_default=(w > 0 and h > 0), upsample_size=resize_out_ratio)

    image = TfPoseEstimator.draw_humans(image, humans, imgcopy=False)
    image_h, image_w = image.shape[:2]

    dfs = pd.DataFrame(index=[])
    columns = ['human', 'point', 'x', 'y']

    xx = 0
    if len(humans) != 1: return

    for human in humans: # 実際は一人しかいない
        xx = xx + 1

        for m in human.body_parts:
            body_part = human.body_parts[m]
            center = (int(body_part.x * image_w + 0.5), int(body_part.y * image_h + 0.5))
            list = [[xx, m, center[0],center[1]]]
            df = pd.DataFrame(data=list, columns=columns)

            dfs = pd.concat([dfs, df])

    dfs = dfs[dfs['point'] < max_n_body_parts]
    if len(dfs) != max_n_body_parts: return
    
    return np.array(dfs.x).tolist() + np.array(dfs.y).tolist()
```

また、画像中の身体の位置を補正するために、x座標とy座標それぞれでmin-maxの正規化をかけるので、実際はベクトル化の部分の処理は以下のようになりました。

```python
def min_max_norm(l):
    max_ = max(l)
    return [l_ / max_ for l_ in l]

vec = img2vec(e, w, h, f"{file_path}", resize="432x368", local_file=False)
min_max_norm(vec[:14]) + min_max_norm(vec[14:]) # このベクトルが一つの画像に対するベクトル（リスト型）
```

# スナップ写真をベクトル化
今回は、メンズのスナップ写真とレディースのスナップ写真に分けてデータを作りました。また、冬服はコートやロングスカートやマフラーなどで姿勢推定の難易度が少し上がる気がしたので、夏の期間に絞って画像を集めました。

- https://wear.jp/men-coordinate/?from_month=6&to_month=8&pageno=hoge
- https://wear.jp/women-coordinate/?from_month=6&to_month=8&pageno=hoge

それぞれ、`men_vecs`と`women_vecs`という変数名のリストに先程の関数で作られるベクトルをappendしていったところ、それぞれ708個と374個のベクトルが集まりました。

# クラスタリング（K-means）
メンズとレディースそれぞれのベクトルの集合をクラスタリングしていきます。姿勢の座標特有のベクトル化やクラスタリングの手法がよく分からなかったこともあり、x座標とy座標を結合したベクトルをその空間上のk-meansによるクラスタリングを行いました。

## クラスタ数の決定

まずは、クラスタ数（`k`）を決めるために、クラスタ数ごとの損失のようなものを計算します。

[sklearnのKMeans](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html)によると、

>inertia_ : float
>Sum of squared distances of samples to their closest cluster center.

とのことなので、この値（各点の最寄りの重心からの距離の総和）をその損失のようなものとして、各クラスタ数ごとにプロットします。

```python
from sklearn.cluster import KMeans

# kを変えて距離の総和を計算
errors = []
for k in range(1, 14):
    kmeans_model = KMeans(n_clusters=k, random_state=0).fit(np.array(men_vecs)) # もしくはwomen_vecs
    errors.append(kmeans_model.inertia_)
plt.plot(errors)
```

| メンズ画像のクラスタリング | レディース画像のクラスタリング |
|:-----------|:------------|
| ![スクリーンショット 2021-01-10 15.31.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/5aec7717-f432-8851-9917-2c2492c64572.png) | ![スクリーンショット 2021-01-10 15.33.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/edbec549-cf99-fad4-13cd-a53c5affff94.png) |

これ以降は`men_vecs` もしくは`women_vecs`を`vecs`に置き換えたものとします。

```python
vecs = men_vecs
# vecs = women_vecs
```

## クラスタリングのプロット（PCA）
先程のプロットから、今回は`k = 3`とすることにしました。クラスタごとのデータの散らばりを主成分分析を用いた2次元のプロットで確認します。

```python
k = 3
kmeans_model = KMeans(n_clusters=k, random_state=0).fit(np.array(vecs))
labels = kmeans_model.labels_
```
クラスタごとに色を塗り分ける設定をしています。重心はすべて黒にしました。

```python

from sklearn.decomposition import PCA

pca = PCA()
pca.fit(np.array(vecs))
feature = pca.transform(np.array(vecs))
centroids_pca = pca.transform(kmeans_model.cluster_centers_)

# NOTE: クラスタの重心を別の色に割り当てる
color_codes = list(sns.color_palette(n_colors=k).as_hex())
colors = [color_codes[label] for label in labels]
colors += ['#000000' for i in range(k)]

plt.figure(figsize=(6, 6))
for x, y in zip(feature[:, 0], feature[:, 1]):
    plt.text(x, y, '', alpha=0.8, size=10)
features = np.append(feature, centroids_pca, axis=0)
plt.scatter(features[:, 0], features[:, 1], alpha=0.8, color=colors)
plt.show()
```

| メンズ画像のクラスタリングごとのPCAのプロット |レディース画像のクラスタリングごとのPCAのプロット | 
|:-----------|:------------|
| ![スクリーンショット 2021-01-10 15.41.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/b73a7f46-49b5-39cc-7816-67af3eabc30f.png)  |  ![スクリーンショット 2021-01-10 15.42.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/22c5aacb-24a8-a7bc-568e-36bf56e1cb74.png)  |


# クラスタの重心を姿勢にプロット
ここまでで、姿勢推定のクラスタリングが完了したので、それらのクラスタの重心をベクトルからx座標とy座標の姿勢のプロットに変換したいと思います。各body_partを散布図の点で表して、実際の骨格になるように棒グラフの棒で結んでいます

```python
def show_poses(vecs_list, m=50):
    n_poses = len(vecs_list)
    fig, axes = plt.subplots(n_poses, 1, figsize=(276/m, 368/m*n_poses))
    for i, vecs in enumerate(vecs_list):
    
        x, y = vecs[:14], vecs[14:]
        links = [[0, 1], [1, 2], [2, 3], [3, 4], [1, 5], [5, 6], [6, 7], [1, 8], [8, 9], [9, 10], [1, 11], [11, 12], [12, 13]]

        axes[i].scatter(x, [-y_ for y_ in y]) if n_poses > 1 else plt.scatter(x, [-y_ for y_ in y])
        for l in links:
            axes[i].plot([x[l[0]], x[l[1]]], [-y[l[0]], -y[l[1]]]) if n_poses > 1 else plt.plot([x[l[0]], x[l[1]]], [-y[l[0]], -y[l[1]]])
```
試しにレディースの最初の画像の推定結果を表示してみると次のようになりました。おしゃれな立ち姿が想像できます

```python
show_poses([women_vecs[0]])
```
![スクリーンショット 2021-01-10 15.52.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/0951c001-e58c-17da-72e3-1779e3e7022c.png)

以下が、クラスタの重心を姿勢のプロットに変換した結果になります

```python
show_poses([v.tolist() for v in kmeans_model.cluster_centers_])
```


|カテゴリ|クラスタ1|クラスタ2|クラスタ3|
|:------------|:-----------|:------------|:------------|
|メンズ|![スクリーンショット 2021-01-10 16.04.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/e19bcaff-2012-493d-51a7-b11dcec91902.png)|![スクリーンショット 2021-01-10 16.04.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/a30125a7-2ea8-2bbc-3ebc-c17fe3810a3c.png)|![スクリーンショット 2021-01-10 16.07.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/d0b5f972-11d5-b5e6-9946-a2aa7353330b.png)|
|レディース|![スクリーンショット 2021-01-10 16.00.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/e0ba8efa-7c77-fad4-5bd0-e43c42fa13a5.png)|![スクリーンショット 2021-01-10 16.02.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/0399dd74-df78-560d-3cc2-c6bc8f4ac079.png)|![スクリーンショット 2021-01-10 16.03.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638747/312465cd-fb65-df12-4b0a-f9a185e8b8fc.png)|

メンズの3つ目なんかは斜めに立って片足伸ばしてるんだろうなぁとわかるんですが、全体的にはっきりしない結果になりました

# おわりに

手法の改善点として考えられるのは、

- ベクトル化：単純なx座標とy座標の結合ベクトルは単純すぎたかもしれない
- 正規化：x座標とy座標を両方0から1に押し込めたが、実際はそれらの比率が異なる（人間なので縦長になる）

後者の話は記事後半の姿勢のプロットでも、実際の画像より横長になっているようなので、何か良い方法があればいいなと考えております