[アドカレ2025]Amazon RekognitionとOpenCVで「顔の変化」を検出してみた

プロローグ

「今日の私、いつもとどこが違うかわかる?」

この一言に、思わず背筋が伸びた人も少なくないことでしょう。
そして、この試練を100点満点で乗り越えられる人間は、果たして存在するのでしょうか。

毎日のように顔を合わせ、見慣れているはずの相手であっても
人間の目と記憶だけを頼りに"正解"を導き出すのは、なかなかに至難の業と言えるでしょう。

ということで今回は、この難題に対して人の感覚や勘に頼るのではなく、
システムの力で解決を試みてみようと思います。

やってみた

今回のシステムでは、EC2上でOpenCVとAmazon Rekognitionを用いたスクリプトを実行させて
S3上に置いた「普段の画像」と「いつもとは違う顔の画像」の差分を出していこうと思います。

環境セットアップ

それでは早速実践です。
まずはEC2(Amazon Linux)を起動させ、OpenCVを含めた必要なライブラリをインストールしていきます。

# OSの最新化
yum update -y

# Pythonとpipのインストール
yum install -y python3 python3-devel python3-pip

# pipを最新版へ
python3 -m pip install --upgrade pip

# Pythonライブラリインストール
python3 -m pip install boto3 opencv-python-headless numpy cupy pillow

比較画像の用意

続いて、比較するために「普段の顔画像」と「変化をもたらした顔画像」を用意してS3へ設置します。

構造はこんな感じ↓

S3バケット/
   ├── base/
   └── change/

なお、今回は試作品ということで顔画像はBedrockにイラストとして生成してもらいました。
以下が「普段の顔画像」として使用したものです。

スクリプトの作成

それでは、Amazon RekognitionとOpenCVを用いて顔画像を比較するためのスクリプトを用意します。
今回のスクリプトでは、特に変化が起きやすい髪型・目(アイメイク)・口(リップ)の3点に着目して差分を取得させました。

流れはこんな感じです↓

1. S3に設置した2つの画像からRekognitionのランドマーク検出を用いて顔の各パーツを特定
2. 各パーツの変化を数値化し、閾値以上の変化があれば「変化あり」と判定
3. 変化ポイントを抜き出した画像作成&コメントにして表示

実際のスクリプトはこちら

import boto3
import cv2
import numpy as np

# AWS クライアント
s3 = boto3.client('s3', region_name='ap-northeast-1')
rekognition = boto3.client('rekognition', region_name='ap-northeast-1')

# S3 設定
BUCKET = '[S3バケット名]'
BASE_KEY = 'base/[設置画像].png'
NEW_KEY = 'change/[設置画像].png'

# 閾値設定
EYE_COLOR_BIG = 0.5
EYE_INTENSITY_BIG = 0.02
LIP_BIG = 10.0
HAIR_BIG = 800.0

# S3から画像読み込む
def load_image_from_s3(bucket, key):
    obj = s3.get_object(Bucket=bucket, Key=key)
    img_bytes = obj['Body'].read()
    nparr = np.frombuffer(img_bytes, np.uint8)
    return cv2.imdecode(nparr, cv2.IMREAD_COLOR), img_bytes

# rekognitionで顔を検出
def detect_face_bytes(image_bytes):
    resp = rekognition.detect_faces(Image={'Bytes': image_bytes}, Attributes=['ALL'])
    if not resp['FaceDetails']:
        raise ValueError("顔が検出できません")
    return resp['FaceDetails'][0]

# ランドマークから部分(目・口)を取り出す
def extract_region(image, landmarks, points):
    coords = []
    for p in points:
        if p in landmarks:
            lm = landmarks[p]
            coords.append((
                int(lm['X'] * image.shape[1]),
                int(lm['Y'] * image.shape[0])
            ))
    if not coords:
        return None

    xs, ys = zip(*coords)
    x1, y1 = max(min(xs), 0), max(min(ys), 0)
    x2, y2 = min(max(xs), image.shape[1]), min(max(ys), image.shape[0])
    return image[y1:y2, x1:x2]

# 色の変化量計算
def calc_color_change(region1, region2):
    if region1 is None or region2 is None:
        return None
    lab1 = cv2.cvtColor(region1, cv2.COLOR_BGR2LAB)
    lab2 = cv2.cvtColor(region2, cv2.COLOR_BGR2LAB)
    mean1 = cv2.mean(lab1)[:3]
    mean2 = cv2.mean(lab2)[:3]
    return np.sqrt(sum((a - b) ** 2 for a, b in zip(mean1, mean2)))

# アイメイク部分の判定
def calc_eye_intensity_change(region1, region2):
    if region1 is None or region2 is None:
        return None
    gray1 = cv2.cvtColor(region1, cv2.COLOR_BGR2GRAY)
    gray2 = cv2.cvtColor(region2, cv2.COLOR_BGR2GRAY)
    dark1 = np.mean(gray1 < 80)
    dark2 = np.mean(gray2 < 80)
    return abs(dark2 - dark1)

# 差分画像の可視化
def visualize_diff(img1, img2, out_path):
    h = min(img1.shape[0], img2.shape[0])
    w = min(img1.shape[1], img2.shape[1])
    img1 = img1[:h, :w]
    img2 = img2[:h, :w]

    # 差分検出
    diff = cv2.absdiff(img1, img2)
    gray_diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray_diff, (5, 5), 0)
    _, mask = cv2.threshold(blur, 25, 255, cv2.THRESH_BINARY)

    # 新画像をグレースケール化
    gray_img = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
    gray_img = cv2.cvtColor(gray_img, cv2.COLOR_GRAY2BGR)

    # 出力用画像
    result = gray_img.copy()

    # 変化した部分だけ赤くする
    result[mask > 0] = [0, 0, 255]

    cv2.imwrite(out_path, result)

# メイン処理
def main():
    # S3の画像取得
    img_base, bytes_base = load_image_from_s3(BUCKET, BASE_KEY)
    img_new, bytes_new = load_image_from_s3(BUCKET, NEW_KEY)

    # 顔検出
    face_base = detect_face_bytes(bytes_base)
    face_new = detect_face_bytes(bytes_new)

    # ランドマーク取得
    lm_base = {lm['Type']: lm for lm in face_base['Landmarks']}
    lm_new = {lm['Type']: lm for lm in face_new['Landmarks']}

    # アイメイクの差分計算
    eye_points = [
        'leftEyeLeft', 'leftEyeRight', 'leftEyeUp', 'leftEyeDown',
        'rightEyeLeft', 'rightEyeRight', 'rightEyeUp', 'rightEyeDown'
    ]
    eye_base = extract_region(img_base, lm_base, eye_points)
    eye_new = extract_region(img_new, lm_new, eye_points)
    eye_color_diff = calc_color_change(eye_base, eye_new)
    eye_intensity_diff = calc_eye_intensity_change(eye_base, eye_new)

    # リップの差分計算
    lip_points = ['mouthLeft', 'mouthRight', 'mouthUp', 'mouthDown']
    lip_color_diff = calc_color_change(
        extract_region(img_base, lm_base, lip_points),
        extract_region(img_new, lm_new, lip_points)
    )

    # 髪型の差分計算
    top = min(
        int(min(lm['Y'] for lm in face_base['Landmarks']) * img_base.shape[0]),
        int(min(lm['Y'] for lm in face_new['Landmarks']) * img_new.shape[0])
    )
    gray_base = cv2.cvtColor(img_base[0:top, :], cv2.COLOR_BGR2GRAY)
    gray_new = cv2.cvtColor(img_new[0:top, :], cv2.COLOR_BGR2GRAY)
    hair_diff = np.sum(cv2.absdiff(gray_base, gray_new)) / 255

    # 数値差分出力
    print("=== 数値差分 ===")
    print({
        "eye_makeup": {"color_change": eye_color_diff, "intensity_change": eye_intensity_diff},
        "lip": {"color_change": lip_color_diff},
        "hair": {"difference_score": hair_diff}
    })

    # コメント判定
    comments = []

    if lip_color_diff is not None and lip_color_diff > LIP_BIG:
        comments.append("リップの色がいつもと違う")

    if ((eye_intensity_diff is not None and eye_intensity_diff > EYE_INTENSITY_BIG) or
        (eye_color_diff is not None and eye_color_diff > EYE_COLOR_BIG)):
        comments.append("アイメイクが変わってる")

    if hair_diff > HAIR_BIG:
        comments.append("髪型が変わってる")

    # コメント出力
    print("\n=== コメント ===")
    if not comments:
        print("(検出できる変化なし)")
    else:
        print(" / ".join(comments))

    visualize_diff(img_base, img_new, "diff.png")
    print("\ndiff.png を出力しました")

if __name__ == "__main__":
    main()

差分を可視化させる

さて、これで差分の抽出はできるようになりました。
せっかくなので、抽出された差分をHTMLで表示させてみます。

今回は簡易的にローカルサーバで表示させたので、S3に置いた画像を現在のディレクトリへ持ってきて以下コードを設置します。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Lover Diff Checker</title>
  <style>
    body {
      background: #f6f1e7;
      color: #222;
      font-family: "Helvetica Neue", Arial, sans-serif;
      margin: 0;
      padding: 20px;
    }

    h1 {
      text-align: center;
      margin-bottom: 30px;
    }

    p {
      font-size: 2em;
    }

    .row {
      display: flex;
      justify-content: center;
      gap: 30px;
      flex-wrap: wrap;
    }

    .image-box {
      text-align: center;
    }

    .image-box p {
      margin-bottom: 10px;
      font-weight: bold;
    }

    img {
      max-width: 420px;
      width: 100%;
      border: 4px solid #000;
      background: #fff;
      box-shadow: 0 6px 12px rgba(0,0,0,0.2);
    }

    #comments {
      margin-top: 40px;
      padding: 15px;
      text-align: center;
      font-size: 1.3em;
      font-weight: bold;
      background: #fff8c6;
      border: 2px solid #000;
      max-width: 800px;
      margin-left: auto;
      margin-right: auto;
    }
  </style>
</head>
<body>

<h1>差分チェック結果</h1>

<div class="row">
  <div class="image-box">
    <p>普段</p>
    <img src="base.png">
  </div>

  <div class="image-box">
    <p>変化あり</p>
    <img src="change2.png">
  </div>

  <div class="image-box">
    <p>差分</p>
    <img src="diff.png">
  </div>
</div>

<div id="comments">コメント読み込み中...</div>

<script>
fetch("result.json")
  .then(res => res.json())
  .then(data => {
    const el = document.getElementById("comments");
    if (!data.comments || data.comments.length === 0) {
      el.textContent = "特に変化は見つかりませんでした";
    } else {
      el.textContent = data.comments.join(" / ");
    }
  })
  .catch(() => {
    document.getElementById("comments").textContent =
      "コメントを読み込めませんでした";
  });
</script>

</body>
</html>

その後セキュリティグループで8080ポートを許可して以下コマンドでローカルサーバを起動させます。

python3 -m http.server 8080

そして「 http://[EC2のパブリックIP]:8080/ 」にアクセスすると、差分をわかりやすく可視化させることができます。

実行してみる

それでは、実際に変化を加えた画像を用意して差分を出力できるのか試してみます。
システムに頼る前に変化を見つけられるか、是非一緒に試してみてください。

レベル1

まずはこの画像。
これは序の口。人間の目でも簡単に気付くことはできますよね。

正解は、髪型です。
作成したシステムでもしっかりと変化を抽出してくれました。

レベル2

続いてこちらの画像。
変化の部分としては定番ですが、気付ける人はグッと減ったのではないでしょうか。

正解は、リップの色です。
こちらもきちんと変化を特定してくれました。

レベル3

最後はこちらの画像。
これに気付ける強者は稀かと思われますが、果たして。

正解は、アイメイクの色です。
これまたきちんと変化を見つけ出してくれました。素晴らしい!

最後に

以上、無事に相手の変化をぴしゃりと言い当てることができるようになりました。

しかし、これで安心してはいけません。
今回はわかりやすく変化を与えましたが、実際の人間はそう簡単にはいかないものです。

引き続き試練を乗り越えるべく、アンテナを張っておくのが吉ですね。

・・・

そしてそして、本日をもって アドカレ2025は無事完遂です!
振り返ってみるとあっという間でしたが、毎日様々な記事が投稿されて楽しかったですね🫶🏻

お忙しい中、筆を執って記事を投稿してくださったみなさま、
そして毎日楽しみに読んでくださったみなさまに、心から感謝いたします。

それではまた来年もお会いしましょう!
みなさまに素敵なクリスマスが訪れますように・・・💫

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA