公開APIで配布されている人物プロフィール画像が約1.4万枚手に入ったので、MediaPipe の Face Landmarker で顔を計測して「目が大きい順」「丸顔順」といったランキングを作ってみました。
結果から言うと、最初に出てきたのは顔のランキングではありませんでした。
- 「目が細い人ランキング」→ 目を閉じた瞬間の写真が並ぶ
- 「唇が厚い人ランキング」→ 口をすぼめた写真が並ぶ
- 「丸顔ランキング」→ 顔を上に向けた写真が並ぶ
顔ではなく表情と姿勢を測っていたわけです。この記事は、そこから測定として成立させるまでに踏んだ手順と、途中で見つかった交絡の実測値をまとめたものです。
データセットの性質上、具体的な出典は伏せます。手法だけ読んでもらえれば十分成立する内容です。
1. 何を測っているのか
Face Landmarker は顔に478点のランドマークを返します。そこから比率を作ります。絶対座標では画像サイズに依存するので、すべて顔幅で正規化します。
def features(lms, w, h):
p = [(l.x * w, l.y * h) for l in lms]
face_w = dist(p, CHEEK_L, CHEEK_R)
face_h = dist(p, FACE_TOP, CHIN)
ear_l = dist(p, L_EYE_UP, L_EYE_LO) / dist(p, L_EYE_IN, L_EYE_OUT)
ear_r = dist(p, R_EYE_UP, R_EYE_LO) / dist(p, R_EYE_IN, R_EYE_OUT)
mouth_w = dist(p, MOUTH_L, MOUTH_R)
return {
"eye_open": (ear_l + ear_r) / 2, # 細目 <-> ぱっちり
"eye_size": (dist(p, L_EYE_IN, L_EYE_OUT)
+ dist(p, R_EYE_IN, R_EYE_OUT)) / 2 / face_w,
"eye_spacing": dist(p, L_EYE_IN, R_EYE_IN) / face_w, # 寄り目 <-> 離れ目
"lip_thick": (lip_up + lip_lo) / mouth_w, # 薄い唇 <-> 厚い唇
"mouth_size": mouth_w / face_w,
"face_ratio": face_h / face_w, # 丸顔 <-> 面長
"jaw_width": dist(p, JAW_L, JAW_R) / face_w,
"nose_width": dist(p, NOSE_L, NOSE_R) / face_w,
}
ここまでは素直です。問題はこの先。
2. 一番大きい交絡は「笑顔」だった
「唇が厚い人ランキング」の上位を目視したら、キス顔と口をすぼめた写真ばかりでした。逆に「唇が薄い」上位は満面の笑み。
笑うと口が横に広がるので、lip_thick = 唇の厚み / 口の横幅 の分母が大きくなって薄く出ます。目も同じで、笑うと細くなります。
そこで笑顔の強さ(口角がどれだけ持ち上がっているか)と口の開きを別途測って、相関を見ました。
| 測っている軸 | 笑顔の強さとの相関 |
|---|---|
lip_thick(唇の厚み) |
r = -0.78 |
mouth_size(口の大きさ) |
r = +0.82 |
顔の造作を測っているつもりが、実際には分散の6割前後を表情で説明できてしまう状態でした。
対処: 表情を説明変数にした回帰の残差を使う
補正は単純な線形回帰で足りました。
import numpy as np
def remove_expression(target, smile, mouth_open):
"""表情で説明できる分を引いた残差を返す"""
X = np.column_stack([smile, mouth_open, np.ones(len(target))])
coef, *_ = np.linalg.lstsq(X, target, rcond=None)
return target - X @ coef
これで lip_thick と笑顔の相関は r = 0.00 になりました。
ポイントは、表情の指標を「軸として出さない」ことです。 補正のためだけに測って、ランキングには使いません。混ぜると「よく笑う人ランキング」が紛れ込みます。
3. 姿勢が崩れた写真は測る前に捨てる
顔幅で正規化している以上、横を向いた顔は比率が壊れます。ランドマークは返ってくるので、エラーにならず静かに間違った値が入るのが厄介でした。
閾値は上位・下位の顔を実際に並べて目視しながら決めました。
MAX_YAW = 0.15 # 鼻の左右ズレ / 顔幅
MAX_ROLL = 0.22 # 目の線の傾き(rad) 約13度
PITCH_RANGE = (0.30, 0.62) # 目->鼻先 が 目->顎 のどのあたりか
MIN_EYE_OPEN = 0.18 # 未満は目を閉じている
MIN_MOUTH = 0.26 # 未満は口をすぼめている
MAX_MOUTH_OPEN= 0.13 # 超えると大口を開けていて唇が測れない
yaw は「鼻先が両頬の中点からどれだけ横にずれているか」で取れます。追加のモデルは要りません。
mid_x = (p[CHEEK_L][0] + p[CHEEK_R][0]) / 2
yaw = abs(p[NOSE_TIP][0] - mid_x) / face_w
**14,152枚中、採用できたのは6,965枚(49%)**でした。半分捨てることになりますが、捨てないとランキングが機能しません。
4. 出力はパーセンタイルにする
生の比率(eye_size = 0.283 など)を見せられても大小の感覚がつかめません。全体の中での順位をパーセンタイルに直して持たせます。
order = np.argsort(values)
pct = np.empty(len(values))
pct[order] = np.linspace(0, 100, len(values))
副次的な効果として、軸ごとにスケールが違う問題も同時に消えます。
5. 「似ている人」を出すときの落とし穴
顔の類似度は SFace の埋め込みのコサイン類似度で出しました。ここで基準値を測っておかないと解釈できません。
無関係な人どうしをランダムに4,000ペア取って測ったところ:
別人どうしの類似度 平均 0.105 標準偏差 0.117
95パーセンタイル 0.308 最大 0.503
SFace で同一人物とみなす一般的な閾値は 0.36 前後です。つまり 0.3 前後は「別人でもよく出る値」 で、ここを「似ている」として並べると意味のないリストになります。
この基準を取らずに実装していたら、類似度0.10の相手を堂々と「似ている人」として表示していました。実際、最初はそうなっていました。
6. argpartition と argsort を組み合わせるときのインデックス
上位K件を高速に取るのに argpartition → argsort の二段でやったのですが、類似度の値を間違ったインデックスで引いていて、降順に並んでいませんでした。
# 誤り
part = np.argpartition(-sims, K, axis=1)[:, :K]
order = np.argsort(-sims[rows, part], axis=1)
top = part[rows, order]
tsim = sims[rows, order] # ← order は part の中での位置。元の列番号ではない
# 正しい
tsim = np.take_along_axis(sims, top, axis=1)
order は「part の中での並び順」であって元の行の列番号ではありません。top で引き直す必要があります。
出力を目視するまで気づきませんでした。類似度が 0.47, 0.10, 0.30, 0.50 と並んでいたのが発見のきっかけです。ソート済みのはずの列がソートされていないかは、assert を1行入れておくと早く気づけます。
assert all(s == sorted(s, reverse=True) for s in sims_list)
7. スコアがデータ量と交絡していないか確認する
複数枚の顔を平均して重心を作り、別の写真との一致度を出す、ということをやりました。この「一致度」を指標として出そうとしたのですが、枚数と相関していないかを先に確認しました。
| 一致度と相関を取った相手 | r |
|---|---|
| 使った顔の枚数 | +0.46 |
| 元データの一貫性 | +0.46 |
枚数が少ないと重心がぶれて、機械的に一致度が下がります。つまり**「一致度が低い」が「対象の性質」ではなく「データが足りない」を意味してしまう**状態でした。
条件を振って交絡が消える境界を探しました。
| 条件 | 件数 | 枚数との相関 |
|---|---|---|
| 全件 | 464 | +0.467 |
| 顔10枚以上 | 249 | +0.303 |
| 顔15枚以上 | 145 | +0.006 |
| 顔20枚以上 | 90 | +0.024 |
15枚を超えると枚数の影響が消えます。指標を出すのは15枚以上のときだけにして、それ以外は数値を出さない方針にしました。
「出せるものは全部出す」より「信用できる範囲だけ出す」ほうが、後で自分が数字を信じられます。
8. 数字の見せ方で意味が反転する
一致度をユーザーに見せるとき、コサイン類似度をそのまま出しても伝わらないので、パーセントに変換する関数を通していました。これが罠でした。
# 診断結果を「親しみやすく」見せるための変換
# cos 0.5 -> 80%, cos 0.7 -> 95%
この目盛りだと cos 0.639 が 90.4% になります。「ほぼ一致」に見えますが、実際には中程度の類似でしかありません。伝えたかったのは「けっこう違う」なのに、表示は真逆でした。
最終的に母集団の中での相対位置に変えました。
同じ条件で測れた1,180件の中で、一致度が高い順に上位16%の位置です
絶対値に意味がない指標は、パーセントにせず順位で見せたほうが誠実です。
まとめ
顔ランドマークの計測自体はライブラリが全部やってくれます。手こずったのは全部その周辺でした。
- 表情が造作より効く — 笑顔との相関 r=-0.78 / +0.82。回帰の残差で除去
- 姿勢が崩れた顔はエラーを出さずに間違った値を返す — 明示的に弾く。半分捨てた
- 類似度は基準値を測らないと解釈できない — 別人どうしで平均0.105、95%点0.308
- 上位K件の抽出はインデックスを間違えやすい — 目視と assert
- 指標がデータ量と交絡していないか確認する — 消える境界まで条件を絞る
- 絶対値に意味がない数字はパーセントにしない — 相対位置で見せる
どれも「モデルの精度」ではなく「測定の設計」の話です。精度の高いモデルを使っても、測る対象を取り違えていたら出てくるのは別のランキングになります。